oR5O67i1Cm-fmodeagent-chatthreads-v0.1.0.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. // FmodeAgent 会话目录云函数(2026-10-31,v0.1.0)
  2. //
  3. // 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
  4. // 换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
  5. //
  6. // 调用:POST https://server.fmode.cn/api/functions
  7. // 头:X-Parse-Application-Id / X-Parse-Session-Token
  8. // body:{ id: "<本函数 objectId>", params: {...} }
  9. //
  10. // params.op:
  11. // list { agentId } → { code:200, threads:[...] }
  12. // upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
  13. // → { code:200, created, updated }
  14. // patch { threadRoot, title?, archived? } → { code:200, thread }
  15. // remove { threadRoot } → { code:200, removed }
  16. //
  17. // 平台约定(与 fmodeagent-matrix-provision 一致,实测):
  18. // · 入口 async function handler(request, response);入参在**全局 params**;
  19. // · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
  20. // · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
  21. //
  22. // 安全约定:
  23. // 1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
  24. // 2. 会话只归用户本人(不写 company),不会进公司共享视角;
  25. // 3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
  26. // 4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
  27. //
  28. // 表:FmodeChatThread(首次写入自动建表)
  29. // user Pointer<_User> / agentId String / roomId String / threadRoot String
  30. // title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
  31. // archived Boolean / isDeleted Boolean / lastPreview String
  32. // 唯一语义:(user, roomId, threadRoot) —— upsert 前查重保证
  33. var CLASS_NAME = 'FmodeChatThread';
  34. var MAX_ITEMS = 200;
  35. var TITLE_MAX = 60;
  36. var PREVIEW_MAX = 120;
  37. function _ok(data) {
  38. response.status(200).json(Object.assign({ code: 200 }, data || {}));
  39. return true;
  40. }
  41. function _fail(code, mess) {
  42. response.status(200).json({ code: code, mess: mess });
  43. return false;
  44. }
  45. function _str(v, max) {
  46. var s = v == null ? '' : String(v);
  47. return max && s.length > max ? s.slice(0, max) : s;
  48. }
  49. function _date(v) {
  50. if (!v) return new Date();
  51. var d = v instanceof Date ? v : new Date(v);
  52. return isNaN(d.getTime()) ? new Date() : d;
  53. }
  54. /** token → _User(与 fmodeagent-matrix-provision 完全一致) */
  55. async function _getUser(t) {
  56. if (!t) return null;
  57. if (request && request.user && request.user.id) return request.user;
  58. // request.user 未被平台注入(实测),走 token 查 _Session
  59. try {
  60. var q = new Parse.Query(Parse.Session);
  61. q.equalTo('sessionToken', t);
  62. var s = await q.first({ useMasterKey: true });
  63. return s ? s.get('user') : null;
  64. } catch (e) {
  65. return null;
  66. }
  67. }
  68. /** 取当前会话 token(与平台其他函数同一约定) */
  69. /**
  70. * 取当前会话 token。
  71. *
  72. * 🔴 实测(2026-10-31,加 __debug 探测得出):
  73. * · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
  74. * · request.body 只剩 {id, params},body 里的 token 被平台剥离;
  75. * · request.user **没有**被注入。
  76. * ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
  77. */
  78. function _sessionToken() {
  79. var h = request && request.headers ? request.headers : {};
  80. if (h['x-parse-session-token']) return h['x-parse-session-token'];
  81. if (request && request.body && request.body.token) return request.body.token;
  82. if (params && params.sessionToken) return params.sessionToken;
  83. return null;
  84. }
  85. /** 查某用户在某房间下的一条线程 */
  86. function _findOne(userPtr, roomId, threadRoot) {
  87. var q = new Parse.Query(CLASS_NAME);
  88. q.equalTo('user', userPtr);
  89. q.equalTo('roomId', roomId);
  90. q.equalTo('threadRoot', threadRoot);
  91. q.limit(1);
  92. return q.first({ useMasterKey: true });
  93. }
  94. async function opList(userPtr, p) {
  95. var q = new Parse.Query(CLASS_NAME);
  96. q.equalTo('user', userPtr);
  97. /*
  98. * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
  99. * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
  100. */
  101. if (p.includeDeleted) q.equalTo('isDeleted', true);
  102. else q.notEqualTo('isDeleted', true);
  103. if (p.agentId) q.equalTo('agentId', String(p.agentId));
  104. if (p.roomId) q.equalTo('roomId', String(p.roomId));
  105. q.descending('lastMessageAt');
  106. q.limit(500);
  107. var rows = await q.find({ useMasterKey: true });
  108. return _ok({
  109. threads: rows.map(function (r) {
  110. return {
  111. objectId: r.id,
  112. agentId: r.get('agentId') || '',
  113. roomId: r.get('roomId') || '',
  114. threadRoot: r.get('threadRoot') || '',
  115. title: r.get('title') || '',
  116. titleLocked: !!r.get('titleLocked'),
  117. messageCount: r.get('messageCount') || 0,
  118. lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
  119. archived: !!r.get('archived'),
  120. lastPreview: r.get('lastPreview') || '',
  121. };
  122. }),
  123. });
  124. }
  125. async function opUpsert(userPtr, p) {
  126. var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
  127. if (items.length === 0) return _fail(400, 'items 不能为空');
  128. var created = 0;
  129. var updated = 0;
  130. for (var i = 0; i < items.length; i++) {
  131. var it = items[i] || {};
  132. var roomId = _str(it.roomId, 200);
  133. var threadRoot = _str(it.threadRoot, 200);
  134. if (!roomId || !threadRoot) continue;
  135. var rec = await _findOne(userPtr, roomId, threadRoot);
  136. if (!rec) {
  137. rec = new Parse.Object(CLASS_NAME);
  138. rec.set('user', userPtr);
  139. rec.set('roomId', roomId);
  140. rec.set('threadRoot', threadRoot);
  141. rec.set('messageCount', 0);
  142. rec.set('archived', false);
  143. rec.set('isDeleted', false);
  144. rec.set('titleLocked', false);
  145. created++;
  146. } else {
  147. updated++;
  148. }
  149. // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
  150. var incomingTitle = _str(it.title, TITLE_MAX);
  151. if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
  152. rec.set('title', incomingTitle);
  153. }
  154. if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
  155. if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
  156. rec.set('messageCount', it.messageCount);
  157. }
  158. if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
  159. if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
  160. // 重新出现即视为未删除(例如被误删的线程又有了新消息)
  161. if (it.revive) rec.set('isDeleted', false);
  162. await rec.save(null, { useMasterKey: true });
  163. }
  164. return _ok({ created: created, updated: updated });
  165. }
  166. async function opPatch(userPtr, p) {
  167. var threadRoot = _str(p.threadRoot, 200);
  168. if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
  169. var q = new Parse.Query(CLASS_NAME);
  170. q.equalTo('user', userPtr);
  171. q.equalTo('threadRoot', threadRoot);
  172. q.notEqualTo('isDeleted', true);
  173. q.descending('updatedAt');
  174. var rec = await q.first({ useMasterKey: true });
  175. if (!rec) return _fail(404, '会话不存在');
  176. if (p.title != null) {
  177. var t = _str(p.title, TITLE_MAX);
  178. if (!t) return _fail(400, '标题不能为空');
  179. rec.set('title', t);
  180. rec.set('titleLocked', true);
  181. }
  182. if (p.archived != null) rec.set('archived', !!p.archived);
  183. await rec.save(null, { useMasterKey: true });
  184. return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
  185. }
  186. /**
  187. * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
  188. *
  189. * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
  190. * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
  191. * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
  192. */
  193. async function opRemove(userPtr, p) {
  194. var threadRoot = _str(p.threadRoot, 200);
  195. if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
  196. /*
  197. * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
  198. * 平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
  199. * 匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
  200. * 用 Pointer 显式指定 className + objectId 最稳。
  201. */
  202. var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
  203. /* ① 查全部同线程记录(含已软删) */
  204. var q = new Parse.Query(CLASS_NAME);
  205. q.equalTo('user', ptr);
  206. q.equalTo('threadRoot', threadRoot);
  207. q.limit(100);
  208. var rows = await q.find({ useMasterKey: true });
  209. console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
  210. /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
  211. var tomb = null;
  212. for (var i = 0; i < rows.length; i++) {
  213. if (rows[i].get('isDeleted') === true && !tomb) {
  214. tomb = rows[i];
  215. continue;
  216. }
  217. try {
  218. await rows[i].destroy({ useMasterKey: true });
  219. } catch (e) {
  220. /* 单条删除失败不影响其余 */
  221. }
  222. }
  223. if (!tomb) {
  224. tomb = new Parse.Object(CLASS_NAME);
  225. tomb.set('user', ptr);
  226. tomb.set('threadRoot', threadRoot);
  227. tomb.set('roomId', _str(p.roomId, 200) || '');
  228. tomb.set('agentId', _str(p.agentId, 120) || '');
  229. tomb.set('title', '');
  230. tomb.set('messageCount', 0);
  231. tomb.set('isDeleted', true);
  232. await tomb.save(null, { useMasterKey: true });
  233. }
  234. return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
  235. }
  236. async function handler(request, response) {
  237. try {
  238. /*
  239. * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
  240. * (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
  241. * 业务参数在全局 params;用户身份两种来源都兼容。
  242. */
  243. params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
  244. var user = await _getUser(_sessionToken());
  245. if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
  246. switch (params.op) {
  247. case 'list':
  248. return await opList(user, params);
  249. case 'upsert':
  250. return await opUpsert(user, params);
  251. case 'patch':
  252. return await opPatch(user, params);
  253. case 'remove':
  254. return await opRemove(user, params);
  255. default:
  256. return _fail(400, '未知 op: ' + params.op);
  257. }
  258. } catch (e) {
  259. return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
  260. }
  261. }