| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- // fmode-file-hub v0.1.0 — 文件台账(按会话隔离)(表: AgentDeliverable)
- //
- // ## 为什么新建而不是改老函数
- //
- // 老函数 lfYlgU7SkK(fmodeagent-deliverables) 的 report 用 UPDATABLE 白名单写字段,
- // 里面**没有 threadRoot** —— 容器传了也会被静默丢弃,所以成果永远无法归属到会话。
- // 用户裁决(2026-11-10):**现有云函数一行不动,新建独立函数走新接口**。
- //
- // ## 职责边界(用户裁决)
- //
- // · 消息**不入库**(单聊/群聊都在 Matrix)
- // · 只有**文件与成果**入库,目的是让右侧栏能按会话直接加载
- // · 只存访问链接与文件元信息,**不存文件内容**(内容由 AlP56LCKFm 签名直传 OBS)
- // · 不做老数据适配:没有 threadRoot 的历史成果不出现,按用户裁决"全面切新逻辑"
- //
- // ## 调用
- //
- // POST /api/functions {token, id:<Function objectId>, 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;
- 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;
- }
- }
- /** 云盘链接只接受本平台域名,防止登记任意外链冒充成果 */
- function _isCloudUrl(u) {
- return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
- }
- /**
- * 确保 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', '');
- 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') || '',
- 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,
- };
- }
- // ==================== 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);
- /*
- * 幂等:同一会话内同一 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);
- obj.set('threadRoot', threadRoot);
- 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 });
- }
- return _ok(response, {
- threadRoot: threadRoot,
- files: rows.map(_toFile),
- count: rows.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 });
- return _ok(response, { files: rows.map(_toFile), count: rows.length });
- }
- // ==================== 入口 ====================
- 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);
- 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));
- }
- })();
- }
|