|
@@ -0,0 +1,339 @@
|
|
|
|
|
+// FmodeAgent 会话目录云函数(2026-10-31 建,2026-11-01 v0.1.3 修复重复记录)
|
|
|
|
|
+//
|
|
|
|
|
+// 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
|
|
|
|
|
+// 换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
|
|
|
|
|
+//
|
|
|
|
|
+// 调用:POST https://server.fmode.cn/api/functions
|
|
|
|
|
+// 头:X-Parse-Application-Id / X-Parse-Session-Token
|
|
|
|
|
+// body:{ id: "<本函数 objectId>", params: {...} }
|
|
|
|
|
+//
|
|
|
|
|
+// params.op:
|
|
|
|
|
+// list { agentId } → { code:200, threads:[...] }
|
|
|
|
|
+// upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
|
|
|
|
|
+// → { code:200, created, updated }
|
|
|
|
|
+// patch { threadRoot, title?, archived? } → { code:200, thread }
|
|
|
|
|
+// remove { threadRoot } → { code:200, removed }
|
|
|
|
|
+//
|
|
|
|
|
+// 平台约定(与 fmodeagent-matrix-provision 一致,实测):
|
|
|
|
|
+// · 入口 async function handler(request, response);入参在**全局 params**;
|
|
|
|
|
+// · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
|
|
|
|
|
+// · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
|
|
|
|
|
+//
|
|
|
|
|
+// 安全约定:
|
|
|
|
|
+// 1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
|
|
|
|
|
+// 2. 会话只归用户本人(不写 company),不会进公司共享视角;
|
|
|
|
|
+// 3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
|
|
|
|
|
+// 4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
|
|
|
|
|
+//
|
|
|
|
|
+// 表:FmodeChatThread(首次写入自动建表)
|
|
|
|
|
+// user Pointer<_User> / agentId String / roomId String / threadRoot String
|
|
|
|
|
+// title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
|
|
|
|
|
+// archived Boolean / isDeleted Boolean / lastPreview String
|
|
|
|
|
+// workspaceId String(归属工作区;'' = 日常对话)— v0.1.4 新增
|
|
|
|
|
+// 唯一语义:(user, threadRoot) —— upsert 前查重保证
|
|
|
|
|
+
|
|
|
|
|
+var CLASS_NAME = 'FmodeChatThread';
|
|
|
|
|
+var MAX_ITEMS = 200;
|
|
|
|
|
+var TITLE_MAX = 60;
|
|
|
|
|
+var PREVIEW_MAX = 120;
|
|
|
|
|
+
|
|
|
|
|
+function _ok(data) {
|
|
|
|
|
+ response.status(200).json(Object.assign({ code: 200 }, data || {}));
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+function _fail(code, mess) {
|
|
|
|
|
+ response.status(200).json({ code: code, mess: mess });
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function _str(v, max) {
|
|
|
|
|
+ var s = v == null ? '' : String(v);
|
|
|
|
|
+ return max && s.length > max ? s.slice(0, max) : s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function _date(v) {
|
|
|
|
|
+ if (!v) return new Date();
|
|
|
|
|
+ var d = v instanceof Date ? v : new Date(v);
|
|
|
|
|
+ return isNaN(d.getTime()) ? new Date() : d;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * token → _User(唯一权威解析)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 加固(2026-11-01,**不是**重复记录的根因,属正确性改进):
|
|
|
|
|
+ * 上一版先判 `request.user && request.user.id` 就直接 `return request.user`。
|
|
|
|
|
+ * 平台注入的 `request.user` 实测不一定带出 `objectId`,拿它当
|
|
|
|
|
+ * `equalTo('user', ...)` 的条件会匹配不到记录。
|
|
|
|
|
+ * 现在以 token 查 `_Session` 得到的真实 `_User` 为准,
|
|
|
|
|
+ * `request.user` 只在**确实带出 objectId** 时作为兜底。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ⚠️ 重复记录的真根因在 `_findAllByRoot`(查重键含 roomId),见其注释。
|
|
|
|
|
+ */
|
|
|
|
|
+async function _getUser(t) {
|
|
|
|
|
+ if (!t) return null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ var q = new Parse.Query(Parse.Session);
|
|
|
|
|
+ q.equalTo('sessionToken', t);
|
|
|
|
|
+ q.include('user');
|
|
|
|
|
+ var s = await q.first({ useMasterKey: true });
|
|
|
|
|
+ if (s) {
|
|
|
|
|
+ var u = s.get('user');
|
|
|
|
|
+ if (u) return u;
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ /* 落到 request.user 兜底 */
|
|
|
|
|
+ }
|
|
|
|
|
+ // 兜底:平台注入了**完整**用户对象时使用(残缺 Pointer 一律不用)
|
|
|
|
|
+ if (request && request.user && request.user.id) return request.user;
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 取当前会话 token(与平台其他函数同一约定) */
|
|
|
|
|
+/**
|
|
|
|
|
+ * 取当前会话 token。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 实测(2026-10-31,加 __debug 探测得出):
|
|
|
|
|
+ * · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
|
|
|
|
|
+ * · request.body 只剩 {id, params},body 里的 token 被平台剥离;
|
|
|
|
|
+ * · request.user **没有**被注入。
|
|
|
|
|
+ * ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
|
|
|
|
|
+ */
|
|
|
|
|
+function _sessionToken() {
|
|
|
|
|
+ var h = request && request.headers ? request.headers : {};
|
|
|
|
|
+ if (h['x-parse-session-token']) return h['x-parse-session-token'];
|
|
|
|
|
+ if (request && request.body && request.body.token) return request.body.token;
|
|
|
|
|
+ if (params && params.sessionToken) return params.sessionToken;
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 查某用户**所有房间**下同一线程根的全部记录(2026-11-01)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么按 `threadRoot` 而不是 `(roomId, threadRoot)` 查:
|
|
|
|
|
+ * · `threadRoot` 是客户端生成的全局唯一 ID,一个会话只可能属于一个线程;
|
|
|
|
|
+ * · 房间可能变(agent 重建、换绑定),同一会话被写进新房间后,
|
|
|
|
|
+ * 旧房间那条会变成孤儿 —— 必须收敛掉,不能各留一条。
|
|
|
|
|
+ */
|
|
|
|
|
+function _findAllByRoot(userPtr, threadRoot) {
|
|
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
|
|
+ q.equalTo('user', userPtr);
|
|
|
|
|
+ q.equalTo('threadRoot', threadRoot);
|
|
|
|
|
+ q.limit(50);
|
|
|
|
|
+ return q.find({ useMasterKey: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function opList(userPtr, p) {
|
|
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
|
|
+ q.equalTo('user', userPtr);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
|
|
|
|
|
+ * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (p.includeDeleted) q.equalTo('isDeleted', true);
|
|
|
|
|
+ else q.notEqualTo('isDeleted', true);
|
|
|
|
|
+ if (p.agentId) q.equalTo('agentId', String(p.agentId));
|
|
|
|
|
+ if (p.roomId) q.equalTo('roomId', String(p.roomId));
|
|
|
|
|
+ q.descending('lastMessageAt');
|
|
|
|
|
+ q.limit(500);
|
|
|
|
|
+ var rows = await q.find({ useMasterKey: true });
|
|
|
|
|
+ return _ok({
|
|
|
|
|
+ threads: rows.map(function (r) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ objectId: r.id,
|
|
|
|
|
+ agentId: r.get('agentId') || '',
|
|
|
|
|
+ roomId: r.get('roomId') || '',
|
|
|
|
|
+ threadRoot: r.get('threadRoot') || '',
|
|
|
|
|
+ title: r.get('title') || '',
|
|
|
|
|
+ titleLocked: !!r.get('titleLocked'),
|
|
|
|
|
+ messageCount: r.get('messageCount') || 0,
|
|
|
|
|
+ lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
|
|
|
|
|
+ archived: !!r.get('archived'),
|
|
|
|
|
+ lastPreview: r.get('lastPreview') || '',
|
|
|
|
|
+ // v0.1.4:工作区归属随列表返回,客户端据此还原层级
|
|
|
|
|
+ workspaceId: r.get('workspaceId') || '',
|
|
|
|
|
+ };
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function opUpsert(userPtr, p) {
|
|
|
|
|
+ var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
|
|
|
|
|
+ if (items.length === 0) return _fail(400, 'items 不能为空');
|
|
|
|
|
+ var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
|
|
|
|
|
+ var created = 0;
|
|
|
|
|
+ var updated = 0;
|
|
|
|
|
+ var collapsed = 0;
|
|
|
|
|
+ for (var i = 0; i < items.length; i++) {
|
|
|
|
|
+ var it = items[i] || {};
|
|
|
|
|
+ var roomId = _str(it.roomId, 200);
|
|
|
|
|
+ var threadRoot = _str(it.threadRoot, 200);
|
|
|
|
|
+ if (!roomId || !threadRoot) continue;
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 按 threadRoot 查全部记录(不只是当前房间)。
|
|
|
|
|
+ * 房间可能变化;同时兜住历史脏数据里「同一线程根在多条记录」的情况 ——
|
|
|
|
|
+ * 取最新一条作为主记录,其余物理删除(索引重复没有任何保留价值)。
|
|
|
|
|
+ */
|
|
|
|
|
+ var rows = await _findAllByRoot(ptr, threadRoot);
|
|
|
|
|
+ var rec = null;
|
|
|
|
|
+ for (var j = 0; j < rows.length; j++) {
|
|
|
|
|
+ if (!rec || rows[j].updatedAt >= rec.updatedAt) rec = rows[j];
|
|
|
|
|
+ }
|
|
|
|
|
+ for (var k = 0; k < rows.length; k++) {
|
|
|
|
|
+ if (rec && rows[k].id !== rec.id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await rows[k].destroy({ useMasterKey: true });
|
|
|
|
|
+ collapsed++;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ /* 单条失败不影响其余 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!rec) {
|
|
|
|
|
+ rec = new Parse.Object(CLASS_NAME);
|
|
|
|
|
+ rec.set('user', ptr);
|
|
|
|
|
+ rec.set('roomId', roomId);
|
|
|
|
|
+ rec.set('threadRoot', threadRoot);
|
|
|
|
|
+ rec.set('messageCount', 0);
|
|
|
|
|
+ rec.set('archived', false);
|
|
|
|
|
+ rec.set('isDeleted', false);
|
|
|
|
|
+ rec.set('titleLocked', false);
|
|
|
|
|
+ created++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ updated++;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
|
|
|
|
|
+ var incomingTitle = _str(it.title, TITLE_MAX);
|
|
|
|
|
+ if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
|
|
|
|
|
+ rec.set('title', incomingTitle);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 房间以本次上报为准(会话跟着房间走)
|
|
|
|
|
+ rec.set('roomId', roomId);
|
|
|
|
|
+ if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
|
|
|
|
|
+ if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
|
|
|
|
|
+ rec.set('messageCount', it.messageCount);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
|
|
|
|
|
+ if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
|
|
|
|
|
+ /*
|
|
|
|
|
+ * v0.1.4:工作区归属。
|
|
|
|
|
+ * 🔴 用 `!= null` 判定而不是真值判断 —— '' 是合法值(代表「移回日常对话」),
|
|
|
|
|
+ * 用 if (it.workspaceId) 会把「移回日常」当成「没传」,归属永远清不掉。
|
|
|
|
|
+ */
|
|
|
|
|
+ if (it.workspaceId !== undefined && it.workspaceId !== null) {
|
|
|
|
|
+ rec.set('workspaceId', _str(it.workspaceId, 120));
|
|
|
|
|
+ }
|
|
|
|
|
+ // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
|
|
|
|
|
+ // 上一版只有传 revive:true 才解除,客户端漏传就会出现
|
|
|
|
|
+ // 「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。
|
|
|
|
|
+ rec.set('isDeleted', false);
|
|
|
|
|
+ await rec.save(null, { useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ return _ok({ created: created, updated: updated, collapsed: collapsed });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function opPatch(userPtr, p) {
|
|
|
|
|
+ var threadRoot = _str(p.threadRoot, 200);
|
|
|
|
|
+ if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
|
|
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
|
|
+ q.equalTo('user', userPtr);
|
|
|
|
|
+ q.equalTo('threadRoot', threadRoot);
|
|
|
|
|
+ q.notEqualTo('isDeleted', true);
|
|
|
|
|
+ q.descending('updatedAt');
|
|
|
|
|
+ var rec = await q.first({ useMasterKey: true });
|
|
|
|
|
+ if (!rec) return _fail(404, '会话不存在');
|
|
|
|
|
+ if (p.title != null) {
|
|
|
|
|
+ var t = _str(p.title, TITLE_MAX);
|
|
|
|
|
+ if (!t) return _fail(400, '标题不能为空');
|
|
|
|
|
+ rec.set('title', t);
|
|
|
|
|
+ rec.set('titleLocked', true);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (p.archived != null) rec.set('archived', !!p.archived);
|
|
|
|
|
+ /* v0.1.4:移动工作区单独走 patch,'' = 移回日常对话 */
|
|
|
|
|
+ if (p.workspaceId !== undefined && p.workspaceId !== null) {
|
|
|
|
|
+ rec.set('workspaceId', _str(p.workspaceId, 120));
|
|
|
|
|
+ }
|
|
|
|
|
+ await rec.save(null, { useMasterKey: true });
|
|
|
|
|
+ return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
|
|
|
|
|
+ * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
|
|
|
|
|
+ * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
|
|
|
|
|
+ */
|
|
|
|
|
+async function opRemove(userPtr, p) {
|
|
|
|
|
+ var threadRoot = _str(p.threadRoot, 200);
|
|
|
|
|
+ if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
|
|
|
|
|
+ * 平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
|
|
|
|
|
+ * 匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
|
|
|
|
|
+ * 用 Pointer 显式指定 className + objectId 最稳。
|
|
|
|
|
+ */
|
|
|
|
|
+ var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
|
|
|
|
|
+
|
|
|
|
|
+ /* ① 查全部同线程记录(含已软删) */
|
|
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
|
|
+ q.equalTo('user', ptr);
|
|
|
|
|
+ q.equalTo('threadRoot', threadRoot);
|
|
|
|
|
+ q.limit(100);
|
|
|
|
|
+ var rows = await q.find({ useMasterKey: true });
|
|
|
|
|
+ console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
|
|
|
|
|
+
|
|
|
|
|
+ /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
|
|
|
|
|
+ var tomb = null;
|
|
|
|
|
+ for (var i = 0; i < rows.length; i++) {
|
|
|
|
|
+ if (rows[i].get('isDeleted') === true && !tomb) {
|
|
|
|
|
+ tomb = rows[i];
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ await rows[i].destroy({ useMasterKey: true });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ /* 单条删除失败不影响其余 */
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!tomb) {
|
|
|
|
|
+ tomb = new Parse.Object(CLASS_NAME);
|
|
|
|
|
+ tomb.set('user', ptr);
|
|
|
|
|
+ tomb.set('threadRoot', threadRoot);
|
|
|
|
|
+ tomb.set('roomId', _str(p.roomId, 200) || '');
|
|
|
|
|
+ tomb.set('agentId', _str(p.agentId, 120) || '');
|
|
|
|
|
+ tomb.set('title', '');
|
|
|
|
|
+ tomb.set('messageCount', 0);
|
|
|
|
|
+ tomb.set('isDeleted', true);
|
|
|
|
|
+ await tomb.save(null, { useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function handler(request, response) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
|
|
|
|
|
+ * (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
|
|
|
|
|
+ * 业务参数在全局 params;用户身份两种来源都兼容。
|
|
|
|
|
+ */
|
|
|
|
|
+ params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
|
|
|
|
|
+ var user = await _getUser(_sessionToken());
|
|
|
|
|
+ if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
|
|
|
|
|
+
|
|
|
|
|
+ switch (params.op) {
|
|
|
|
|
+ case 'list':
|
|
|
|
|
+ return await opList(user, params);
|
|
|
|
|
+ case 'upsert':
|
|
|
|
|
+ return await opUpsert(user, params);
|
|
|
|
|
+ case 'patch':
|
|
|
|
|
+ return await opPatch(user, params);
|
|
|
|
|
+ case 'remove':
|
|
|
|
|
+ return await opRemove(user, params);
|
|
|
|
|
+ default:
|
|
|
|
|
+ return _fail(400, '未知 op: ' + params.op);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|