// fmode-file-hub v0.2.0 — 文件台账(按会话 / 按群隔离)(表: AgentDeliverable) // // ## v0.2.0(2026-11-11):群隔离 // // 伪群聊架构下(App 把群消息拆成 N 条私聊发给各成员容器),容器只在自己的 // DM 房间工作,**它不知道文件属于哪个群**。所以「归群」只能由 App 侧决定: // // · 用户上传 → 写入时直接带 `groupId`(App 知道当前是哪个群) // · 容器产出 → 靠「派发锚点」反查(后续版本),或 App 惰性补归并 // // 新增字段:`groupId`(所属群;'' = 不属于任何群)、`groupIdSource`、`prevThreadRoot` // 新增 action:`listByGroup` / `claimToGroup` // // 🔴 `threadRoot` 语义不变(单聊 = 单聊会话 id,群 = 群 id)。 // `claimToGroup` 会把记录**搬**进群(threadRoot 一并改写), // 保证一条文件只出现在一个页签 —— 这就是「不串台」的落地方式。 // // ## 为什么新建而不是改老函数 // // 老函数 lfYlgU7SkK(fmodeagent-deliverables) 的 report 用 UPDATABLE 白名单写字段, // 里面**没有 threadRoot** —— 容器传了也会被静默丢弃,所以成果永远无法归属到会话。 // 用户裁决(2026-11-10):**现有云函数一行不动,新建独立函数走新接口**。 // // ## 职责边界(用户裁决) // // · 消息**不入库**(单聊/群聊都在 Matrix) // · 只有**文件与成果**入库,目的是让右侧栏能按会话直接加载 // · 只存访问链接与文件元信息,**不存文件内容**(内容由 AlP56LCKFm 签名直传 OBS) // · 不做老数据适配:没有 threadRoot 的历史成果不出现,按用户裁决"全面切新逻辑" // // ## 调用 // // POST /api/functions {token, id:, params:{action, ...}} // action: registerFile | listBySession | listByAgent // // ## 表 // // 复用 AgentDeliverable(已有 user / company / ACL),新增字段: // threadRoot / uploadedBy / fileName / fileSize / mimeType / storageKey / roomId // ==================== 基础工具(各云函数本就各自复制一份,沿用惯例) ==================== var CLASS_NAME = 'AgentDeliverable'; var MAX_LIMIT = 200; var DEFAULT_LIMIT = 50; /** 会话标识、URL、文件名等的长度上限 */ var LEN = { threadRoot: 200, roomId: 200, agentId: 120, url: 1000, name: 200, key: 500, mime: 120 }; /** 单个文件大小上限(与 App 侧 upload-limits.ts 的 20MB 保持一致) */ var MAX_FILE_BYTES = 20 * 1024 * 1024; /** * 「派发锚点」的有效窗口(v0.2.0)。 * * App 发群消息时为每个目标容器记一条锚点,容器产出的文件在**锚点之后 * 这个窗口内**才认它属于那个群。超过窗口不认 —— 宁可漏,不归错 * (用户裁决 2026-11-11)。 */ var GROUP_ANCHOR_TTL_MS = 30 * 60 * 1000; /** 锚点记录的识别标记(App 写入时用的 mimeType) */ var ANCHOR_MIME = 'application/x-fmode-anchor'; function _ok(response, data) { var body = { code: 200 }; if (data) for (var k in data) body[k] = data[k]; response.status(200).json(body); } function _fail(response, code, msg) { response.status(200).json({ code: code, msg: msg }); } function _str(v, max) { if (v === undefined || v === null) return ''; var s = String(v).trim(); return max && s.length > max ? s.slice(0, max) : s; } function _num(v) { var n = Number(v); return isFinite(n) && n >= 0 ? n : null; } function _sessionToken() { // 与 fmodeagent-status 同口径:请求头优先(平台实测只保留请求头) if (request.headers && request.headers['x-parse-session-token']) { return request.headers['x-parse-session-token']; } if (params && params.token) return params.token; if (request.body && request.body.token) return request.body.token; return null; } async function _getUser(t) { if (!t) return null; if (request.user && request.user.id) return request.user; try { var q = new Parse.Query(Parse.Session); q.equalTo('sessionToken', t); q.include('user'); var s = await q.first({ useMasterKey: true }); return s ? s.get('user') : null; } catch (e) { return null; } } /** * 反查容器产出的文件属于哪个群(v0.2.0)。 * * ## 为什么需要 * * 伪群聊下容器只在自己的 DM 房间工作,回报文件时**不知道群**。 * App 在派活前为每个目标容器记了一条锚点(`uploadedBy:'agent'` + * `ANCHOR_MIME`),锚点上带着 `groupId` / `agentId` / `roomId`。 * 容器产出到达时,按「同 user + 同 agentId + 同 roomId + 时间在锚点之后」匹配。 * * ## 宁可漏,不归错(用户裁决) * * - 有多条候选锚点 → **不认**(同时被多个群派活的典型场景,认了就会串台) * - 锚点太旧(超出 TTL)→ 不认 * - 匹配不到 → 返回 '',文件照常登记,只是不归群 * * @returns {{ groupId: string }} 命中返回群 id,否则空串 */ async function _resolveGroup(user, agentId, roomId) { if (!agentId) return ''; var q = new Parse.Query(CLASS_NAME); q.equalTo('user', user); q.equalTo('agentId', agentId); q.equalTo('uploadedBy', 'agent'); q.equalTo('mimeType', ANCHOR_MIME); q.greaterThan('deliveredAt', new Date(Date.now() - GROUP_ANCHOR_TTL_MS)); q.descending('deliveredAt'); q.limit(10); var rows; try { rows = await q.find({ useMasterKey: true }); } catch (e) { if (!_isMissingColumn(e)) throw e; await _ensureColumns(); rows = await q.find({ useMasterKey: true }); } if (rows.length === 0) return ''; /* * 🔴 唯一性要求:窗口内必须只有**同一个群**的锚点。 * * 同一容器被群 G 和群 H 先后派活 → 有两条锚点指向不同群 → * 无法判断这个文件是谁的 → 不认。这是「不串台」的底线, * 代价是这种并发场景下文件不归群(仍会出现在单聊里)。 */ var groups = new Set(); for (var i = 0; i < rows.length; i++) { var g = rows[i].get('groupId') || ''; if (g) groups.add(g); } if (groups.size !== 1) return ''; return groups.values().next().value || ''; } /** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */ function _isCloudUrl(u) { return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u); } /** * 校验 `groupId` 确实是**本用户的群会话**(v0.2.0)。 * * 🔴 为什么必须校验:`groupId` 是隔离边界。若不校验,客户端可以传任意字符串, * 把文件"归"到一个不存在的群或别人的群 —— 隔离就成了摆设。 * * 会话目录在 `FmodeChatThread` 表,`kind === 'group'` 才是群。 * 查不到 / 不是群 / 不是本人 → 一律拒绝。 */ async function _isOwnGroup(user, groupId) { if (!groupId) return false; try { var q = new Parse.Query('FmodeChatThread'); q.equalTo('user', user); q.equalTo('objectId', groupId); var row = await q.first({ useMasterKey: true }); if (!row) return false; var kind = String(row.get('kind') || '').toLowerCase(); return kind === 'group'; } catch (e) { /* 表不存在或查询失败 → 保守拒绝,宁可登记成"不归群" */ return false; } } /** * 确保 class 上存在某个列(2026-11-10)。 * * 🔴 Parse 的自动建列**只在写入时发生**:数据库里还没有 `threadRoot` 列时, * 直接 `equalTo('threadRoot', x)` 会让 PostgreSQL 报 42703 errorMissingColumn。 * 历史数据本来就没有这个字段,所以第一次查询必然踩到。 * * 做法:写一个带该字段的探测对象再立刻销毁,把列建出来。 * 只在捕获到缺列错误时执行一次,正常路径零开销。 */ var _ensured = false; /** * 一次性建出所有新列。 * * 历史数据里这些字段一个都不存在,分散建列会让每个字段都触发一次 * 失败查询 + 重试,这里一次建全,后续查询零开销。 */ async function _ensureColumns() { if (_ensured) return; var probe = new Parse.Object(CLASS_NAME); probe.set('threadRoot', ''); probe.set('fileUrl', ''); probe.set('uploadedBy', ''); probe.set('fileName', ''); probe.set('fileSize', 0); probe.set('mimeType', ''); probe.set('storageKey', ''); probe.set('roomId', ''); /* v0.2.0(2026-11-11):群隔离字段 */ probe.set('groupId', ''); probe.set('groupIdSource', ''); probe.set('scope', ''); await probe.save(null, { useMasterKey: true }); // 探测对象必须销毁,否则会在成果列表里留下一条空记录 await probe.destroy({ useMasterKey: true }); _ensured = true; } /** * 判断是否「列不存在」错误。 * * 🔴 只认 PostgreSQL 的错误码,不能按字段名匹配 —— * 查询语句本身就包含字段名,按名匹配会让任何错误都被当成缺列。 */ function _isMissingColumn(e) { try { var s = JSON.stringify(e); return s.indexOf('42703') >= 0 || s.indexOf('errorMissingColumn') >= 0; } catch (e2) { return false; } } // ==================== 行 → 返回对象 ==================== function _toFile(r) { var artifacts = r.get('artifacts') || []; var first = Array.isArray(artifacts) && artifacts.length ? artifacts[0] : null; if (typeof first === 'string') first = { url: first, name: '' }; return { id: r.id, threadRoot: r.get('threadRoot') || '', /* v0.2.0:所属群会话 id;'' = 不属于任何群 */ groupId: r.get('groupId') || '', groupIdSource: r.get('groupIdSource') || '', agentId: r.get('agentId') || '', roomId: r.get('roomId') || '', // 文件信息优先取新字段,回退到 artifacts[0](两者写入时保持一致) name: r.get('fileName') || (first && first.name) || '', size: typeof r.get('fileSize') === 'number' ? r.get('fileSize') : -1, mimeType: r.get('mimeType') || (first && first.mimeType) || '', url: (first && first.url) || '', storageKey: r.get('storageKey') || '', uploadedBy: r.get('uploadedBy') || 'agent', status: r.get('status') || 'delivered', deliveredAt: (r.get('deliveredAt') || r.get('createdAt')) ? new Date(r.get('deliveredAt') || r.get('createdAt')).getTime() : 0, }; } /** * 过滤掉**派发锚点**记录(v0.2.0)。 * * 锚点是 App 在派活前写的内部记录(`mimeType = ANCHOR_MIME`), * 只用来反查文件归属,**不是文件**,不能出现在任何列表里。 */ function _toFiles(rows) { var out = []; for (var i = 0; i < rows.length; i++) { if (rows[i].get('mimeType') === ANCHOR_MIME) continue; out.push(_toFile(rows[i])); } return out; } // ==================== actions ==================== /** * registerFile —— 登记一条文件记录 * * 用户上传(uploadedBy:'user')与容器产出(uploadedBy:'agent')共用。 * 幂等:同一 (user, threadRoot, url) 重复登记返回已有记录,不产生重复。 */ async function opRegisterFile(user, response) { var p = params || {}; var threadRoot = _str(p.threadRoot, LEN.threadRoot); var url = _str(p.url, LEN.url); var name = _str(p.name, LEN.name); if (!threadRoot) return _fail(response, 400, 'threadRoot required'); if (!url) return _fail(response, 400, 'url required'); if (!_isCloudUrl(url)) return _fail(response, 400, 'url must be a platform cloud link'); if (!name) return _fail(response, 400, 'name required'); var size = _num(p.size); if (size !== null && size > MAX_FILE_BYTES) return _fail(response, 400, 'file too large'); var uploadedBy = p.uploadedBy === 'user' ? 'user' : 'agent'; var agentId = _str(p.agentId, LEN.agentId); /* * 群归属(v0.2.0,2026-11-11)。 * * 传了 `groupId` 就必须是本用户的群,否则拒绝 —— 见 `_isOwnGroup`。 * 没传或校验不过 → `groupId` 留空,按原来的「只按会话」处理, * 不影响存量调用方。 */ var groupId = _str(p.groupId, LEN.threadRoot); if (groupId) { var ok = await _isOwnGroup(user, groupId); if (!ok) return _fail(response, 400, 'groupId must be your own group session'); } else if (uploadedBy === 'agent' && _str(p.mimeType, LEN.mime) !== ANCHOR_MIME) { /* * 容器产出没带 groupId → 反查派发锚点(v0.2.0)。 * * 用户上传(uploadedBy='user')不做反查:App 知道群,直接传就行。 * 锚点记录本身(ANCHOR_MIME)也跳过,否则会自己匹配自己。 */ groupId = await _resolveGroup(user, agentId, _str(p.roomId, LEN.roomId)); } /* * 幂等:同一会话内同一 URL 只存一条。 * * 🔴 不能查 `artifacts.url`(数组内字段)—— 实测该查询永远匹配不到, * 会导致重复登记。改查顶层 `fileUrl`:registerFile 写入时同步落一份。 * 该列首次查询时也不存在,需要建列后重试(见 _ensureColumn)。 */ var dupQ = new Parse.Query(CLASS_NAME); dupQ.equalTo('user', user); dupQ.equalTo('threadRoot', threadRoot); dupQ.equalTo('fileUrl', url); var dup; try { dup = await dupQ.first({ useMasterKey: true }); } catch (e) { if (!_isMissingColumn(e)) throw e; await _ensureColumns(); dup = await dupQ.first({ useMasterKey: true }); } if (dup) return _ok(response, { id: dup.id, duplicated: true }); var obj = new Parse.Object(CLASS_NAME); obj.set('user', user); /* * 🔴 反查命中的群文件,`threadRoot` **一并改写为群 id**(v0.2.0)。 * * 这是「不串台」的落地:一条文件只能属于一个页签。 * 若仍保留 DM thread,单聊 `listBySession(dm)` 也会返回它 —— * 同一个文件在单聊和群里各出现一次。 * * 原始的 DM thread 存进 `prevThreadRoot`,出问题可据此还原。 */ var resolvedByAnchor = !p.groupId && !!groupId; obj.set('threadRoot', resolvedByAnchor ? groupId : threadRoot); if (resolvedByAnchor) obj.set('prevThreadRoot', threadRoot); obj.set('groupId', groupId); obj.set( 'groupIdSource', groupId ? (resolvedByAnchor ? 'anchor' : _str(p.groupIdSource, 20) || 'app') : '', ); obj.set('roomId', _str(p.roomId, LEN.roomId)); obj.set('agentId', agentId); obj.set('uploadedBy', uploadedBy); obj.set('fileName', name); obj.set('fileSize', size === null ? -1 : size); obj.set('mimeType', _str(p.mimeType, LEN.mime)); obj.set('storageKey', _str(p.key, LEN.key)); /* 幂等键:顶层单独存一份 URL,见 opRegisterFile 的去重说明 */ obj.set('fileUrl', url); obj.set('title', name); obj.set('summary', p.source ? '容器产出' : '用户上传'); obj.set('status', 'delivered'); obj.set('deliveredAt', new Date()); obj.set('reportedAt', new Date()); obj.set('tags', ['file-hub', uploadedBy]); /* * artifacts 保留数组形态:App 现有的 Deliverable 映射(mapDeliverable) * 按 artifacts[0].url 取链接,写在这里就不用改 App 的解析层。 */ obj.set('artifacts', [ { name: name, url: url, type: _str(p.mimeType, LEN.mime), source: _str(p.source, LEN.url), }, ]); // ACL:只给本人读写(成果属于个人会话,不跨用户可见) var acl = new Parse.ACL(user); acl.setPublicReadAccess(false); acl.setPublicWriteAccess(false); obj.setACL(acl); // 公司指针从 agent 带过来,保持与老成果表一致的多租户口径 if (agentId) { try { var aq = new Parse.Query('FmodeAgent'); aq.equalTo('agentId', agentId); var agent = await aq.first({ useMasterKey: true }); if (agent) { obj.set('agent', agent); obj.set('agentObjectId', agent.id); var company = agent.get('company'); if (company) obj.set('company', company); if (!agentId) obj.set('agentId', agent.get('agentId') || ''); } } catch (e) { /* agent 查不到不影响登记:文件仍归属到用户与会话 */ } } var saved = await obj.save(null, { useMasterKey: true }); return _ok(response, { id: saved.id }); } /** * listBySession —— 按会话拉文件(右侧栏主路径) * * 这是用户要求「每次直接从数据库加载文件」的入口: * 不依赖加载聊天历史,也不依赖 FmodeChatThread.deliverableUrls。 */ async function opListBySession(user, response) { var p = params || {}; var threadRoot = _str(p.threadRoot, LEN.threadRoot); if (!threadRoot) return _fail(response, 400, 'threadRoot required'); var q = new Parse.Query(CLASS_NAME); q.equalTo('user', user); q.equalTo('threadRoot', threadRoot); if (p.before) { var before = new Date(Number(p.before)); if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before); } q.descending('deliveredAt'); q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT)); var rows; try { rows = await q.find({ useMasterKey: true }); } catch (e) { // 列还不存在 → 建列后重试一次(历史数据没有 threadRoot,首次查询必然走到这里) if (!_isMissingColumn(e)) throw e; await _ensureColumns(); rows = await q.find({ useMasterKey: true }); } var list = _toFiles(rows); return _ok(response, { threadRoot: threadRoot, files: list, count: list.length, }); } /** listByAgent —— 按容器拉(成果页用,不按会话过滤) */ async function opListByAgent(user, response) { var p = params || {}; var q = new Parse.Query(CLASS_NAME); q.equalTo('user', user); var agentId = _str(p.agentId, LEN.agentId); if (agentId) q.equalTo('agentId', agentId); if (p.before) { var before = new Date(Number(p.before)); if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before); } q.descending('deliveredAt'); q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT)); var rows = await q.find({ useMasterKey: true }); var list = _toFiles(rows); return _ok(response, { files: list, count: list.length }); } /** * listByGroup —— 按**群**拉文件(v0.2.0,2026-11-11) * * ## 为什么不能只用 threadRoot * * 群里的文件有两种来源: * · **用户上传**:写入时 `threadRoot = groupId`(历史数据也只有 threadRoot) * · **容器产出**:容器只知道 DM thread,由服务端反查/补归并后 * `threadRoot` 改写为群 id、同时补 `groupId` * * 查询用 `groupId == g OR threadRoot == g`,两条来源都能覆盖, * 也让**没有 groupId 的存量群内上传记录**照样查得到(无需数据迁移)。 */ async function opListByGroup(user, response) { var p = params || {}; var groupId = _str(p.groupId, LEN.threadRoot); if (!groupId) return _fail(response, 400, 'groupId required'); var byGroup = new Parse.Query(CLASS_NAME); byGroup.equalTo('user', user); byGroup.equalTo('groupId', groupId); var byThread = new Parse.Query(CLASS_NAME); byThread.equalTo('user', user); byThread.equalTo('threadRoot', groupId); var q = Parse.Query.or(byGroup, byThread); if (p.before) { var before = new Date(Number(p.before)); if (!isNaN(before.getTime())) q.lessThan('deliveredAt', before); } q.descending('deliveredAt'); q.limit(Math.min(Number(p.limit) || DEFAULT_LIMIT, MAX_LIMIT)); var rows; try { rows = await q.find({ useMasterKey: true }); } catch (e) { if (!_isMissingColumn(e)) throw e; await _ensureColumns(); rows = await q.find({ useMasterKey: true }); } var list = _toFiles(rows); return _ok(response, { groupId: groupId, files: list, count: list.length }); } /** * claimToGroup —— 惰性补归并(v0.2.0,2026-11-11) * * 有些容器产出登记时没能确定归属群(`groupId=''`)。群页面打开时, * App 把「有把握属于本群」的记录 id 传进来,这里把它们**搬**到群上: * 补 `groupId`、把 `threadRoot` 改写为群 id。 * * 🔴 改写 threadRoot 是「互斥」的落地:一条文件只能出现在一个页签 —— * 搬进群之后,单聊 `listBySession(单聊sid)` 自然查不到它了。 * * 安全:只允许操作**本人**的、`uploadedBy='agent'`(容器产出)、 * 且当前 `groupId` 为空的记录。用户上传的文件不允许被搬。 */ async function opClaimToGroup(user, response) { var p = params || {}; var groupId = _str(p.groupId, LEN.threadRoot); var ids = Array.isArray(p.ids) ? p.ids.filter((x) => typeof x === 'string' && x) : []; if (!groupId) return _fail(response, 400, 'groupId required'); if (ids.length === 0) return _fail(response, 400, 'ids required'); if (!(await _isOwnGroup(user, groupId))) { return _fail(response, 400, 'groupId must be your own group session'); } var claimed = 0; var skipped = 0; for (var i = 0; i < ids.length && i < 100; i++) { var obj; try { var q = new Parse.Query(CLASS_NAME); q.equalTo('user', user); obj = await q.get(ids[i], { useMasterKey: true }); } catch (e) { skipped++; continue; } if (!obj) { skipped++; continue; } if (obj.get('uploadedBy') !== 'agent' || obj.get('groupId')) { skipped++; continue; } try { /* 留一份原 threadRoot,便于出问题时还原 */ if (!obj.get('prevThreadRoot')) obj.set('prevThreadRoot', obj.get('threadRoot') || ''); obj.set('groupId', groupId); obj.set('groupIdSource', 'claim'); obj.set('threadRoot', groupId); await obj.save(null, { useMasterKey: true }); claimed++; } catch (e) { skipped++; } } return _ok(response, { groupId: groupId, claimed: claimed, skipped: skipped }); } // ==================== 入口 ==================== function handler(request, response) { (async function () { var p = params || {}; var act = p.action || 'listBySession'; var token = _sessionToken(); var user = await _getUser(token); if (!user) return _fail(response, 401, 'invalid session'); try { if (act === 'registerFile') return await opRegisterFile(user, response); if (act === 'listBySession') return await opListBySession(user, response); if (act === 'listByAgent') return await opListByAgent(user, response); /* v0.2.0(2026-11-11):群隔离 */ if (act === 'listByGroup') return await opListByGroup(user, response); if (act === 'claimToGroup') return await opClaimToGroup(user, response); return _fail(response, 400, 'unknown action: ' + act); } catch (e) { /* * Parse 的异常对象字符串化是 `[object Object]`,直接丢出去排查不了。 * 新函数上线初期必须能看到真实原因(字段不存在 / 索引缺失等)。 */ var msg = 'unknown'; try { msg = (e && (e.message || e.error || e.code)) || String(e); if (typeof msg === 'object') msg = JSON.stringify(msg); } catch (e2) { msg = String(e); } return _fail(response, 500, String(msg)); } })(); }