fmode-file-hub.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. // fmode-file-hub v0.2.0 — 文件台账(按会话 / 按群隔离)(表: AgentDeliverable)
  2. //
  3. // ## v0.2.0(2026-11-11):群隔离
  4. //
  5. // 伪群聊架构下(App 把群消息拆成 N 条私聊发给各成员容器),容器只在自己的
  6. // DM 房间工作,**它不知道文件属于哪个群**。所以「归群」只能由 App 侧决定:
  7. //
  8. // · 用户上传 → 写入时直接带 `groupId`(App 知道当前是哪个群)
  9. // · 容器产出 → 靠「派发锚点」反查(后续版本),或 App 惰性补归并
  10. //
  11. // 新增字段:`groupId`(所属群;'' = 不属于任何群)、`groupIdSource`、`prevThreadRoot`
  12. // 新增 action:`listByGroup` / `claimToGroup`
  13. //
  14. // 🔴 `threadRoot` 语义不变(单聊 = 单聊会话 id,群 = 群 id)。
  15. // `claimToGroup` 会把记录**搬**进群(threadRoot 一并改写),
  16. // 保证一条文件只出现在一个页签 —— 这就是「不串台」的落地方式。
  17. //
  18. // ## 为什么新建而不是改老函数
  19. //
  20. // 老函数 lfYlgU7SkK(fmodeagent-deliverables) 的 report 用 UPDATABLE 白名单写字段,
  21. // 里面**没有 threadRoot** —— 容器传了也会被静默丢弃,所以成果永远无法归属到会话。
  22. // 用户裁决(2026-11-10):**现有云函数一行不动,新建独立函数走新接口**。
  23. //
  24. // ## 职责边界(用户裁决)
  25. //
  26. // · 消息**不入库**(单聊/群聊都在 Matrix)
  27. // · 只有**文件与成果**入库,目的是让右侧栏能按会话直接加载
  28. // · 只存访问链接与文件元信息,**不存文件内容**(内容由 AlP56LCKFm 签名直传 OBS)
  29. // · 不做老数据适配:没有 threadRoot 的历史成果不出现,按用户裁决"全面切新逻辑"
  30. //
  31. // ## 调用
  32. //
  33. // POST /api/functions {token, id:<Function objectId>, params:{action, ...}}
  34. // action: registerFile | listBySession | listByAgent
  35. //
  36. // ## 表
  37. //
  38. // 复用 AgentDeliverable(已有 user / company / ACL),新增字段:
  39. // threadRoot / uploadedBy / fileName / fileSize / mimeType / storageKey / roomId
  40. // ==================== 基础工具(各云函数本就各自复制一份,沿用惯例) ====================
  41. var CLASS_NAME = 'AgentDeliverable';
  42. var MAX_LIMIT = 200;
  43. var DEFAULT_LIMIT = 50;
  44. /** 会话标识、URL、文件名等的长度上限 */
  45. var LEN = { threadRoot: 200, roomId: 200, agentId: 120, url: 1000, name: 200, key: 500, mime: 120 };
  46. /** 单个文件大小上限(与 App 侧 upload-limits.ts 的 20MB 保持一致) */
  47. var MAX_FILE_BYTES = 20 * 1024 * 1024;
  48. /**
  49. * 「派发锚点」的有效窗口(v0.2.0)。
  50. *
  51. * App 发群消息时为每个目标容器记一条锚点,容器产出的文件在**锚点之后
  52. * 这个窗口内**才认它属于那个群。超过窗口不认 —— 宁可漏,不归错
  53. * (用户裁决 2026-11-11)。
  54. */
  55. var GROUP_ANCHOR_TTL_MS = 30 * 60 * 1000;
  56. /** 锚点记录的识别标记(App 写入时用的 mimeType) */
  57. var ANCHOR_MIME = 'application/x-fmode-anchor';
  58. function _ok(response, data) {
  59. var body = { code: 200 };
  60. if (data) for (var k in data) body[k] = data[k];
  61. response.status(200).json(body);
  62. }
  63. function _fail(response, code, msg) {
  64. response.status(200).json({ code: code, msg: msg });
  65. }
  66. function _str(v, max) {
  67. if (v === undefined || v === null) return '';
  68. var s = String(v).trim();
  69. return max && s.length > max ? s.slice(0, max) : s;
  70. }
  71. function _num(v) {
  72. var n = Number(v);
  73. return isFinite(n) && n >= 0 ? n : null;
  74. }
  75. function _sessionToken() {
  76. // 与 fmodeagent-status 同口径:请求头优先(平台实测只保留请求头)
  77. if (request.headers && request.headers['x-parse-session-token']) {
  78. return request.headers['x-parse-session-token'];
  79. }
  80. if (params && params.token) return params.token;
  81. if (request.body && request.body.token) return request.body.token;
  82. return null;
  83. }
  84. async function _getUser(t) {
  85. if (!t) return null;
  86. if (request.user && request.user.id) return request.user;
  87. try {
  88. var q = new Parse.Query(Parse.Session);
  89. q.equalTo('sessionToken', t);
  90. q.include('user');
  91. var s = await q.first({ useMasterKey: true });
  92. return s ? s.get('user') : null;
  93. } catch (e) {
  94. return null;
  95. }
  96. }
  97. /**
  98. * 反查容器产出的文件属于哪个群(v0.2.0)。
  99. *
  100. * ## 为什么需要
  101. *
  102. * 伪群聊下容器只在自己的 DM 房间工作,回报文件时**不知道群**。
  103. * App 在派活前为每个目标容器记了一条锚点(`uploadedBy:'agent'` +
  104. * `ANCHOR_MIME`),锚点上带着 `groupId` / `agentId` / `roomId`。
  105. * 容器产出到达时,按「同 user + 同 agentId + 同 roomId + 时间在锚点之后」匹配。
  106. *
  107. * ## 宁可漏,不归错(用户裁决)
  108. *
  109. * - 有多条候选锚点 → **不认**(同时被多个群派活的典型场景,认了就会串台)
  110. * - 锚点太旧(超出 TTL)→ 不认
  111. * - 匹配不到 → 返回 '',文件照常登记,只是不归群
  112. *
  113. * @returns {{ groupId: string }} 命中返回群 id,否则空串
  114. */
  115. async function _resolveGroup(user, agentId, roomId) {
  116. if (!agentId) return '';
  117. var q = new Parse.Query(CLASS_NAME);
  118. q.equalTo('user', user);
  119. q.equalTo('agentId', agentId);
  120. q.equalTo('uploadedBy', 'agent');
  121. q.equalTo('mimeType', ANCHOR_MIME);
  122. q.greaterThan('deliveredAt', new Date(Date.now() - GROUP_ANCHOR_TTL_MS));
  123. q.descending('deliveredAt');
  124. q.limit(10);
  125. var rows;
  126. try {
  127. rows = await q.find({ useMasterKey: true });
  128. } catch (e) {
  129. if (!_isMissingColumn(e)) throw e;
  130. await _ensureColumns();
  131. rows = await q.find({ useMasterKey: true });
  132. }
  133. if (rows.length === 0) return '';
  134. /*
  135. * 🔴 唯一性要求:窗口内必须只有**同一个群**的锚点。
  136. *
  137. * 同一容器被群 G 和群 H 先后派活 → 有两条锚点指向不同群 →
  138. * 无法判断这个文件是谁的 → 不认。这是「不串台」的底线,
  139. * 代价是这种并发场景下文件不归群(仍会出现在单聊里)。
  140. */
  141. var groups = new Set();
  142. for (var i = 0; i < rows.length; i++) {
  143. var g = rows[i].get('groupId') || '';
  144. if (g) groups.add(g);
  145. }
  146. if (groups.size !== 1) return '';
  147. return groups.values().next().value || '';
  148. }
  149. /** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */
  150. function _isCloudUrl(u) {
  151. return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
  152. }
  153. /**
  154. * 用 threadRoot 反查该会话的房间 id(v0.3.0,2026-11-13)。
  155. *
  156. * 为什么要它:`registerFile` 的主键改成了 roomId,但老调用方(以及容器)
  157. * 手里可能只有 threadRoot。会话目录 `FmodeChatThread` 同时存了
  158. * `threadRoot` 与 `roomId`,是这条换算的权威来源。
  159. *
  160. * 反查不到就返回空串,由调用方决定降级策略(这里退回用 threadRoot 当键)。
  161. */
  162. async function _roomOfThread(user, threadRoot) {
  163. if (!threadRoot) return '';
  164. try {
  165. var q = new Parse.Query('FmodeChatThread');
  166. q.equalTo('user', user);
  167. q.equalTo('threadRoot', threadRoot);
  168. q.descending('lastMessageAt');
  169. var row = await q.first({ useMasterKey: true });
  170. return row ? row.get('roomId') || '' : '';
  171. } catch (e) {
  172. /* 会话目录不可用不该阻断登记:调用方会退回 threadRoot */
  173. return '';
  174. }
  175. }
  176. /**
  177. * 校验 `groupId` 确实是**本用户的群会话**(v0.2.0)。
  178. *
  179. * 🔴 为什么必须校验:`groupId` 是隔离边界。若不校验,客户端可以传任意字符串,
  180. * 把文件"归"到一个不存在的群或别人的群 —— 隔离就成了摆设。
  181. *
  182. * 会话目录在 `FmodeChatThread` 表,`kind === 'group'` 才是群。
  183. * 查不到 / 不是群 / 不是本人 → 一律拒绝。
  184. */
  185. async function _isOwnGroup(user, groupId) {
  186. if (!groupId) return false;
  187. try {
  188. var q = new Parse.Query('FmodeChatThread');
  189. q.equalTo('user', user);
  190. q.equalTo('objectId', groupId);
  191. var row = await q.first({ useMasterKey: true });
  192. if (!row) return false;
  193. var kind = String(row.get('kind') || '').toLowerCase();
  194. return kind === 'group';
  195. } catch (e) {
  196. /* 表不存在或查询失败 → 保守拒绝,宁可登记成"不归群" */
  197. return false;
  198. }
  199. }
  200. /**
  201. * 确保 class 上存在某个列(2026-11-10)。
  202. *
  203. * 🔴 Parse 的自动建列**只在写入时发生**:数据库里还没有 `threadRoot` 列时,
  204. * 直接 `equalTo('threadRoot', x)` 会让 PostgreSQL 报 42703 errorMissingColumn。
  205. * 历史数据本来就没有这个字段,所以第一次查询必然踩到。
  206. *
  207. * 做法:写一个带该字段的探测对象再立刻销毁,把列建出来。
  208. * 只在捕获到缺列错误时执行一次,正常路径零开销。
  209. */
  210. var _ensured = false;
  211. /**
  212. * 一次性建出所有新列。
  213. *
  214. * 历史数据里这些字段一个都不存在,分散建列会让每个字段都触发一次
  215. * 失败查询 + 重试,这里一次建全,后续查询零开销。
  216. */
  217. async function _ensureColumns() {
  218. if (_ensured) return;
  219. var probe = new Parse.Object(CLASS_NAME);
  220. probe.set('threadRoot', '');
  221. probe.set('fileUrl', '');
  222. probe.set('uploadedBy', '');
  223. probe.set('fileName', '');
  224. probe.set('fileSize', 0);
  225. probe.set('mimeType', '');
  226. probe.set('storageKey', '');
  227. probe.set('roomId', '');
  228. /* v0.2.0(2026-11-11):群隔离字段 */
  229. probe.set('groupId', '');
  230. probe.set('groupIdSource', '');
  231. probe.set('scope', '');
  232. await probe.save(null, { useMasterKey: true });
  233. // 探测对象必须销毁,否则会在成果列表里留下一条空记录
  234. await probe.destroy({ useMasterKey: true });
  235. _ensured = true;
  236. }
  237. /**
  238. * 判断是否「列不存在」错误。
  239. *
  240. * 🔴 只认 PostgreSQL 的错误码,不能按字段名匹配 ——
  241. * 查询语句本身就包含字段名,按名匹配会让任何错误都被当成缺列。
  242. */
  243. function _isMissingColumn(e) {
  244. try {
  245. var s = JSON.stringify(e);
  246. return s.indexOf('42703') >= 0 || s.indexOf('errorMissingColumn') >= 0;
  247. } catch (e2) {
  248. return false;
  249. }
  250. }
  251. // ==================== 行 → 返回对象 ====================
  252. function _toFile(r) {
  253. var artifacts = r.get('artifacts') || [];
  254. var first = Array.isArray(artifacts) && artifacts.length ? artifacts[0] : null;
  255. if (typeof first === 'string') first = { url: first, name: '' };
  256. return {
  257. id: r.id,
  258. threadRoot: r.get('threadRoot') || '',
  259. /* v0.2.0:所属群会话 id;'' = 不属于任何群 */
  260. groupId: r.get('groupId') || '',
  261. groupIdSource: r.get('groupIdSource') || '',
  262. agentId: r.get('agentId') || '',
  263. roomId: r.get('roomId') || '',
  264. // 文件信息优先取新字段,回退到 artifacts[0](两者写入时保持一致)
  265. name: r.get('fileName') || (first && first.name) || '',
  266. size: typeof r.get('fileSize') === 'number' ? r.get('fileSize') : -1,
  267. mimeType: r.get('mimeType') || (first && first.mimeType) || '',
  268. url: (first && first.url) || '',
  269. storageKey: r.get('storageKey') || '',
  270. uploadedBy: r.get('uploadedBy') || 'agent',
  271. status: r.get('status') || 'delivered',
  272. deliveredAt: (r.get('deliveredAt') || r.get('createdAt'))
  273. ? new Date(r.get('deliveredAt') || r.get('createdAt')).getTime()
  274. : 0,
  275. };
  276. }
  277. /**
  278. * 过滤掉**派发锚点**记录(v0.2.0)。
  279. *
  280. * 锚点是 App 在派活前写的内部记录(`mimeType = ANCHOR_MIME`),
  281. * 只用来反查文件归属,**不是文件**,不能出现在任何列表里。
  282. */
  283. function _toFiles(rows) {
  284. var out = [];
  285. for (var i = 0; i < rows.length; i++) {
  286. if (rows[i].get('mimeType') === ANCHOR_MIME) continue;
  287. out.push(_toFile(rows[i]));
  288. }
  289. return out;
  290. }
  291. // ==================== actions ====================
  292. /**
  293. * registerFile —— 登记一条文件记录
  294. *
  295. * 用户上传(uploadedBy:'user')与容器产出(uploadedBy:'agent')共用。
  296. * 幂等:同一 (user, roomId, url) 重复登记返回已有记录,不产生重复。
  297. *
  298. * ## 归属键:roomId 优先(v0.3.0,2026-11-13)
  299. *
  300. * 旧版要求 `threadRoot` 必填,而它依赖容器能拿到 App 的会话根 —— 实测拿不到
  301. * (DM 的 `m.relates_to` 根本没进消息),容器只能回落到房间 id。
  302. * App 却按 `$approot_*` 会话根查,**两边键从来对不上**。
  303. *
  304. * 现改为:`roomId` 必填、`threadRoot` 可选。
  305. * 一房间一会话下 roomId 与会话一一对应(App 建房失败会硬报错,不再降级共用房间)。
  306. *
  307. * 老调用方仍可只传 `threadRoot`:服务端用 `FmodeChatThread` 反查它的 roomId。
  308. */
  309. async function opRegisterFile(user, response) {
  310. var p = params || {};
  311. var threadRoot = _str(p.threadRoot, LEN.threadRoot);
  312. var roomId = _str(p.roomId, LEN.roomId);
  313. var url = _str(p.url, LEN.url);
  314. var name = _str(p.name, LEN.name);
  315. /*
  316. * 兼容老调用方:只给了 threadRoot 时,反查该会话的房间。
  317. * 查不到就退回用 threadRoot 当归属键(保证老链路不至于直接失败)。
  318. */
  319. if (!roomId && threadRoot) {
  320. roomId = (await _roomOfThread(user, threadRoot)) || '';
  321. }
  322. if (!roomId) return _fail(response, 400, 'roomId required');
  323. if (!url) return _fail(response, 400, 'url required');
  324. if (!_isCloudUrl(url)) return _fail(response, 400, 'url must be a platform cloud link');
  325. if (!name) return _fail(response, 400, 'name required');
  326. var size = _num(p.size);
  327. if (size !== null && size > MAX_FILE_BYTES) return _fail(response, 400, 'file too large');
  328. var uploadedBy = p.uploadedBy === 'user' ? 'user' : 'agent';
  329. var agentId = _str(p.agentId, LEN.agentId);
  330. /*
  331. * 群归属(v0.2.0,2026-11-11)。
  332. *
  333. * 传了 `groupId` 就必须是本用户的群,否则拒绝 —— 见 `_isOwnGroup`。
  334. * 没传或校验不过 → `groupId` 留空,按原来的「只按会话」处理,
  335. * 不影响存量调用方。
  336. */
  337. var groupId = _str(p.groupId, LEN.threadRoot);
  338. if (groupId) {
  339. var ok = await _isOwnGroup(user, groupId);
  340. if (!ok) return _fail(response, 400, 'groupId must be your own group session');
  341. } else if (uploadedBy === 'agent' && _str(p.mimeType, LEN.mime) !== ANCHOR_MIME) {
  342. /*
  343. * 容器产出没带 groupId → 反查派发锚点(v0.2.0)。
  344. *
  345. * 用户上传(uploadedBy='user')不做反查:App 知道群,直接传就行。
  346. * 锚点记录本身(ANCHOR_MIME)也跳过,否则会自己匹配自己。
  347. */
  348. groupId = await _resolveGroup(user, agentId, _str(p.roomId, LEN.roomId));
  349. }
  350. /*
  351. * 幂等:同一会话内同一 URL 只存一条。
  352. *
  353. * 🔴 不能查 `artifacts.url`(数组内字段)—— 实测该查询永远匹配不到,
  354. * 会导致重复登记。改查顶层 `fileUrl`:registerFile 写入时同步落一份。
  355. * 该列首次查询时也不存在,需要建列后重试(见 _ensureColumn)。
  356. */
  357. var dupQ = new Parse.Query(CLASS_NAME);
  358. dupQ.equalTo('user', user);
  359. dupQ.equalTo('roomId', roomId);
  360. dupQ.equalTo('fileUrl', url);
  361. var dup;
  362. try {
  363. dup = await dupQ.first({ useMasterKey: true });
  364. } catch (e) {
  365. if (!_isMissingColumn(e)) throw e;
  366. await _ensureColumns();
  367. dup = await dupQ.first({ useMasterKey: true });
  368. }
  369. if (dup) return _ok(response, { id: dup.id, duplicated: true });
  370. var obj = new Parse.Object(CLASS_NAME);
  371. obj.set('user', user);
  372. /*
  373. * 🔴 反查命中的群文件,`threadRoot` **一并改写为群 id**(v0.2.0)。
  374. *
  375. * 这是「不串台」的落地:一条文件只能属于一个页签。
  376. * 若仍保留 DM thread,单聊 `listBySession(dm)` 也会返回它 ——
  377. * 同一个文件在单聊和群里各出现一次。
  378. *
  379. * 原始的 DM thread 存进 `prevThreadRoot`,出问题可据此还原。
  380. */
  381. var resolvedByAnchor = !p.groupId && !!groupId;
  382. /*
  383. * roomId 是主键;threadRoot 落库保留(历史兼容 + 排查用),但不再作为归属依据。
  384. * 群归属命中时两者都改写为群 id,避免同一文件在两个页签各出现一次。
  385. */
  386. obj.set('roomId', resolvedByAnchor ? groupId : roomId);
  387. obj.set('threadRoot', resolvedByAnchor ? groupId : threadRoot || roomId);
  388. if (resolvedByAnchor) obj.set('prevThreadRoot', threadRoot);
  389. obj.set('groupId', groupId);
  390. obj.set(
  391. 'groupIdSource',
  392. groupId ? (resolvedByAnchor ? 'anchor' : _str(p.groupIdSource, 20) || 'app') : '',
  393. );
  394. obj.set('agentId', agentId);
  395. obj.set('uploadedBy', uploadedBy);
  396. obj.set('fileName', name);
  397. obj.set('fileSize', size === null ? -1 : size);
  398. obj.set('mimeType', _str(p.mimeType, LEN.mime));
  399. obj.set('storageKey', _str(p.key, LEN.key));
  400. /* 幂等键:顶层单独存一份 URL,见 opRegisterFile 的去重说明 */
  401. obj.set('fileUrl', url);
  402. obj.set('title', name);
  403. obj.set('summary', p.source ? '容器产出' : '用户上传');
  404. obj.set('status', 'delivered');
  405. obj.set('deliveredAt', new Date());
  406. obj.set('reportedAt', new Date());
  407. obj.set('tags', ['file-hub', uploadedBy]);
  408. /*
  409. * artifacts 保留数组形态:App 现有的 Deliverable 映射(mapDeliverable)
  410. * 按 artifacts[0].url 取链接,写在这里就不用改 App 的解析层。
  411. */
  412. obj.set('artifacts', [
  413. {
  414. name: name,
  415. url: url,
  416. type: _str(p.mimeType, LEN.mime),
  417. source: _str(p.source, LEN.url),
  418. },
  419. ]);
  420. // ACL:只给本人读写(成果属于个人会话,不跨用户可见)
  421. var acl = new Parse.ACL(user);
  422. acl.setPublicReadAccess(false);
  423. acl.setPublicWriteAccess(false);
  424. obj.setACL(acl);
  425. // 公司指针从 agent 带过来,保持与老成果表一致的多租户口径
  426. if (agentId) {
  427. try {
  428. var aq = new Parse.Query('FmodeAgent');
  429. aq.equalTo('agentId', agentId);
  430. var agent = await aq.first({ useMasterKey: true });
  431. if (agent) {
  432. obj.set('agent', agent);
  433. obj.set('agentObjectId', agent.id);
  434. var company = agent.get('company');
  435. if (company) obj.set('company', company);
  436. if (!agentId) obj.set('agentId', agent.get('agentId') || '');
  437. }
  438. } catch (e) {
  439. /* agent 查不到不影响登记:文件仍归属到用户与会话 */
  440. }
  441. }
  442. var saved = await obj.save(null, { useMasterKey: true });
  443. return _ok(response, { id: saved.id });
  444. }
  445. /**
  446. * listBySession —— 按会话拉文件(右侧栏主路径)
  447. *
  448. * 这是用户要求「每次直接从数据库加载文件」的入口:
  449. * 不依赖加载聊天历史,也不依赖 FmodeChatThread.deliverableUrls。
  450. *
  451. * ## 入参(v0.3.0,2026-11-13)
  452. *
  453. * 优先 `roomId`;只给了 `threadRoot` 时先反查房间,再按 roomId 查。
  454. * 两者都没有才报错。
  455. *
  456. * 为什么不能继续只按 threadRoot 查:容器登记的 threadRoot 实际是房间 id
  457. * (`_thread_root()` 的二级回落),而 App 传的是 `$approot_*` 会话根 ——
  458. * 两个值对不上,右栏永远查不到容器成果。
  459. */
  460. async function opListBySession(user, response) {
  461. var p = params || {};
  462. var roomId = _str(p.roomId, LEN.roomId);
  463. var threadRoot = _str(p.threadRoot, LEN.threadRoot);
  464. if (!roomId && threadRoot) {
  465. roomId = (await _roomOfThread(user, threadRoot)) || '';
  466. /* 反查不到:退回按 threadRoot 查(兼容老数据) */
  467. if (!roomId) roomId = threadRoot;
  468. }
  469. if (!roomId) return _fail(response, 400, 'roomId required');
  470. var q = new Parse.Query(CLASS_NAME);
  471. q.equalTo('user', user);
  472. q.equalTo('roomId', roomId);
  473. if (p.before) {
  474. var before = new Date(Number(p.before));
  475. if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
  476. }
  477. q.descending('deliveredAt');
  478. q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
  479. var rows;
  480. try {
  481. rows = await q.find({ useMasterKey: true });
  482. } catch (e) {
  483. // 列还不存在 → 建列后重试一次(历史数据没有 threadRoot,首次查询必然走到这里)
  484. if (!_isMissingColumn(e)) throw e;
  485. await _ensureColumns();
  486. rows = await q.find({ useMasterKey: true });
  487. }
  488. var list = _toFiles(rows);
  489. return _ok(response, {
  490. roomId: roomId,
  491. threadRoot: threadRoot,
  492. files: list,
  493. count: list.length,
  494. });
  495. }
  496. /**
  497. * listByUrl —— 按 URL 反查已登记记录(v0.3.0,2026-11-13)
  498. *
  499. * App 的「成果绑定服务」用它判断某个链接是否已经进了台账:
  500. * · 命中且 roomId 与当前房间一致 → 已绑定,什么都不用做
  501. * · 没命中 → 由 App 补登记
  502. *
  503. * 这是**跨会话**查询(只按 user + url),所以调用方必须自己再校验房间归属,
  504. * 不能拿它当作"这条属于我"的证据。返回里带 roomId 就是为了让调用方能校验。
  505. */
  506. async function opListByUrl(user, response) {
  507. var p = params || {};
  508. var url = _str(p.url, LEN.url);
  509. if (!url) return _fail(response, 400, 'url required');
  510. var q = new Parse.Query(CLASS_NAME);
  511. q.equalTo('user', user);
  512. q.equalTo('fileUrl', url);
  513. q.descending('deliveredAt');
  514. q.limit(Math.min(Number(p.limit) || 10, 50));
  515. var rows;
  516. try {
  517. rows = await q.find({ useMasterKey: true });
  518. } catch (e) {
  519. if (!_isMissingColumn(e)) throw e;
  520. await _ensureColumns();
  521. rows = await q.find({ useMasterKey: true });
  522. }
  523. var list = _toFiles(rows);
  524. return _ok(response, { files: list, count: list.length });
  525. }
  526. /** listByAgent —— 按容器拉(成果页用,不按会话过滤) */async function opListByAgent(user, response) {
  527. var p = params || {};
  528. var q = new Parse.Query(CLASS_NAME);
  529. q.equalTo('user', user);
  530. var agentId = _str(p.agentId, LEN.agentId);
  531. if (agentId) q.equalTo('agentId', agentId);
  532. if (p.before) {
  533. var before = new Date(Number(p.before));
  534. if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
  535. }
  536. q.descending('deliveredAt');
  537. q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
  538. var rows = await q.find({ useMasterKey: true });
  539. var list = _toFiles(rows);
  540. return _ok(response, { files: list, count: list.length });
  541. }
  542. /**
  543. * listByGroup —— 按**群**拉文件(v0.2.0,2026-11-11)
  544. *
  545. * ## 为什么不能只用 threadRoot
  546. *
  547. * 群里的文件有两种来源:
  548. * · **用户上传**:写入时 `threadRoot = groupId`(历史数据也只有 threadRoot)
  549. * · **容器产出**:容器只知道 DM thread,由服务端反查/补归并后
  550. * `threadRoot` 改写为群 id、同时补 `groupId`
  551. *
  552. * 查询用 `groupId == g OR threadRoot == g`,两条来源都能覆盖,
  553. * 也让**没有 groupId 的存量群内上传记录**照样查得到(无需数据迁移)。
  554. */
  555. async function opListByGroup(user, response) {
  556. var p = params || {};
  557. var groupId = _str(p.groupId, LEN.threadRoot);
  558. if (!groupId) return _fail(response, 400, 'groupId required');
  559. var byGroup = new Parse.Query(CLASS_NAME);
  560. byGroup.equalTo('user', user);
  561. byGroup.equalTo('groupId', groupId);
  562. var byThread = new Parse.Query(CLASS_NAME);
  563. byThread.equalTo('user', user);
  564. byThread.equalTo('threadRoot', groupId);
  565. var q = Parse.Query.or(byGroup, byThread);
  566. if (p.before) {
  567. var before = new Date(Number(p.before));
  568. if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
  569. }
  570. q.descending('deliveredAt');
  571. q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
  572. var rows;
  573. try {
  574. rows = await q.find({ useMasterKey: true });
  575. } catch (e) {
  576. if (!_isMissingColumn(e)) throw e;
  577. await _ensureColumns();
  578. rows = await q.find({ useMasterKey: true });
  579. }
  580. var list = _toFiles(rows);
  581. return _ok(response, { groupId: groupId, files: list, count: list.length });
  582. }
  583. /**
  584. * claimToGroup —— 惰性补归并(v0.2.0,2026-11-11)
  585. *
  586. * 有些容器产出登记时没能确定归属群(`groupId=''`)。群页面打开时,
  587. * App 把「有把握属于本群」的记录 id 传进来,这里把它们**搬**到群上:
  588. * 补 `groupId`、把 `threadRoot` 改写为群 id。
  589. *
  590. * 🔴 改写 threadRoot 是「互斥」的落地:一条文件只能出现在一个页签 ——
  591. * 搬进群之后,单聊 `listBySession(单聊sid)` 自然查不到它了。
  592. *
  593. * 安全:只允许操作**本人**的、`uploadedBy='agent'`(容器产出)、
  594. * 且当前 `groupId` 为空的记录。用户上传的文件不允许被搬。
  595. */
  596. async function opClaimToGroup(user, response) {
  597. var p = params || {};
  598. var groupId = _str(p.groupId, LEN.threadRoot);
  599. var ids = Array.isArray(p.ids) ? p.ids.filter((x) => typeof x === 'string' && x) : [];
  600. if (!groupId) return _fail(response, 400, 'groupId required');
  601. if (ids.length === 0) return _fail(response, 400, 'ids required');
  602. if (!(await _isOwnGroup(user, groupId))) {
  603. return _fail(response, 400, 'groupId must be your own group session');
  604. }
  605. var claimed = 0;
  606. var skipped = 0;
  607. for (var i = 0; i < ids.length && i < 100; i++) {
  608. var obj;
  609. try {
  610. var q = new Parse.Query(CLASS_NAME);
  611. q.equalTo('user', user);
  612. obj = await q.get(ids[i], { useMasterKey: true });
  613. } catch (e) {
  614. skipped++;
  615. continue;
  616. }
  617. if (!obj) {
  618. skipped++;
  619. continue;
  620. }
  621. if (obj.get('uploadedBy') !== 'agent' || obj.get('groupId')) {
  622. skipped++;
  623. continue;
  624. }
  625. try {
  626. /* 留一份原 threadRoot,便于出问题时还原 */
  627. if (!obj.get('prevThreadRoot')) obj.set('prevThreadRoot', obj.get('threadRoot') || '');
  628. obj.set('groupId', groupId);
  629. obj.set('groupIdSource', 'claim');
  630. obj.set('threadRoot', groupId);
  631. await obj.save(null, { useMasterKey: true });
  632. claimed++;
  633. } catch (e) {
  634. skipped++;
  635. }
  636. }
  637. return _ok(response, { groupId: groupId, claimed: claimed, skipped: skipped });
  638. }
  639. // ==================== 入口 ====================
  640. function handler(request, response) {
  641. (async function () {
  642. var p = params || {};
  643. var act = p.action || 'listBySession';
  644. var token = _sessionToken();
  645. var user = await _getUser(token);
  646. if (!user) return _fail(response, 401, 'invalid session');
  647. try {
  648. if (act === 'registerFile') return await opRegisterFile(user, response);
  649. if (act === 'listBySession') return await opListBySession(user, response);
  650. if (act === 'listByUrl') return await opListByUrl(user, response);
  651. if (act === 'listByAgent') return await opListByAgent(user, response);
  652. /* v0.2.0(2026-11-11):群隔离 */
  653. if (act === 'listByGroup') return await opListByGroup(user, response);
  654. if (act === 'claimToGroup') return await opClaimToGroup(user, response);
  655. return _fail(response, 400, 'unknown action: ' + act);
  656. } catch (e) {
  657. /*
  658. * Parse 的异常对象字符串化是 `[object Object]`,直接丢出去排查不了。
  659. * 新函数上线初期必须能看到真实原因(字段不存在 / 索引缺失等)。
  660. */
  661. var msg = 'unknown';
  662. try {
  663. msg = (e && (e.message || e.error || e.code)) || String(e);
  664. if (typeof msg === 'object') msg = JSON.stringify(msg);
  665. } catch (e2) {
  666. msg = String(e);
  667. }
  668. return _fail(response, 500, String(msg));
  669. }
  670. })();
  671. }