fmode-file-hub.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. // fmode-file-hub v0.1.0 — 文件台账(按会话隔离)(表: AgentDeliverable)
  2. //
  3. // ## 为什么新建而不是改老函数
  4. //
  5. // 老函数 lfYlgU7SkK(fmodeagent-deliverables) 的 report 用 UPDATABLE 白名单写字段,
  6. // 里面**没有 threadRoot** —— 容器传了也会被静默丢弃,所以成果永远无法归属到会话。
  7. // 用户裁决(2026-11-10):**现有云函数一行不动,新建独立函数走新接口**。
  8. //
  9. // ## 职责边界(用户裁决)
  10. //
  11. // · 消息**不入库**(单聊/群聊都在 Matrix)
  12. // · 只有**文件与成果**入库,目的是让右侧栏能按会话直接加载
  13. // · 只存访问链接与文件元信息,**不存文件内容**(内容由 AlP56LCKFm 签名直传 OBS)
  14. // · 不做老数据适配:没有 threadRoot 的历史成果不出现,按用户裁决"全面切新逻辑"
  15. //
  16. // ## 调用
  17. //
  18. // POST /api/functions {token, id:<Function objectId>, params:{action, ...}}
  19. // action: registerFile | listBySession | listByAgent
  20. //
  21. // ## 表
  22. //
  23. // 复用 AgentDeliverable(已有 user / company / ACL),新增字段:
  24. // threadRoot / uploadedBy / fileName / fileSize / mimeType / storageKey / roomId
  25. // ==================== 基础工具(各云函数本就各自复制一份,沿用惯例) ====================
  26. var CLASS_NAME = 'AgentDeliverable';
  27. var MAX_LIMIT = 200;
  28. var DEFAULT_LIMIT = 50;
  29. /** 会话标识、URL、文件名等的长度上限 */
  30. var LEN = { threadRoot: 200, roomId: 200, agentId: 120, url: 1000, name: 200, key: 500, mime: 120 };
  31. /** 单个文件大小上限(与 App 侧 upload-limits.ts 的 20MB 保持一致) */
  32. var MAX_FILE_BYTES = 20 * 1024 * 1024;
  33. function _ok(response, data) {
  34. var body = { code: 200 };
  35. if (data) for (var k in data) body[k] = data[k];
  36. response.status(200).json(body);
  37. }
  38. function _fail(response, code, msg) {
  39. response.status(200).json({ code: code, msg: msg });
  40. }
  41. function _str(v, max) {
  42. if (v === undefined || v === null) return '';
  43. var s = String(v).trim();
  44. return max && s.length > max ? s.slice(0, max) : s;
  45. }
  46. function _num(v) {
  47. var n = Number(v);
  48. return isFinite(n) && n >= 0 ? n : null;
  49. }
  50. function _sessionToken() {
  51. // 与 fmodeagent-status 同口径:请求头优先(平台实测只保留请求头)
  52. if (request.headers && request.headers['x-parse-session-token']) {
  53. return request.headers['x-parse-session-token'];
  54. }
  55. if (params && params.token) return params.token;
  56. if (request.body && request.body.token) return request.body.token;
  57. return null;
  58. }
  59. async function _getUser(t) {
  60. if (!t) return null;
  61. if (request.user && request.user.id) return request.user;
  62. try {
  63. var q = new Parse.Query(Parse.Session);
  64. q.equalTo('sessionToken', t);
  65. q.include('user');
  66. var s = await q.first({ useMasterKey: true });
  67. return s ? s.get('user') : null;
  68. } catch (e) {
  69. return null;
  70. }
  71. }
  72. /** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */
  73. function _isCloudUrl(u) {
  74. return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
  75. }
  76. /**
  77. * 确保 class 上存在某个列(2026-11-10)。
  78. *
  79. * 🔴 Parse 的自动建列**只在写入时发生**:数据库里还没有 `threadRoot` 列时,
  80. * 直接 `equalTo('threadRoot', x)` 会让 PostgreSQL 报 42703 errorMissingColumn。
  81. * 历史数据本来就没有这个字段,所以第一次查询必然踩到。
  82. *
  83. * 做法:写一个带该字段的探测对象再立刻销毁,把列建出来。
  84. * 只在捕获到缺列错误时执行一次,正常路径零开销。
  85. */
  86. var _ensured = false;
  87. /**
  88. * 一次性建出所有新列。
  89. *
  90. * 历史数据里这些字段一个都不存在,分散建列会让每个字段都触发一次
  91. * 失败查询 + 重试,这里一次建全,后续查询零开销。
  92. */
  93. async function _ensureColumns() {
  94. if (_ensured) return;
  95. var probe = new Parse.Object(CLASS_NAME);
  96. probe.set('threadRoot', '');
  97. probe.set('fileUrl', '');
  98. probe.set('uploadedBy', '');
  99. probe.set('fileName', '');
  100. probe.set('fileSize', 0);
  101. probe.set('mimeType', '');
  102. probe.set('storageKey', '');
  103. probe.set('roomId', '');
  104. await probe.save(null, { useMasterKey: true });
  105. // 探测对象必须销毁,否则会在成果列表里留下一条空记录
  106. await probe.destroy({ useMasterKey: true });
  107. _ensured = true;
  108. }
  109. /**
  110. * 判断是否「列不存在」错误。
  111. *
  112. * 🔴 只认 PostgreSQL 的错误码,不能按字段名匹配 ——
  113. * 查询语句本身就包含字段名,按名匹配会让任何错误都被当成缺列。
  114. */
  115. function _isMissingColumn(e) {
  116. try {
  117. var s = JSON.stringify(e);
  118. return s.indexOf('42703') >= 0 || s.indexOf('errorMissingColumn') >= 0;
  119. } catch (e2) {
  120. return false;
  121. }
  122. }
  123. // ==================== 行 → 返回对象 ====================
  124. function _toFile(r) {
  125. var artifacts = r.get('artifacts') || [];
  126. var first = Array.isArray(artifacts) && artifacts.length ? artifacts[0] : null;
  127. if (typeof first === 'string') first = { url: first, name: '' };
  128. return {
  129. id: r.id,
  130. threadRoot: r.get('threadRoot') || '',
  131. agentId: r.get('agentId') || '',
  132. roomId: r.get('roomId') || '',
  133. // 文件信息优先取新字段,回退到 artifacts[0](两者写入时保持一致)
  134. name: r.get('fileName') || (first && first.name) || '',
  135. size: typeof r.get('fileSize') === 'number' ? r.get('fileSize') : -1,
  136. mimeType: r.get('mimeType') || (first && first.mimeType) || '',
  137. url: (first && first.url) || '',
  138. storageKey: r.get('storageKey') || '',
  139. uploadedBy: r.get('uploadedBy') || 'agent',
  140. status: r.get('status') || 'delivered',
  141. deliveredAt: (r.get('deliveredAt') || r.get('createdAt'))
  142. ? new Date(r.get('deliveredAt') || r.get('createdAt')).getTime()
  143. : 0,
  144. };
  145. }
  146. // ==================== actions ====================
  147. /**
  148. * registerFile —— 登记一条文件记录
  149. *
  150. * 用户上传(uploadedBy:'user')与容器产出(uploadedBy:'agent')共用。
  151. * 幂等:同一 (user, threadRoot, url) 重复登记返回已有记录,不产生重复。
  152. */
  153. async function opRegisterFile(user, response) {
  154. var p = params || {};
  155. var threadRoot = _str(p.threadRoot, LEN.threadRoot);
  156. var url = _str(p.url, LEN.url);
  157. var name = _str(p.name, LEN.name);
  158. if (!threadRoot) return _fail(response, 400, 'threadRoot required');
  159. if (!url) return _fail(response, 400, 'url required');
  160. if (!_isCloudUrl(url)) return _fail(response, 400, 'url must be a platform cloud link');
  161. if (!name) return _fail(response, 400, 'name required');
  162. var size = _num(p.size);
  163. if (size !== null && size > MAX_FILE_BYTES) return _fail(response, 400, 'file too large');
  164. var uploadedBy = p.uploadedBy === 'user' ? 'user' : 'agent';
  165. var agentId = _str(p.agentId, LEN.agentId);
  166. /*
  167. * 幂等:同一会话内同一 URL 只存一条。
  168. *
  169. * 🔴 不能查 `artifacts.url`(数组内字段)—— 实测该查询永远匹配不到,
  170. * 会导致重复登记。改查顶层 `fileUrl`:registerFile 写入时同步落一份。
  171. * 该列首次查询时也不存在,需要建列后重试(见 _ensureColumn)。
  172. */
  173. var dupQ = new Parse.Query(CLASS_NAME);
  174. dupQ.equalTo('user', user);
  175. dupQ.equalTo('threadRoot', threadRoot);
  176. dupQ.equalTo('fileUrl', url);
  177. var dup;
  178. try {
  179. dup = await dupQ.first({ useMasterKey: true });
  180. } catch (e) {
  181. if (!_isMissingColumn(e)) throw e;
  182. await _ensureColumns();
  183. dup = await dupQ.first({ useMasterKey: true });
  184. }
  185. if (dup) return _ok(response, { id: dup.id, duplicated: true });
  186. var obj = new Parse.Object(CLASS_NAME);
  187. obj.set('user', user);
  188. obj.set('threadRoot', threadRoot);
  189. obj.set('roomId', _str(p.roomId, LEN.roomId));
  190. obj.set('agentId', agentId);
  191. obj.set('uploadedBy', uploadedBy);
  192. obj.set('fileName', name);
  193. obj.set('fileSize', size === null ? -1 : size);
  194. obj.set('mimeType', _str(p.mimeType, LEN.mime));
  195. obj.set('storageKey', _str(p.key, LEN.key));
  196. /* 幂等键:顶层单独存一份 URL,见 opRegisterFile 的去重说明 */
  197. obj.set('fileUrl', url);
  198. obj.set('title', name);
  199. obj.set('summary', p.source ? '容器产出' : '用户上传');
  200. obj.set('status', 'delivered');
  201. obj.set('deliveredAt', new Date());
  202. obj.set('reportedAt', new Date());
  203. obj.set('tags', ['file-hub', uploadedBy]);
  204. /*
  205. * artifacts 保留数组形态:App 现有的 Deliverable 映射(mapDeliverable)
  206. * 按 artifacts[0].url 取链接,写在这里就不用改 App 的解析层。
  207. */
  208. obj.set('artifacts', [
  209. {
  210. name: name,
  211. url: url,
  212. type: _str(p.mimeType, LEN.mime),
  213. source: _str(p.source, LEN.url),
  214. },
  215. ]);
  216. // ACL:只给本人读写(成果属于个人会话,不跨用户可见)
  217. var acl = new Parse.ACL(user);
  218. acl.setPublicReadAccess(false);
  219. acl.setPublicWriteAccess(false);
  220. obj.setACL(acl);
  221. // 公司指针从 agent 带过来,保持与老成果表一致的多租户口径
  222. if (agentId) {
  223. try {
  224. var aq = new Parse.Query('FmodeAgent');
  225. aq.equalTo('agentId', agentId);
  226. var agent = await aq.first({ useMasterKey: true });
  227. if (agent) {
  228. obj.set('agent', agent);
  229. obj.set('agentObjectId', agent.id);
  230. var company = agent.get('company');
  231. if (company) obj.set('company', company);
  232. if (!agentId) obj.set('agentId', agent.get('agentId') || '');
  233. }
  234. } catch (e) {
  235. /* agent 查不到不影响登记:文件仍归属到用户与会话 */
  236. }
  237. }
  238. var saved = await obj.save(null, { useMasterKey: true });
  239. return _ok(response, { id: saved.id });
  240. }
  241. /**
  242. * listBySession —— 按会话拉文件(右侧栏主路径)
  243. *
  244. * 这是用户要求「每次直接从数据库加载文件」的入口:
  245. * 不依赖加载聊天历史,也不依赖 FmodeChatThread.deliverableUrls。
  246. */
  247. async function opListBySession(user, response) {
  248. var p = params || {};
  249. var threadRoot = _str(p.threadRoot, LEN.threadRoot);
  250. if (!threadRoot) return _fail(response, 400, 'threadRoot required');
  251. var q = new Parse.Query(CLASS_NAME);
  252. q.equalTo('user', user);
  253. q.equalTo('threadRoot', threadRoot);
  254. if (p.before) {
  255. var before = new Date(Number(p.before));
  256. if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
  257. }
  258. q.descending('deliveredAt');
  259. q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
  260. var rows;
  261. try {
  262. rows = await q.find({ useMasterKey: true });
  263. } catch (e) {
  264. // 列还不存在 → 建列后重试一次(历史数据没有 threadRoot,首次查询必然走到这里)
  265. if (!_isMissingColumn(e)) throw e;
  266. await _ensureColumns();
  267. rows = await q.find({ useMasterKey: true });
  268. }
  269. return _ok(response, {
  270. threadRoot: threadRoot,
  271. files: rows.map(_toFile),
  272. count: rows.length,
  273. });
  274. }
  275. /** listByAgent —— 按容器拉(成果页用,不按会话过滤) */
  276. async function opListByAgent(user, response) {
  277. var p = params || {};
  278. var q = new Parse.Query(CLASS_NAME);
  279. q.equalTo('user', user);
  280. var agentId = _str(p.agentId, LEN.agentId);
  281. if (agentId) q.equalTo('agentId', agentId);
  282. if (p.before) {
  283. var before = new Date(Number(p.before));
  284. if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before);
  285. }
  286. q.descending('deliveredAt');
  287. q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT));
  288. var rows = await q.find({ useMasterKey: true });
  289. return _ok(response, { files: rows.map(_toFile), count: rows.length });
  290. }
  291. // ==================== 入口 ====================
  292. function handler(request, response) {
  293. (async function () {
  294. var p = params || {};
  295. var act = p.action || 'listBySession';
  296. var token = _sessionToken();
  297. var user = await _getUser(token);
  298. if (!user) return _fail(response, 401, 'invalid session');
  299. try {
  300. if (act === 'registerFile') return await opRegisterFile(user, response);
  301. if (act === 'listBySession') return await opListBySession(user, response);
  302. if (act === 'listByAgent') return await opListByAgent(user, response);
  303. return _fail(response, 400, 'unknown action: ' + act);
  304. } catch (e) {
  305. /*
  306. * Parse 的异常对象字符串化是 `[object Object]`,直接丢出去排查不了。
  307. * 新函数上线初期必须能看到真实原因(字段不存在 / 索引缺失等)。
  308. */
  309. var msg = 'unknown';
  310. try {
  311. msg = (e && (e.message || e.error || e.code)) || String(e);
  312. if (typeof msg === 'object') msg = JSON.stringify(msg);
  313. } catch (e2) {
  314. msg = String(e);
  315. }
  316. return _fail(response, 500, String(msg));
  317. }
  318. })();
  319. }