parse-api.ts 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365
  1. import { environment } from '../../../environments/environment';
  2. import { tsOf } from '../time';
  3. import { prettifyStorageKey } from '../s3-link';
  4. import { Scope } from '../acl';
  5. import { FmodeApi } from './fmode-api';
  6. import {
  7. Agent,
  8. AgentStatus,
  9. ChatThreadRecord,
  10. ChatThreadUpsert,
  11. WorkspaceRecord,
  12. WorkspaceUpsert,
  13. Artifact,
  14. ArtifactJob,
  15. AuthSession,
  16. ChannelConfig,
  17. Deliverable,
  18. DeliverableGroup,
  19. FileEntry,
  20. MatrixProvision,
  21. Me,
  22. SessionFile,
  23. OrgMembership,
  24. OrgUser,
  25. OrgUserPage,
  26. Task,
  27. UploadTicket,
  28. WorkGroup,
  29. UsageSession,
  30. } from './models';
  31. /**
  32. * 真实数据源实现 —— 走 fmode 平台云函数 + REST(《API-契约.md》)
  33. *
  34. * 依据《数据访问层设计.md》§一/§二:本类是 `FmodeApi` 的**真实实现**,
  35. * 由 `api.provider.ts` 在 `environment.useMockData === false` 时装配。
  36. *
  37. * ## 约定
  38. *
  39. * - 云函数统一入口 `POST https://server.fmode.cn/api/functions`
  40. * (头 `X-Parse-Application-Id` / `X-Parse-Session-Token`;体 `{id, token, params}`)
  41. * - 错误处理:HTTP 200 但 `body.code` 为 401/403 → 视为业务错误并抛出,
  42. * 文案取 `msg` → `mess` → `message`
  43. * - 云函数返回的 `host` / `channels` / `balance` 是**字符串形态 JSON**,
  44. * 映射层兼容「已是对象」与「是字符串」两种形态(存量 `parseJsonLoose` 的教训)
  45. *
  46. * ## 前端安全边界(黄金规则 6 / NFR-03)
  47. *
  48. * 前端**不含任何服务端密钥**:无 masterKey、无 OBS AK/SK、无 Matrix 派生密码。
  49. * Matrix 凭据一律由 provision 云函数服务端签发。
  50. *
  51. * ## 一期未实现项
  52. *
  53. * `fetchTasks` / `fetchTaskDetail` 的云函数接收端**尚未实现**(SPEC FR-07b 依赖声明),
  54. * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。
  55. */
  56. /*
  57. * 端点来自环境配置(黄金规则 6:serverURL 只在初始化处配置一次)。
  58. * 见 `src/environments/environment.base.ts`。
  59. */
  60. const FUNCTION_API = environment.endpoints.functionsBase;
  61. const PARSE_BASE = environment.endpoints.parseServer;
  62. const PARSE_APP_ID = environment.endpoints.parseAppId;
  63. /** 云函数 objectId(《API-契约》§二) */
  64. const FN = {
  65. agentStatus: 'xlosi3m46q',
  66. /** 容器重命名(2026-11-05 新建,独立于 agentStatus) */
  67. agentRename: 'AqXGNNVRNb',
  68. deliverables: 'lfYlgU7SkK',
  69. provision: 'RP5Ht55NeX',
  70. tokenSync: 'zl4yU8PAEB',
  71. uploadUrl: 'AlP56LCKFm',
  72. workgroup: 'V1EBgm05y2',
  73. /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */
  74. chatThreads: 'oR5O67i1Cm',
  75. /**
  76. * 文件台账(2026-11-10 新建,Function.name = fmode-file-hub)
  77. *
  78. * 为什么另起一个:老成果函数 `lfYlgU7SkK` 的 report 用字段白名单写入,
  79. * 里面没有 `threadRoot`,容器传了也被静默丢弃,成果无法归属到会话。
  80. * 用户裁决:现有云函数一行不动,新建独立函数走新接口。
  81. */
  82. fileHub: 'THXAOGKiPK',
  83. /**
  84. * 文件正文代理(2026-11-11 新建,Function.name = fmode-file-read)。
  85. *
  86. * 🔴 为什么要它:云盘 s3.fmode.cn 是 public-read 但**没配 CORS**,
  87. * 浏览器 fetch 必被拦(「加载失败:Failed to fetch」),
  88. * .md / .txt 在页内根本渲染不出来。
  89. * 由服务端代取文本,浏览器只调本域(已配 ACAO:*)。
  90. *
  91. * ⚠️ 已部署(2026-11-11,`tocXyQjyh6`)。未部署时此 ID 为空,`readText` 会抛错,
  92. * 调用方回落到「在新窗口打开」。
  93. */
  94. fileRead: 'tocXyQjyh6',
  95. /**
  96. * 组织成员管理(2026-11-10 新建,Function.name = fmodeagent-users)。
  97. *
  98. * 🔴 源码见 `cloud/functions/fmodeagent-users.js`;**部署后**把真实 objectId 填到这里。
  99. * 未部署时该调用会 404,页面显示错误态而不是空白。
  100. */
  101. orgUsers: 'mgR4EA2n5a',
  102. } as const;
  103. /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
  104. function parseJsonLoose<T = Record<string, unknown>>(v: unknown): T | undefined {
  105. if (v == null) return undefined;
  106. if (typeof v === 'object') return v as T;
  107. if (typeof v !== 'string' || !v.trim()) return undefined;
  108. try {
  109. return JSON.parse(v) as T;
  110. } catch {
  111. return undefined;
  112. }
  113. }
  114. export class ParseApi extends FmodeApi {
  115. /**
  116. * 会话令牌读取器 + 请求范围判定器。
  117. *
  118. * - **令牌**由 `SessionService` 唯一持有,**每次调用实时读取**(避免登录/退登后持过期副本)
  119. * - **scope** 由 `acl.scopeFor(me)` 判定(超管 all / 公司管理员 company / 普通用户 mine);
  120. * 🔴 **不得硬编码 `'all'`** —— 那会让每个用户都看到全平台别人的容器(越权)
  121. */
  122. constructor(
  123. private readonly readToken: () => string = () => '',
  124. private readonly readScope: () => Scope = () => 'mine',
  125. ) {
  126. super();
  127. }
  128. /** 当前会话令牌(读取器为空时回落到内部缓存,供单测注入) */
  129. private get sessionToken(): string {
  130. return this.readToken() || this.fallbackToken;
  131. }
  132. /** 仅单测/无 DI 场景使用的回落令牌 */
  133. private fallbackToken = '';
  134. setToken(token: string): void {
  135. this.fallbackToken = token;
  136. }
  137. // ---------- 鉴权 ----------
  138. async sendMobileCode(mobile: string): Promise<void> {
  139. // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功
  140. const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, {
  141. method: 'POST',
  142. body: JSON.stringify({ mobile, company: LOGIN_COMPANY }),
  143. });
  144. const d = (await res.json()) as { code?: number; msg?: string; mess?: string };
  145. if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status));
  146. }
  147. async loginWithMobile(mobile: string, code: string): Promise<AuthSession> {
  148. // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token
  149. const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent(
  150. mobile,
  151. )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`;
  152. const res = await this.rawFetch(url, { method: 'GET' });
  153. const d = (await res.json()) as {
  154. code?: number;
  155. data?: { token?: string };
  156. msg?: string;
  157. mess?: string;
  158. };
  159. const token = d.data?.token;
  160. if (d.code !== 200 || !token) throw new Error(pickMessage(d) ?? '登录失败');
  161. this.setToken(token);
  162. const me = await this.fetchMe(token);
  163. return { token, me };
  164. }
  165. async loginWithToken(token: string): Promise<AuthSession> {
  166. const me = await this.fetchMe(token);
  167. this.setToken(token);
  168. return { token, me };
  169. }
  170. async fetchMe(token: string): Promise<Me> {
  171. // R1:**GET** /parse/users/me(头 X-Parse-Session-Token)
  172. // 🔴 实测教训:POST 会 404(返回 HTML 错误页);契约文档曾误写为 POST。
  173. const res = await this.rawFetch(`${PARSE_BASE}/users/me`, {
  174. method: 'GET',
  175. headers: { 'X-Parse-Session-Token': token },
  176. });
  177. if (!res.ok) throw new Error(`Token 校验失败(HTTP ${res.status})`);
  178. const u = (await res.json()) as Record<string, unknown>;
  179. const objectId = String(u['objectId'] ?? '');
  180. // R2:_User JSON **不透出 relation**,roles 必须单独查(存量 v0.3.3 著名坑:
  181. // 超管因缺 roles 被误判为 company-member → 列表空)
  182. const roles = objectId
  183. ? await this.fetchRoles(token, objectId).catch(() => [] as string[])
  184. : [];
  185. const companyId = (u['company'] as { objectId?: string } | undefined)?.objectId;
  186. // 所属组织名:实测 Company 表有 name(如 E4KpGvTEto → 飞码AI)。
  187. // 取不到时为 undefined → UI 显示占位,**不编造**。
  188. const companyName = companyId
  189. ? await this.fetchCompanyName(token, companyId).catch(() => undefined)
  190. : undefined;
  191. return {
  192. id: objectId,
  193. name: String(u['nickname'] ?? u['username'] ?? ''),
  194. mobile: u['mobile'] ? String(u['mobile']) : undefined,
  195. avatar: u['avatar'] ? String(u['avatar']) : undefined,
  196. companyId,
  197. companyName,
  198. roles,
  199. };
  200. }
  201. private async fetchRoles(token: string, userId: string): Promise<string[]> {
  202. // R2:GET /parse/roles?where={users:Pointer<_User>}&limit=50
  203. const where = JSON.stringify({
  204. users: { __type: 'Pointer', className: '_User', objectId: userId },
  205. });
  206. const res = await this.rawFetch(
  207. `${PARSE_BASE}/roles?where=${encodeURIComponent(where)}&limit=50`,
  208. { headers: { 'X-Parse-Session-Token': token } },
  209. );
  210. if (!res.ok) return [];
  211. const d = (await res.json()) as { results?: { name?: string }[] };
  212. return (d.results ?? []).map((r) => String(r.name ?? '')).filter(Boolean);
  213. }
  214. /** 取组织名(`Company.name`);失败返回 undefined(不回退假值) */
  215. private async fetchCompanyName(token: string, companyId: string): Promise<string | undefined> {
  216. const res = await this.rawFetch(
  217. `${PARSE_BASE}/classes/Company/${encodeURIComponent(companyId)}?keys=name`,
  218. { headers: { 'X-Parse-Session-Token': token } },
  219. );
  220. if (!res.ok) return undefined;
  221. const d = (await res.json()) as { name?: string };
  222. return d.name ? String(d.name) : undefined;
  223. }
  224. // ---------- 数字生命(实况) ----------
  225. async fetchAgents(): Promise<Agent[]> {
  226. const d = await this.callFunction<{ code: number; agents?: unknown[] }>(FN.agentStatus, {
  227. op: 'query',
  228. // 🔴 按角色判定(acl.scopeFor)—— 超管 all / 公司管理员 company / 普通用户 mine。
  229. // 曾硬编码 'all' → 每个用户都拿到全平台 51 个容器(越权)。
  230. scope: this.readScope(),
  231. });
  232. if (d.code !== 200 || !Array.isArray(d.agents)) {
  233. throw new Error(pickMessage(d) ?? '实况数据查询失败');
  234. }
  235. return d.agents.map((raw) => mapAgent(raw as Record<string, unknown>));
  236. }
  237. /**
  238. * 重命名容器(2026-11-04)。
  239. *
  240. * ## 当前状态:**后端接口未实现**
  241. *
  242. * 走**独立云函数** `fmodecontainer-rename`(2026-11-05 上线)。
  243. * 服务端独立校验权限:公司管理员只能改本公司容器,超管可改全部。
  244. *
  245. * 🔴 失败**如实抛错**,不写本地缓存 —— 否则会出现
  246. * 「我这改了、同事看到的还是旧名」的假象。
  247. */
  248. async renameAgent(agentId: string, displayName: string): Promise<Agent | null> {
  249. const d = await this.callFunction<{ code: number; agent?: unknown; msg?: string }>(
  250. FN.agentRename,
  251. { op: 'rename', agentId, displayName: displayName.trim() },
  252. );
  253. if (d.code !== 200) {
  254. throw new Error(pickMessage(d) ?? `重命名失败(服务端返回 ${d.code})`);
  255. }
  256. return d.agent ? mapAgent(d.agent as Record<string, unknown>) : null;
  257. }
  258. async fetchAgentStatus(agentId: string): Promise<Agent | null> {
  259. const d = await this.callFunction<{ code: number; agent?: unknown }>(FN.agentStatus, {
  260. op: 'query',
  261. scope: this.readScope(),
  262. agentId,
  263. });
  264. // 单条查询:code 404/403 → 返回 null(页面自行显示「不存在/无权限」)
  265. if (d.code !== 200 || !d.agent) return null;
  266. return mapAgent(d.agent as Record<string, unknown>);
  267. }
  268. // ---------- Matrix ----------
  269. async provisionMatrix(agentId: string): Promise<MatrixProvision> {
  270. const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
  271. agentId,
  272. });
  273. // 校验规则(《API-契约》§2.3):必须 code===200 && accessToken && userMxId
  274. if (d.code !== 200 || !d.accessToken || !d.userMxId) {
  275. throw new Error(pickMessage(d) ?? 'FMODE 开通失败');
  276. }
  277. return d;
  278. }
  279. async validateMatrix(agentId: string): Promise<MatrixProvision> {
  280. const d = await this.callFunction<MatrixProvision & { code: number }>(FN.provision, {
  281. agentId,
  282. validate: 1,
  283. });
  284. if (d.code !== 200 || !d.accessToken || !d.userMxId) {
  285. throw new Error(pickMessage(d) ?? 'FMODE 凭据校验失败');
  286. }
  287. return d;
  288. }
  289. // ---------- 会话目录(云函数 oR5O67i1Cm / fmodeagent-chatthreads) ----------
  290. /**
  291. * 拉取当前用户的会话目录。
  292. *
  293. * 🔴 云函数按 sessionToken 强制归属当前用户;这里**不传任何用户参数**。
  294. * 返回结构在 function 内已归一(毫秒时间戳、驼峰字段)。
  295. */
  296. async fetchChatThreads(agentId: string, opts?: { includeDeleted?: boolean }): Promise<ChatThreadRecord[]> {
  297. const d = await this.callFunction<{ code: number; threads?: ChatThreadRecord[]; mess?: string }>(
  298. FN.chatThreads,
  299. { op: 'list', agentId, ...(opts?.includeDeleted ? { includeDeleted: 1 } : {}) },
  300. );
  301. if (d.code !== 200) throw new Error(d.mess ?? '会话目录读取失败');
  302. return Array.isArray(d.threads) ? d.threads : [];
  303. }
  304. async upsertChatThreads(items: ChatThreadUpsert[]): Promise<void> {
  305. if (items.length === 0) return;
  306. const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
  307. op: 'upsert',
  308. items,
  309. });
  310. if (d.code !== 200) throw new Error(d.mess ?? '会话目录同步失败');
  311. }
  312. async patchChatThread(input: {
  313. threadRoot: string;
  314. title?: string;
  315. archived?: boolean;
  316. /** 归属工作区;`''` = 移回日常对话(2026-11-01 起上云,换设备可见) */
  317. workspaceId?: string;
  318. }): Promise<void> {
  319. const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
  320. op: 'patch',
  321. ...input,
  322. });
  323. // 404 = 云端还没有这条(本地先建、还没同步过)→ 视为成功,下次 upsert 会补上
  324. if (d.code !== 200 && d.code !== 404) throw new Error(d.mess ?? '会话更新失败');
  325. }
  326. // ---------- 工作区(2026-11-01 上云) ----------
  327. /**
  328. * 拉取工作区列表。
  329. *
  330. * 与工作区相关的 op 都复用同一个云函数 `oR5O67i1Cm`(fmodeagent-chatthreads),
  331. * 不另开函数 —— 它们同属「用户自己的对话组织数据」,部署与回滚应该一起走。
  332. */
  333. async fetchWorkspaces(): Promise<WorkspaceRecord[]> {
  334. const d = await this.callFunction<{ code: number; workspaces?: WorkspaceRecord[]; mess?: string }>(
  335. FN.chatThreads,
  336. { op: 'wsList' },
  337. );
  338. if (d.code !== 200) throw new Error(d.mess ?? '工作区读取失败');
  339. return Array.isArray(d.workspaces) ? d.workspaces : [];
  340. }
  341. async upsertWorkspaces(items: WorkspaceUpsert[]): Promise<void> {
  342. if (items.length === 0) return;
  343. const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
  344. op: 'wsUpsert',
  345. items,
  346. });
  347. if (d.code !== 200) throw new Error(d.mess ?? '工作区同步失败');
  348. }
  349. async removeWorkspace(id: string): Promise<void> {
  350. const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
  351. op: 'wsRemove',
  352. id,
  353. });
  354. if (d.code !== 200) throw new Error(d.mess ?? '工作区删除失败');
  355. }
  356. async removeChatThread(input: { threadRoot: string; roomId?: string; agentId?: string }): Promise<void> {
  357. const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
  358. op: 'remove',
  359. ...input,
  360. });
  361. if (d.code !== 200) throw new Error(d.mess ?? '会话删除失败');
  362. }
  363. // ---------- 交付成果 ----------
  364. async fetchDeliverables(opts?: { agentId?: string; limit?: number }): Promise<Deliverable[]> {
  365. const params: Record<string, unknown> = { action: 'list', limit: opts?.limit ?? 200 };
  366. if (opts?.agentId) params['agentId'] = opts.agentId;
  367. const d = await this.callFunction<{ code: number; deliverables?: unknown[] }>(
  368. FN.deliverables,
  369. params,
  370. );
  371. if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果查询失败');
  372. return (d.deliverables ?? [])
  373. .map((x) => mapDeliverable(x as Record<string, unknown>))
  374. /*
  375. * 🔴 过滤「非成果」记录(用户实测:右栏出现两条没有意义的文档):
  376. *
  377. * · `title` 为空/纯符号 → 没有任何信息,点了也看不懂是什么
  378. * · `title` 恰为 `probe` 且无附件 → 上报器的**探针测试**,不是交付物
  379. *
  380. * 过滤放在映射层(全项目唯一入口),页面不必各自判断。
  381. */
  382. .filter((x) => {
  383. const t = x.title.trim();
  384. if (!t || !/[\p{L}\p{N}]/u.test(t)) return false;
  385. if (/^probe$/i.test(t) && x.artifacts.length === 0) return false;
  386. return true;
  387. });
  388. }
  389. async fetchDeliverablesByProject(limit?: number): Promise<DeliverableGroup[]> {
  390. const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.deliverables, {
  391. action: 'byProject',
  392. limit: limit ?? 200,
  393. });
  394. if (d.code !== 200) throw new Error(pickMessage(d) ?? '成果分组查询失败');
  395. return (d.groups ?? []).map((g) => {
  396. const raw = g as Record<string, unknown>;
  397. const items = Array.isArray(raw['items'])
  398. ? raw['items']
  399. .map((x) => mapDeliverable(x as Record<string, unknown>))
  400. // 与 fetchDeliverables 同一过滤口径(见上)
  401. .filter((x) => {
  402. const t = x.title.trim();
  403. if (!t || !/[\p{L}\p{N}]/u.test(t)) return false;
  404. if (/^probe$/i.test(t) && x.artifacts.length === 0) return false;
  405. return true;
  406. })
  407. : [];
  408. return {
  409. project: raw['project'] ? String(raw['project']) : undefined,
  410. projectName: String(raw['projectName'] ?? '未归类'),
  411. count: Number(raw['count']) || items.length,
  412. items,
  413. };
  414. });
  415. }
  416. // ---------- 任务看板(FR-07b:云函数接收端未实现) ----------
  417. async fetchTasks(_agentId: string): Promise<Task[]> {
  418. // 🔴 不得静默返回空数组(那会把「未接入」伪装成「没有数据」)
  419. throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
  420. }
  421. async fetchTaskDetail(_taskId: string): Promise<Task | null> {
  422. throw new Error('任务看板数据源未就绪:云函数接收端尚未实现(SPEC FR-07b 依赖声明)');
  423. }
  424. // ---------- 上传(OBS / 华为云,统一走 S3 预签名) ----------
  425. async requestUploadUrl(
  426. filename: string,
  427. mimeType: string,
  428. size: number,
  429. ): Promise<UploadTicket> {
  430. const d = await this.callFunction<{
  431. code: number;
  432. uploadUrl?: string;
  433. key?: string;
  434. publicUrl?: string;
  435. headers?: Record<string, string>;
  436. }>(FN.uploadUrl, { filename, mimeType, size });
  437. // 《API-契约》§2.5:缺 uploadUrl 即抛错,不得静默降级为本地 blob
  438. if (d.code !== 200 || !d.uploadUrl || !d.key) {
  439. throw new Error(pickMessage(d) ?? '上传签名获取失败');
  440. }
  441. return {
  442. uploadUrl: d.uploadUrl,
  443. key: d.key,
  444. publicUrl: d.publicUrl ?? `https://s3.fmode.cn/${d.key}`,
  445. headers: d.headers,
  446. };
  447. }
  448. async uploadFile(
  449. file: File,
  450. onProgress?: (pct: number) => void,
  451. ): Promise<{ key: string; publicUrl: string }> { const ticket = await this.requestUploadUrl(
  452. file.name,
  453. file.type || 'application/octet-stream',
  454. file.size,
  455. );
  456. // S3 直传(华为云 OBS 兼容 S3 协议);前端不经手任何 AK/SK
  457. const headers: Record<string, string> = {
  458. 'Content-Type': file.type || 'application/octet-stream',
  459. 'x-obs-acl': 'public-read',
  460. ...ticket.headers,
  461. };
  462. await this.putWithProgress(ticket.uploadUrl, headers, file, onProgress);
  463. return { key: ticket.key, publicUrl: ticket.publicUrl };
  464. }
  465. // ---------- 成果文件搬运队列 ----------
  466. async enqueueArtifactJob(job: {
  467. deliverableId: string;
  468. agentId: string;
  469. source: string;
  470. filename: string;
  471. index: number;
  472. }): Promise<{ id: string }> {
  473. // 只报「传哪个」;地址与头由云函数签好写进队列(客户端不接触密钥)
  474. const d = await this.callFunction<{ code: number; id?: string }>(FN.deliverables, {
  475. action: 'enqueueArtifact',
  476. ...job,
  477. });
  478. if (d.code !== 200 || !d.id) {
  479. throw new Error(pickMessage(d) ?? '搬运任务创建失败');
  480. }
  481. return { id: d.id };
  482. }
  483. async fetchArtifactJobs(agentId: string): Promise<ArtifactJob[]> {
  484. const d = await this.callFunction<{ code: number; jobs?: unknown[] }>(FN.deliverables, {
  485. action: 'pendingArtifacts',
  486. agentId,
  487. });
  488. if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务查询失败');
  489. return (d.jobs ?? []).map((x) => x as ArtifactJob);
  490. }
  491. // ---------- 文件台账(fmode-file-hub,2026-11-10) ----------
  492. async registerFile(entry: FileEntry): Promise<{ id: string; duplicated?: boolean }> {
  493. const d = await this.callFunction<{ code: number; id?: string; duplicated?: boolean }>(
  494. FN.fileHub,
  495. { action: 'registerFile', ...entry },
  496. );
  497. if (d.code !== 200 || !d.id) {
  498. throw new Error(pickMessage(d) ?? '文件登记失败');
  499. }
  500. return { id: d.id, duplicated: d.duplicated };
  501. }
  502. /**
  503. * 读云盘文本文件正文(2026-11-11)。
  504. *
  505. * 走云函数 `fmode-file-read` 代取 —— 云盘没配 CORS,浏览器直连必失败。
  506. *
  507. * @throws 未部署 / 非文本 / 过大 / 网络失败时抛错,调用方自行降级(新窗口打开)
  508. */
  509. async readText(url: string): Promise<string> {
  510. if (!FN.fileRead) throw new Error('文件读取服务未部署');
  511. const d = await this.callFunction<{ code: number; text?: string; msg?: string }>(FN.fileRead, {
  512. action: 'readText',
  513. url,
  514. });
  515. if (d.code !== 200 || typeof d.text !== 'string') {
  516. throw new Error(d.msg ?? '读取失败');
  517. }
  518. return d.text;
  519. }
  520. /**
  521. * 按**群**拉文件台账(2026-11-11,v0.2.0)。
  522. *
  523. * 服务端同时匹配 `groupId` 与 `threadRoot` —— 后者保证**没有 groupId 的
  524. * 存量群内上传记录**也能查到(无需数据迁移)。
  525. */
  526. async fetchFilesByGroup(groupId: string, limit = 200): Promise<SessionFile[]> {
  527. if (!groupId) return [];
  528. const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, {
  529. action: 'listByGroup',
  530. groupId,
  531. limit,
  532. });
  533. if (d.code !== 200) throw new Error(pickMessage(d) ?? '群文件加载失败');
  534. return (d.files ?? []).map((x) => x as SessionFile);
  535. }
  536. /**
  537. * 把容器产出**补归并**到群(2026-11-11,v0.2.0)。
  538. *
  539. * 服务端会把 `threadRoot` 一并改写为群 id —— 这是「不串台」的关键:
  540. * 搬进群之后单聊就查不到了,一条文件只出现在一个页签。
  541. *
  542. * 只接受「本人 + 容器产出 + 当前未归群」的记录,其余静默跳过。
  543. */
  544. async claimFilesToGroup(groupId: string, ids: string[]): Promise<{ claimed: number }> {
  545. if (!groupId || ids.length === 0) return { claimed: 0 };
  546. const d = await this.callFunction<{ code: number; claimed?: number; msg?: string }>(FN.fileHub, {
  547. action: 'claimToGroup',
  548. groupId,
  549. ids: ids.slice(0, 100),
  550. });
  551. if (d.code !== 200) throw new Error(pickMessage(d) ?? '归并失败');
  552. return { claimed: d.claimed ?? 0 };
  553. }
  554. async fetchFilesBySession(threadRoot: string, limit = 100): Promise<SessionFile[]> {
  555. const d = await this.callFunction<{ code: number; files?: unknown[] }>(FN.fileHub, {
  556. action: 'listBySession',
  557. threadRoot,
  558. limit,
  559. });
  560. if (d.code !== 200) throw new Error(pickMessage(d) ?? '会话文件加载失败');
  561. return (d.files ?? []).map((x) => x as SessionFile);
  562. }
  563. // ---------- 工作组 ----------
  564. async createWorkGroup(input: {
  565. name: string;
  566. goal?: string;
  567. memberAgentIds: string[];
  568. }): Promise<{ id: string; roomId: string }> {
  569. const d = await this.callFunction<{ code: number; id?: string; roomId?: string }>(FN.workgroup, {
  570. action: 'create',
  571. name: input.name,
  572. goal: input.goal ?? '',
  573. memberAgentIds: input.memberAgentIds,
  574. });
  575. if (d.code !== 200 || !d.id || !d.roomId) {
  576. throw new Error(pickMessage(d) ?? '创建工作组失败');
  577. }
  578. return { id: d.id, roomId: d.roomId };
  579. }
  580. async listWorkGroups(): Promise<WorkGroup[]> {
  581. const d = await this.callFunction<{ code: number; groups?: unknown[] }>(FN.workgroup, {
  582. action: 'list',
  583. });
  584. if (d.code !== 200) throw new Error(pickMessage(d) ?? '工作组查询失败');
  585. return (d.groups ?? []) as WorkGroup[];
  586. }
  587. async inviteToWorkGroup(groupId: string, agentIds: string[]): Promise<void> {
  588. const d = await this.callFunction<{ code: number }>(FN.workgroup, {
  589. action: 'invite',
  590. groupId,
  591. agentIds,
  592. });
  593. if (d.code !== 200) throw new Error(pickMessage(d) ?? '邀请失败');
  594. }
  595. async removeFromWorkGroup(groupId: string, agentId: string): Promise<void> {
  596. const d = await this.callFunction<{ code: number }>(FN.workgroup, {
  597. action: 'remove',
  598. groupId,
  599. agentId,
  600. });
  601. if (d.code !== 200) throw new Error(pickMessage(d) ?? '移出失败');
  602. }
  603. async archiveWorkGroup(groupId: string): Promise<void> {
  604. const d = await this.callFunction<{ code: number }>(FN.workgroup, {
  605. action: 'archive',
  606. groupId,
  607. });
  608. if (d.code !== 200) throw new Error(pickMessage(d) ?? '归档失败');
  609. }
  610. // ---------- 组织成员管理(2026-11-10) ----------
  611. /*
  612. * 🔴 这组接口**不做任何前端权限判断**:谁能调用由服务端决定,
  613. * 前端只负责把 403 如实显示出来。这样即使前端被改,也越不了权。
  614. */
  615. async listOrgUsers(query: {
  616. keyword?: string;
  617. page?: number;
  618. pageSize?: number;
  619. }): Promise<OrgUserPage> {
  620. const d = await this.callFunction<{ code: number; items?: OrgUser[]; total?: number; page?: number; pageSize?: number }>(
  621. FN.orgUsers,
  622. { op: 'list', ...query },
  623. );
  624. if (d.code !== 200) throw new Error(pickMessage(d) ?? '成员列表加载失败');
  625. return {
  626. items: d.items ?? [],
  627. total: d.total ?? 0,
  628. page: d.page ?? query.page ?? 1,
  629. pageSize: d.pageSize ?? query.pageSize ?? 20,
  630. };
  631. }
  632. async addOrgUser(userId: string): Promise<OrgUser> {
  633. const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, {
  634. op: 'add',
  635. userId,
  636. });
  637. if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '添加成员失败');
  638. return d.item;
  639. }
  640. async updateOrgUser(
  641. userId: string,
  642. patch: { nickname?: string; mobile?: string; password?: string },
  643. ): Promise<OrgUser> {
  644. const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, {
  645. op: 'update',
  646. userId,
  647. ...patch,
  648. });
  649. if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '保存失败');
  650. return d.item;
  651. }
  652. async removeOrgUser(userId: string): Promise<void> {
  653. const d = await this.callFunction<{ code: number }>(FN.orgUsers, { op: 'remove', userId });
  654. if (d.code !== 200) throw new Error(pickMessage(d) ?? '删除失败');
  655. }
  656. async fetchOrgMembership(): Promise<OrgMembership> {
  657. const d = await this.callFunction<Partial<OrgMembership> & { code: number }>(
  658. FN.orgUsers,
  659. { op: 'me' },
  660. );
  661. if (d.code !== 200) throw new Error(pickMessage(d) ?? '查询组织归属失败');
  662. return {
  663. userId: d.userId ?? '',
  664. hasOrg: !!d.hasOrg,
  665. companyId: d.companyId ?? '',
  666. companyName: d.companyName ?? '',
  667. };
  668. }
  669. async ackArtifactJob(input: {
  670. id: string;
  671. ok: boolean;
  672. publicUrl?: string;
  673. error?: string;
  674. }): Promise<void> {
  675. const d = await this.callFunction<{ code: number }>(FN.deliverables, {
  676. action: 'ackArtifact',
  677. objectId: input.id,
  678. ok: input.ok,
  679. publicUrl: input.publicUrl,
  680. error: input.error,
  681. });
  682. if (d.code !== 200) throw new Error(pickMessage(d) ?? '搬运任务回执失败');
  683. }
  684. /** PUT 直传;浏览器 XMLHttpRequest 才能拿到上传进度 */
  685. private putWithProgress(
  686. url: string,
  687. headers: Record<string, string>,
  688. file: File,
  689. onProgress?: (pct: number) => void,
  690. ): Promise<void> {
  691. return new Promise((resolve, reject) => {
  692. const xhr = new XMLHttpRequest();
  693. xhr.open('PUT', url, true);
  694. for (const [k, v] of Object.entries(headers)) xhr.setRequestHeader(k, v);
  695. xhr.upload.onprogress = (e) => {
  696. if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
  697. };
  698. xhr.onload = () => {
  699. if (xhr.status >= 200 && xhr.status < 300) resolve();
  700. else reject(new Error(`上传失败(HTTP ${xhr.status})`));
  701. };
  702. xhr.onerror = () => reject(new Error('上传失败:网络错误'));
  703. xhr.send(file);
  704. });
  705. }
  706. // ---------- 内部 ----------
  707. /**
  708. * 云函数统一调用(《API-契约》§1.1)。
  709. *
  710. * ## 🔴 平台抖动时的表现(2026-10-29 实测)
  711. *
  712. * 平台服务端会出现间歇性故障。实测连续 5 次请求:
  713. * `超时 / 超时 / 503 / 200 / 200`。
  714. *
  715. * 浏览器此时报的是 **CORS 错误**(`No 'Access-Control-Allow-Origin'`),
  716. * 那只是表象 —— 真正原因是服务端 503 或直接断开,
  717. * **连响应头都没返回**,浏览器于是判定为跨域失败。
  718. *
  719. * 所以:
  720. * · 网络层失败(fetch 直接抛)→ 明确告知「连不上平台」,不是用户的操作问题
  721. * · 503 / 502 / 504 → 告知「平台暂时不可用」并带上可重试的意思
  722. * · 其它非 2xx → 保留原状态码
  723. */
  724. private async callFunction<T>(id: string, params: Record<string, unknown>): Promise<T> {
  725. /*
  726. * 平台抖动是**已知会发生**的(实测 5 次里 2 次超时、1 次 503)。
  727. * 瞬时可恢复的错误自动重试一次 —— 用户不该因为一次抖动就看到报错。
  728. * 只重试「网络失败 / 502 / 503 / 504」,业务错误(4xx)不重试。
  729. */
  730. const attempt = async (): Promise<Response> =>
  731. this.rawFetch(FUNCTION_API, {
  732. method: 'POST',
  733. headers: this.authHeaders(),
  734. body: JSON.stringify({ id, token: this.sessionToken, params }),
  735. });
  736. let res: Response | null = null;
  737. let lastErr: unknown = null;
  738. for (let i = 0; i < 2; i++) {
  739. try {
  740. res = await attempt();
  741. // 可恢复的服务端错误 → 短暂等待后重试一次
  742. if ((res.status === 502 || res.status === 503 || res.status === 504) && i === 0) {
  743. await new Promise((r) => setTimeout(r, 800));
  744. continue;
  745. }
  746. break;
  747. } catch (e) {
  748. lastErr = e;
  749. if (i === 0) {
  750. await new Promise((r) => setTimeout(r, 800));
  751. continue;
  752. }
  753. throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试');
  754. }
  755. }
  756. if (!res) {
  757. void lastErr;
  758. throw new Error('连不上平台服务(网络或服务端不可用),请稍后重试');
  759. }
  760. if (!res.ok) {
  761. if (res.status === 503 || res.status === 502 || res.status === 504) {
  762. throw new Error(`平台暂时不可用(HTTP ${res.status}),请稍后重试`);
  763. }
  764. /*
  765. * 🔴 403 要翻译成人话(2026-11-12 用户实测)。
  766. *
  767. * 同公司同事打开某个容器时,provision 云函数会返回 403。
  768. * 原本只抛「云函数调用失败(HTTP 403)」,用户不知道是网络、账号还是权限问题。
  769. *
  770. * ⚠️ 注意:产品目标是「同公司成员可共用全部容器」(见
  771. * `docs/03-interface/需求-同公司共用容器-20261112.md`),
  772. * 当前 403 属于服务端尚未放开的限制,不是正常状态。
  773. * 所以文案用「暂时不可用」而不是「你没有权限」——
  774. * 避免让用户以为这是永久性的个人权限问题。
  775. */
  776. if (res.status === 403) {
  777. throw new Error(
  778. '这个容器暂时无法使用(平台侧授权未开放)。同公司成员共用容器需平台侧开通,请联系管理员。',
  779. );
  780. }
  781. if (res.status === 401) {
  782. throw new Error('登录已失效,请重新登录。');
  783. }
  784. throw new Error(`云函数调用失败(HTTP ${res.status})`);
  785. }
  786. return (await res.json()) as T;
  787. }
  788. /**
  789. * 云函数调用用的请求头。
  790. *
  791. * 🔴 **不要带 `X-Parse-Application-Id`**(2026-11-11 线上实测)。
  792. *
  793. * 线上 `https://ai.fmode.cn` 调用 `server.fmode.cn/api/functions` 时,
  794. * 浏览器预检直接被拒:
  795. *
  796. * Request header field x-parse-application-id is not allowed
  797. * by Access-Control-Allow-Headers in preflight response.
  798. *
  799. * 根因在**服务端网关**:预检响应取决于 `Access-Control-Request-Headers`
  800. * 里各字段的**大小写**——
  801. * · 全小写 → `Access-Control-Allow-Headers: *`(放行)
  802. * · 首字母大写(浏览器实际发的 `Content-Type, X-Parse-Application-Id`)
  803. * → 回固定白名单,**里面没有 x-parse-application-id** → 被拒
  804. *
  805. * 而这个头对云函数**完全无用**:鉴权靠 URL 里的函数 `id` + body 里的
  806. * `token`(/`X-Parse-Session-Token`)。实测去掉后调用正常返回。
  807. *
  808. * 开发态之所以没暴露:`environment.ts` 的 `functionsBase` 是相对路径
  809. * `/api/functions`,同源、不走预检。
  810. */
  811. /**
  812. * 云函数调用用的请求头 —— **一个 `X-Parse-*` 都不带**。
  813. *
  814. * ## 为什么(2026-11-11 线上实测,两轮踩坑)
  815. *
  816. * 线上 `https://ai.fmode.cn` 跨域调 `server.fmode.cn/api/functions`,
  817. * 浏览器先发 OPTIONS 预检。**服务端的预检响应不稳定**:
  818. *
  819. * ```
  820. * 连续 10 次 OPTIONS 统计:
  821. * Access-Control-Allow-Headers: * → 4 次(放行)
  822. * Access-Control-Allow-Headers: Authorization,DNT,...,Content-Type,Range → 6 次(拒)
  823. * ```
  824. *
  825. * 后端的固定白名单里**没有任何 `x-parse-*`**。网关有多个实例、配置不一致,
  826. * 所以表现为「时好时坏」:4/10 成功 → 用户看到部分请求报 CORS,
  827. * 但因为请求彼此独立,主流程往往还能走通。
  828. *
  829. * 第一轮只去掉了 `X-Parse-Application-Id`,问题立刻在
  830. * `X-Parse-Session-Token` 上重现 —— 逐个删是治标的。现在**全部不带**。
  831. *
  832. * ## 不带头怎么鉴权
  833. *
  834. * 云函数从 body 的 `params.token` 取(`fmode-file-hub.js` 的 `_sessionToken()`
  835. * 里 `params.token` 是明确回退路径)。实测只带 body、不带任何头,
  836. * 云函数正常返回数据。
  837. *
  838. * ⚠️ 只对**云函数入口**这样处理;Parse REST / 登录等接口照旧带头,
  839. * 不走这条路径。
  840. */
  841. private authHeaders(): Record<string, string> {
  842. return { 'Content-Type': 'application/json' };
  843. }
  844. private rawFetch(url: string, init: RequestInit): Promise<Response> {
  845. /*
  846. * 🔴 **默认不带任何 `X-Parse-*` 头**,只有 Parse REST 白名单才带。
  847. *
  848. * ## 为什么(两次线上的 CORS 事故)
  849. *
  850. * 服务端网关有多个实例、预检响应不一致:
  851. * Access-Control-Allow-Headers: * → 放行(4/10 次)
  852. * Authorization,DNT,...,Content-Type,Range → 拒绝(6/10 次)
  853. * 固定白名单里**没有任何 `x-parse-*`**,命中那一台就把整条请求拦掉。
  854. *
  855. * ## 踩过的坑(两次,都是上线后才炸)
  856. *
  857. * ① 第一版只剥离「云函数入口」→ `/api/apig/message` 被拦
  858. * ② 第二版改成「`/api/` 路径」→ 新接口只要不在这条规则里,照样会被拦
  859. *
  860. * **教训:枚举「哪些不带」永远会漏,必须反过来枚举「哪些带」。**
  861. *
  862. * ## 现在的规则(白名单式,默认安全)
  863. *
  864. * | 目标 | 带 X-Parse-* |
  865. * |---|---|
  866. * | Parse REST(`/parse/users/me`、`/parse/roles` 等) | ✅ 带(Parse Server 硬性要求) |
  867. * | **其它一切**(`/api/functions`、`/api/fmode/*`、`/api/apig/*`、未来新增) | ❌ 不带 |
  868. *
  869. * 这样**新增任何接口都默认不会被 CORS 拦**,除非它确实属于 Parse REST。
  870. * `/api/*` 下的端点鉴权都在 body / query 里(`token` 字段),不依赖这些头。
  871. */
  872. const needsParseHeaders = /\/parse(\/|$)/.test(url);
  873. const incoming = (init.headers as Record<string, string>) ?? {};
  874. const headers: Record<string, string> = needsParseHeaders
  875. ? { 'X-Parse-Application-Id': PARSE_APP_ID }
  876. : {};
  877. for (const [k, v] of Object.entries(incoming)) {
  878. /*
  879. * 非 Parse REST:调用方误传的 `X-Parse-*` 也一律丢掉 ——
  880. * 宁可鉴权失败(有明确报错),也不要被 CORS 静默拦成 ERR_FAILED。
  881. */
  882. if (!needsParseHeaders && k.toLowerCase().startsWith('x-parse-')) continue;
  883. headers[k] = v;
  884. }
  885. /*
  886. * 🔴 有 body 的方法必须带 `Content-Type: application/json`。
  887. *
  888. * 实测教训:`POST /api/apig/message` 若**不带该头**,服务端**读不到 body**,
  889. * 于是报「缺少参数company」—— 而 body 里其实带了 company。
  890. * 这个误导性报错曾让我误判为「限流」,排查花了很久。
  891. *
  892. * 故此处对带 body 的请求**自动补默认 Content-Type**(调用方显式指定则尊重其值)。
  893. */
  894. if (init.body != null && !headers['Content-Type']) {
  895. headers['Content-Type'] = 'application/json';
  896. }
  897. return fetch(url, { ...init, headers });
  898. }
  899. }
  900. /** 登录公司常量(《API-契约》§1.2) */
  901. const LOGIN_COMPANY = 'E4KpGvTEto';
  902. /** 错误文案取值顺序:msg → mess → message */
  903. function pickMessage(d: unknown): string | undefined {
  904. if (!d || typeof d !== 'object') return undefined;
  905. const o = d as Record<string, unknown>;
  906. for (const k of ['msg', 'mess', 'message']) {
  907. const v = o[k];
  908. if (typeof v === 'string' && v.trim()) return v;
  909. }
  910. return undefined;
  911. }
  912. /**
  913. * 发码错误 → 友好文案。
  914. *
  915. * ## 🔴 为什么要做这一层(实测教训)
  916. *
  917. * 服务端在**限流**状态下会返回**误导性文案**,实测对照:
  918. *
  919. * | 请求 | 服务端返回 |
  920. * |---|---|
  921. * | 带 company(首次) | `200 {"code":1,"data":{"msg":"发送成功"}}` |
  922. * | 带 company(频繁) | `500 {"mess":"该号码发送验证码过于频繁"}` |
  923. * | **不带** company | `500 {"mess":"缺少参数company"}` |
  924. *
  925. * 即:我们的请求**带了** company 且首次能成功,但**短时间重复请求**时
  926. * 服务端可能错报成「缺少参数company」,误导用户以为参数配错了。
  927. *
  928. * 故此处把「缺参数」在**限流上下文**(HTTP 500 而非 400)下转成更可能的真相提示,
  929. * 并在文案中保留原始信息以便排查(不吞掉事实)。
  930. */
  931. function friendlySendCodeError(raw: string | undefined, status: number): string {
  932. const m = raw ?? '';
  933. if (/频繁|过于频繁|too many|rate/i.test(m)) {
  934. return '发送过于频繁,请稍后再试(同一号码通常 60 秒内只能发一次)';
  935. }
  936. // 实测:限流态下服务端会误报「缺少参数company」;保留原始信息供排查
  937. if (/缺少参数\s*company/i.test(m)) {
  938. return '验证码发送失败:服务端返回「缺少参数company」。若已多次点击,通常是发送过于频繁,请稍后再试。(原始:' +
  939. m +
  940. ')';
  941. }
  942. if (m) return m;
  943. return `验证码发送失败(HTTP ${status})`;
  944. }
  945. /**
  946. * `channels` 归一:兼容**数组形态**的非标准容器上报。
  947. *
  948. * 🔴 实测(`agent-node-lj-test`):容器发的是 `["wecom"]`,而平台契约是**对象**
  949. * `{"matrix":true,"matrixConfig":{...},"wecom":true,...}`。
  950. * 不归一 → `channels.matrix` 为 `undefined` → `matrixReady=false` → UI 显示「未接入」,
  951. * 但该容器 Matrix 通道其实**是通的**(实测双向消息可达)。
  952. *
  953. * 数组语义:元素即**已点亮的通道名**,故 `["matrix","wecom"]` → 两者为 true。
  954. * 空数组视为「未上报通道」而非「零通道」(返回 undefined,避免 UI 显示"未接入"误导)。
  955. */
  956. function normalizeChannels(v: unknown): ChannelConfig | undefined {
  957. const parsed = parseJsonLoose<unknown>(v);
  958. if (parsed == null) return undefined;
  959. if (Array.isArray(parsed)) {
  960. const names = parsed.filter((x): x is string => typeof x === 'string');
  961. if (names.length === 0) return undefined;
  962. return {
  963. matrix: names.includes('matrix'),
  964. wecom: names.includes('wecom'),
  965. };
  966. }
  967. // 对象形态:契约标准,原样透传(含 matrixConfig 细节)
  968. return parsed as ChannelConfig;
  969. }
  970. /**
  971. * `host` 归一:兼容 `cpuLoad1`(裸负载)→ `cpuPercent`(百分比)。
  972. *
  973. * 🔴 实测:容器发 `{"cpuLoad1":5.65,"cpuLoad5":5.66,"memPercent":51,...}`,
  974. * 缺 `cpuPercent` 键 → CPU 不显示(memPercent/diskPercent 因键名对上才正常)。
  975. *
  976. * 换算口径 `cpuLoad1 / 核数 * 100`(实测 56 核实测 9.2 → 16.4 与容器自证一致)。
  977. * ⚠️ 核数未知时**不猜**:宁可返回 undefined 让 UI 显示「未上报」,也不编一个假数字。
  978. * 核数来源优先级:`cpuCores`(容器自报)→ 无。
  979. */
  980. function normalizeHost(v: unknown): Agent['host'] {
  981. const h = parseJsonLoose<Record<string, unknown>>(v);
  982. if (!h) return undefined;
  983. const num = (x: unknown): number | undefined =>
  984. typeof x === 'number' && Number.isFinite(x) ? x : undefined;
  985. // 契约标准的 cpuPercent 优先,不被派生值覆盖
  986. let cpuPercent = num(h['cpuPercent']);
  987. if (cpuPercent == null) {
  988. const load = num(h['cpuLoad1']);
  989. const cores = num(h['cpuCores']);
  990. if (load != null && cores != null && cores > 0) {
  991. cpuPercent = Math.min(100, Math.max(0, (load / cores) * 100));
  992. }
  993. }
  994. const memPercent = num(h['memPercent']);
  995. const diskPercent = num(h['diskPercent']);
  996. // 三项指标全无 → 视为未上报(避免 UI 显示 0% 误导为"机器空闲")
  997. if (cpuPercent == null && memPercent == null && diskPercent == null) return undefined;
  998. return { cpuPercent, memPercent, diskPercent };
  999. }
  1000. /**
  1001. * `heartbeatAt` 归一(三级回落)。
  1002. *
  1003. * 🔴 实测:容器不发 `heartbeatAt`,心跳时间藏在 `host.probedAt`
  1004. * (采集时刻);两者皆无时回落 `updatedAtClient.iso`(云函数写入时刻)。
  1005. * 不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。
  1006. */
  1007. function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string | undefined {
  1008. /*
  1009. * 🔴 必须经 `tsOf` 归一,不能 `String()`。
  1010. * 云函数里 heartbeatAt 是 Parse Date → `{__type:'Date', iso:'…'}`,
  1011. * `String()` 得到 `"[object Object]"` → 下游 `Date.parse()` 得 NaN →
  1012. * 「心跳超时」判定永远为假(实测:状态永远显示「空闲」,从不报「状态未同步」)。
  1013. */
  1014. const isoOf = (v: unknown): string | undefined => {
  1015. const t = tsOf(v);
  1016. return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
  1017. };
  1018. const direct = isoOf(raw['heartbeatAt']);
  1019. if (direct) return direct;
  1020. const h = parseJsonLoose<{ probedAt?: string }>(host);
  1021. if (h?.probedAt) return isoOf(h.probedAt);
  1022. const uac = raw['updatedAtClient'] as { iso?: string } | undefined;
  1023. if (uac?.iso) return isoOf(uac.iso);
  1024. return undefined;
  1025. }
  1026. /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
  1027. function mapAgent(raw: Record<string, unknown>): Agent {
  1028. const channels = normalizeChannels(raw['channels']);
  1029. const balance = parseJsonLoose<{
  1030. remainCny?: number;
  1031. remainRmb?: number;
  1032. cny?: number;
  1033. usedCny?: number;
  1034. pageUrl?: string;
  1035. source?: string;
  1036. }>(raw['balance']);
  1037. const host = normalizeHost(raw['host']);
  1038. const heartbeatAt = normalizeHeartbeat(raw, raw['host']);
  1039. const user = raw['user'] as Record<string, unknown> | undefined;
  1040. return {
  1041. id: String(raw['agentId'] ?? raw['objectId'] ?? ''),
  1042. name: String(raw['botName'] ?? raw['name'] ?? ''),
  1043. avatar: raw['botAvatar'] ? String(raw['botAvatar']) : undefined,
  1044. gender: (raw['gender'] as Agent['gender']) ?? 'unknown',
  1045. status: (raw['status'] as AgentStatus) ?? 'offline',
  1046. currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined,
  1047. currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined,
  1048. progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined,
  1049. heartbeatAt,
  1050. /*
  1051. * 心跳距今秒数(2026-11-07):云函数直接给出,比从 heartbeatAt 推算更可靠 ——
  1052. * 实测该字段有值而 heartbeatAt 为 undefined。供状态展示判定「其实已离线」。
  1053. */
  1054. heartbeatAgeSec:
  1055. typeof raw['heartbeatAgeSec'] === 'number' ? (raw['heartbeatAgeSec'] as number) : undefined,
  1056. updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
  1057. /*
  1058. * 会话级状态(2026-11-09,方案 A)。
  1059. *
  1060. * 容器上报的 `status` 是**整个容器一个值**,同一容器的多个会话共用,
  1061. * 导致「会话 A 在跑」时会话 B 也显示正在执行(用户实测)。
  1062. * 容器把每个会话的状态写进 `extra.currentSessions`(`extra` 已在平台白名单里)。
  1063. *
  1064. * 形状不对一律忽略 —— 宁可回落到全局 status,也不能因解析失败显示「未知」。
  1065. */
  1066. currentSessions: (() => {
  1067. const ex = parseJsonLoose<Record<string, unknown>>(raw['extra']);
  1068. const list = ex?.['currentSessions'];
  1069. if (!Array.isArray(list)) return undefined;
  1070. const out = list.flatMap((it) => {
  1071. if (!it || typeof it !== 'object') return [];
  1072. const s = it as Record<string, unknown>;
  1073. const key = typeof s['sessionKey'] === 'string' ? s['sessionKey'] : '';
  1074. if (!key) return [];
  1075. const st = s['status'] === 'running' ? 'running' : 'idle';
  1076. /*
  1077. * `userId`(2026-11-11):容器共享给多人时区分「谁的会话」。
  1078. * 形状不对就省略 —— 宁可退回 roomId 匹配,也不要拿脏值去比对。
  1079. */
  1080. const uid = typeof s['userId'] === 'string' && s['userId'].startsWith('@') ? s['userId'] : undefined;
  1081. /* 会话级任务摘要(2026-11-11):形状不对就省略,顶栏退回通用文案 */
  1082. const task = typeof s['task'] === 'string' && s['task'].trim() ? s['task'].trim() : undefined;
  1083. /* 最后更新时刻(2026-11-11):用于判断 running 是否已过期 */
  1084. const upd = typeof s['updatedAt'] === 'number' && Number.isFinite(s['updatedAt']) ? s['updatedAt'] : undefined;
  1085. return [
  1086. {
  1087. sessionKey: key,
  1088. ...(uid ? { userId: uid } : {}),
  1089. ...(task ? { task } : {}),
  1090. ...(upd ? { updatedAt: upd } : {}),
  1091. status: st as 'running' | 'idle',
  1092. turnStartAt:
  1093. typeof s['turnStartAt'] === 'number' && Number.isFinite(s['turnStartAt'])
  1094. ? s['turnStartAt']
  1095. : undefined,
  1096. },
  1097. ];
  1098. });
  1099. return out.length ? out : undefined;
  1100. })(),
  1101. host,
  1102. balance: balance
  1103. ? {
  1104. // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny
  1105. remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny,
  1106. usedCny: typeof balance.usedCny === 'number' ? balance.usedCny : undefined,
  1107. pageUrl: balance.pageUrl,
  1108. /*
  1109. * `source: 'owner_token'` = 云函数用**主人账号的令牌**实时取的余额。
  1110. * 容器账单挂在主人账号上 → 同一主人的所有容器报同一个数,UI 要标注「账号余额」。
  1111. */
  1112. sharedAccount: balance.source === 'owner_token' ? true : undefined,
  1113. }
  1114. : undefined,
  1115. channels: channels
  1116. ? {
  1117. matrix: channels.matrix,
  1118. homeserver: channels.homeserver,
  1119. matrixConfig: channels.matrixConfig,
  1120. wecom: channels.wecom,
  1121. wecomBotId: channels.wecomBotId,
  1122. }
  1123. : undefined,
  1124. /**
  1125. * 🔴 `usage` 此前**完全没有映射** —— 云函数返回了、映射层丢掉了,
  1126. * 造成「Token 用量」永远空白(实测:库里 totalTokens=2238055,页面不显示)。
  1127. *
  1128. * 键名兼容:容器发 `inputTokens`/`outputTokens`,
  1129. * 契约旧字段是 `promptTokens`/`completionTokens`,两者都收。
  1130. */
  1131. usage: (() => {
  1132. const u = parseJsonLoose<Record<string, unknown>>(raw['usage']);
  1133. if (!u) return undefined;
  1134. const num = (x: unknown): number | undefined =>
  1135. typeof x === 'number' && Number.isFinite(x) ? x : undefined;
  1136. const usage = {
  1137. totalTokens: num(u['totalTokens']),
  1138. promptTokens: num(u['promptTokens']) ?? num(u['inputTokens']),
  1139. completionTokens: num(u['completionTokens']) ?? num(u['outputTokens']),
  1140. updatedAt: u['updatedAt'] ? String(u['updatedAt']) : undefined,
  1141. raw: u,
  1142. };
  1143. // 三项全无 → 视为未上报(避免 UI 显示 0)
  1144. return usage.totalTokens == null && usage.promptTokens == null && usage.completionTokens == null
  1145. ? undefined
  1146. : usage;
  1147. })(),
  1148. owner: user
  1149. ? {
  1150. id: String(user['objectId'] ?? ''),
  1151. name: String(user['nickname'] ?? user['username'] ?? ''),
  1152. avatar: user['avatar'] ? String(user['avatar']) : undefined,
  1153. mobile: user['mobile'] ? String(user['mobile']) : undefined,
  1154. }
  1155. : undefined,
  1156. // 容器标识(排障反查来源用;云函数读的是 hostname/containerRef)
  1157. containerRef: raw['containerRef'] ? String(raw['containerRef']) : undefined,
  1158. // Agent token 健康灯:expired 时余额等依赖 token 的字段会降级
  1159. sessionStatus: (() => {
  1160. const s = String(raw['sessionStatus'] ?? '').toLowerCase();
  1161. return s === 'ok' || s === 'expired' ? (s as 'ok' | 'expired') : 'unknown';
  1162. })(),
  1163. // 会话级用量明细(可选,缺失不影响总量展示)
  1164. usageSessions: (() => {
  1165. const list = parseJsonLoose<unknown[]>(raw['usageSessions']);
  1166. if (!Array.isArray(list) || list.length === 0) return undefined;
  1167. return list as UsageSession[];
  1168. })(),
  1169. companyId: (raw['company'] as { objectId?: string } | undefined)?.objectId,
  1170. // 「是否已接入 FMODE」= channels.matrix 为真(或已有 matrixConfig.userId)
  1171. matrixReady: !!channels?.matrix || !!channels?.matrixConfig?.userId,
  1172. // 未读数**不来自服务端**:云函数不返回该字段,真实计数由
  1173. // `UnreadService` 依据 Matrix 事件流在客户端维护(见该服务注释)。
  1174. // 此处恒为 0,UI 不得消费(消费点一律走 UnreadService.countOf)。
  1175. unread: 0,
  1176. };
  1177. }
  1178. /**
  1179. * 从存储键里还原一个「像文件名」的展示名。
  1180. *
  1181. * 实现已移到 `core/s3-link.ts`(消息卡片也要用,而 `chat.service`
  1182. * 不能反向依赖本文件)。这里重新导出,保持既有调用点不变。
  1183. */
  1184. export { prettifyStorageKey };
  1185. /** 成果原始记录 → `Deliverable` 领域模型 */
  1186. function mapDeliverable(raw: Record<string, unknown>): Deliverable {
  1187. const status = String(raw['status'] ?? '');
  1188. /*
  1189. * 🔴 `artifacts` 是**自由数组**,实测两种形态混用(同一张表里都有):
  1190. *
  1191. * ```
  1192. * ["/opt/data/git-repos/agent-status-reporter/REPORT.md"] ← 字符串数组(容器最常这样写)
  1193. * [{ name: "report.pdf", url: "https://…", type: "pdf" }] ← 对象数组(契约标准)
  1194. * ```
  1195. *
  1196. * 此前只处理对象数组,字符串形态进来后 `name` 变成空串 →
  1197. * 右栏渲染成「仅有 · 的文档」(用户实测反馈)。
  1198. *
  1199. * 字符串形态的分辨口径:
  1200. * · 以 `http` 开头 → 视作已是可访问地址
  1201. * · 其它(容器内路径) → **未同步**,由 `ArtifactSyncService` 交给容器上传到云盘
  1202. */
  1203. const rawArts = Array.isArray(raw['artifacts']) ? (raw['artifacts'] as unknown[]) : [];
  1204. const artifacts: Artifact[] = rawArts
  1205. .map((a): Artifact | null => {
  1206. if (typeof a === 'string') {
  1207. const s = a.trim();
  1208. if (!s) return null;
  1209. const isUrl = /^https?:\/\//i.test(s);
  1210. // 与对象形态同口径:优先从存储键还原展示名,不直接拿末段当名字
  1211. const name = prettifyStorageKey(s);
  1212. return {
  1213. name,
  1214. url: isUrl ? s : '',
  1215. type: name.split('.').pop() || '',
  1216. // 非 URL 形态的原始值(容器内路径),保留给同步服务
  1217. source: isUrl ? undefined : s,
  1218. } as Artifact;
  1219. }
  1220. if (a && typeof a === 'object') {
  1221. const o = a as Record<string, unknown>;
  1222. const name = String(o['name'] ?? o['filename'] ?? '');
  1223. const rawUrl = String(o['url'] ?? '');
  1224. /*
  1225. * 🔴 对象形态也要认 `source`(2026-11-07)。
  1226. *
  1227. * 容器的 artifact 常写成 `{ name, url: '/opt/data/x.md' }`(容器内路径),
  1228. * 原实现把整个 `url` 原样带出 → 前端拿它去 fetch 必然失败,
  1229. * 且**无法入队搬运**(同步服务只认 `source`)。
  1230. * 现在与非 URL 字符串形态同口径:路径进 `source`,`url` 置空。
  1231. */
  1232. const isUrl = /^https?:\/\//i.test(rawUrl);
  1233. const fallbackSource = String(o['source'] ?? '');
  1234. const url = isUrl ? rawUrl : '';
  1235. const source = isUrl ? undefined : rawUrl || fallbackSource || undefined;
  1236. if (!name && !url && !source) return null;
  1237. /*
  1238. * 显示名与存储键**分离**(2026-11-10 用户裁决)。
  1239. *
  1240. * 存储键由服务端生成(形如 `0-mwa2gx2gog-weekly-report.md` 或纯随机 ID),
  1241. * 中文文件名进不了签名 URL,所以**不能**拿 url 末段当显示名。
  1242. * 优先用 `name` 字段;没有才回退,且回退时剥掉服务端加的前缀段。
  1243. */
  1244. const displayName =
  1245. name || prettifyStorageKey(url) || prettifyStorageKey(source ?? '') || '未命名文件';
  1246. return {
  1247. name: displayName,
  1248. url,
  1249. type: String(o['type'] ?? ''),
  1250. ...(source ? { source } : {}),
  1251. };
  1252. }
  1253. return null;
  1254. })
  1255. .filter((x): x is Artifact => x != null);
  1256. const first = artifacts[0];
  1257. const filename = first?.name || undefined;
  1258. const rawType = first?.type || filename?.split('.').pop() || '';
  1259. const fileType = rawType ? rawType.replace(/^\./, '').toUpperCase() : undefined;
  1260. return {
  1261. id: String(raw['objectId'] ?? raw['id'] ?? ''),
  1262. agentId: String(raw['agentId'] ?? ''),
  1263. title: String(raw['title'] ?? ''),
  1264. summary: raw['summary'] ? String(raw['summary']) : undefined,
  1265. projectId: raw['project'] ? String(raw['project']) : undefined,
  1266. projectName: raw['projectName'] ? String(raw['projectName']) : undefined,
  1267. artifacts,
  1268. tags: Array.isArray(raw['tags']) ? (raw['tags'] as string[]).map(String) : [],
  1269. deliveredAt: String(raw['deliveredAt'] ?? ''),
  1270. filename,
  1271. fileType,
  1272. // 契约未返回文件大小 → 保持空(UI 不该显示 0 B)
  1273. size: raw['size'] ? String(raw['size']) : undefined,
  1274. // 契约 §2.2:status 取值为推断 —— 含 fail/error 子串即失败
  1275. state: /fail|error/i.test(status) ? 'failed' : 'delivered',
  1276. };
  1277. }