|
@@ -0,0 +1,516 @@
|
|
|
|
|
+// fmodeagent-deliverables v0.2.0 — 智能体任务成果上报与聚合 (表: AgentDeliverable)
|
|
|
|
|
+// v0.1.0 变更 (2026-10-21):
|
|
|
|
|
+// A. report: artifacts 支持字符串路径(容器内路径),平台侧保持原样存储,由 App 触发搬运
|
|
|
|
|
+// B. 新增 action 'enqueueArtifact' / 'pendingArtifacts' / 'ackArtifact' —— 成果文件搬运队列
|
|
|
|
|
+// 表 FmodeArtifactJob:App 签好 OBS 预签名地址 → 容器领取 → 读文件 PUT → 回执 → 回写 artifacts[].url
|
|
|
|
|
+// 调用: POST /api/functions {token, id:<Function objectId>, params:{action:'report'|'list'|'byProject', ...}}
|
|
|
|
|
+// v0.0.2 变更:
|
|
|
|
|
+// 1. report 移除 FMODE_AGENT_SUPERADMIN 检查 → 仅校验「该 agent 的 owner」;agent 无 owner 时首次上报自动绑定
|
|
|
|
|
+// 2. agent 不存在时自动注册(自注册,agentId 有效即可)
|
|
|
|
|
+// 3. agentId / agentObjectId 任一可传,自动补全,并写入 agent 指针
|
|
|
|
|
+// 4. list/byProject 可见性保持 v0.0.1 逻辑(超管全量 | 有公司看本公司 | 无公司看自己)
|
|
|
|
|
+var _sessionToken = function () {
|
|
|
|
|
+ if (params && params.token) return params.token;
|
|
|
|
|
+ if (request.body && request.body.token) return request.body.token;
|
|
|
|
|
+ return null;
|
|
|
|
|
+};
|
|
|
|
|
+var _getUser = async function (t) {
|
|
|
|
|
+ if (!t) return null;
|
|
|
|
|
+ if (request.user) 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; }
|
|
|
|
|
+};
|
|
|
|
|
+var _isSuper = async function (user) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var q = new Parse.Query(Parse.Role); q.equalTo('name', 'FMODE_AGENT_SUPERADMIN'); q.equalTo('users', user);
|
|
|
|
|
+ return (await q.first({ useMasterKey: true })) != null;
|
|
|
|
|
+ } catch (e) { return false; }
|
|
|
|
|
+};
|
|
|
|
|
+var _idOf = function (v) {
|
|
|
|
|
+ if (!v) return null;
|
|
|
|
|
+ if (typeof v === 'string') return v;
|
|
|
|
|
+ return v.id || null;
|
|
|
|
|
+};
|
|
|
|
|
+// OBS 常量(与 AlP56LCKFm 同桶同凭据;本文件仅 masterKey 可读,与 provision 同级)
|
|
|
|
|
+var OBS_BUCKET = 'storage-s3-nkkj';
|
|
|
|
|
+var OBS_HOST = 'storage-s3-nkkj.obs.cn-north-4.myhuaweicloud.com';
|
|
|
|
|
+var OBS_PUBLIC = 'https://s3.fmode.cn';
|
|
|
|
|
+var OBS_AK = '2BFF7JWXAIJ0UGNJ0OSB';
|
|
|
|
|
+var OBS_SK = 'NaPCiJCGmD3nklCzX65s8mSK1Py13ueyhgepa0s1';
|
|
|
|
|
+var OBS_UNSET = '__OBS_UNSET__';
|
|
|
|
|
+
|
|
|
|
|
+// ==================== OBS 签名与对象键(服务端唯一实现) ====================
|
|
|
|
|
+// SigV2 canonical = "PUT\n\n<Content-Type>\n<Expires>\n[x-obs-acl:public-read\n]/<bucket>/<key>"
|
|
|
|
|
+
|
|
|
|
|
+function _utf8(str) {
|
|
|
|
|
+ var bytes = [];
|
|
|
|
|
+ for (var i = 0; i < str.length; i++) {
|
|
|
|
|
+ var c = str.charCodeAt(i);
|
|
|
|
|
+ if (c < 128) bytes.push(c);
|
|
|
|
|
+ else if (c < 2048) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63));
|
|
|
|
|
+ else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
|
|
|
|
|
+ }
|
|
|
|
|
+ return bytes;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function _sha1Bytes(_input) {
|
|
|
|
|
+ var bytes = typeof _input === 'string' ? _utf8(_input) : _input;
|
|
|
|
|
+ function rr(x, n) { return (x << n) | (x >>> (32 - n)); }
|
|
|
|
|
+ var bitLen = bytes.length * 8;
|
|
|
|
|
+ bytes.push(0x80);
|
|
|
|
|
+ while (bytes.length % 64 !== 56) bytes.push(0);
|
|
|
|
|
+ for (var j = 7; j >= 0; j--) bytes.push(Math.floor(bitLen / Math.pow(2, j * 8)) & 0xff);
|
|
|
|
|
+ var H = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
|
|
|
|
+ for (var b = 0; b < bytes.length; b += 64) {
|
|
|
|
|
+ var w = new Array(80);
|
|
|
|
|
+ for (var t = 0; t < 16; t++) {
|
|
|
|
|
+ w[t] = (bytes[b + t*4] << 24) | (bytes[b + t*4+1] << 16) | (bytes[b + t*4+2] << 8) | bytes[b + t*4+3];
|
|
|
|
|
+ }
|
|
|
|
|
+ for (var t2 = 16; t2 < 80; t2++) w[t2] = rr(w[t2-3] ^ w[t2-8] ^ w[t2-14] ^ w[t2-16], 1);
|
|
|
|
|
+ var a = H[0], bb = H[1], c = H[2], d = H[3], e = H[4];
|
|
|
|
|
+ for (var t3 = 0; t3 < 80; t3++) {
|
|
|
|
|
+ var f = t3 < 20 ? ((bb & c) | (~bb & d)) + 0x5A827999
|
|
|
|
|
+ : t3 < 40 ? (bb ^ c ^ d) + 0x6ED9EBA1
|
|
|
|
|
+ : t3 < 60 ? ((bb & c) | (bb & d) | (c & d)) + 0x8F1BBCDC
|
|
|
|
|
+ : (bb ^ c ^ d) + 0xCA62C1D6;
|
|
|
|
|
+ var temp = (rr(a, 5) + f + e + w[t3]) | 0;
|
|
|
|
|
+ e = d; d = c; c = rr(bb, 30); bb = a; a = temp;
|
|
|
|
|
+ }
|
|
|
|
|
+ H[0] = (H[0] + a) | 0; H[1] = (H[1] + bb) | 0; H[2] = (H[2] + c) | 0;
|
|
|
|
|
+ H[3] = (H[3] + d) | 0; H[4] = (H[4] + e) | 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ var out = [];
|
|
|
|
|
+ for (var k = 0; k < 5; k++) {
|
|
|
|
|
+ out.push((H[k] >>> 24) & 0xff, (H[k] >>> 16) & 0xff, (H[k] >>> 8) & 0xff, H[k] & 0xff);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function _hmacSha1(keyBytes, msgBytes) {
|
|
|
|
|
+ var msg = typeof msgBytes === 'string' ? _utf8(msgBytes) : msgBytes;
|
|
|
|
|
+ if (keyBytes.length > 64) keyBytes = _sha1Bytes(keyBytes);
|
|
|
|
|
+ var ipad = [], opad = [];
|
|
|
|
|
+ for (var i = 0; i < 64; i++) {
|
|
|
|
|
+ var kb = i < keyBytes.length ? keyBytes[i] : 0;
|
|
|
|
|
+ ipad.push(kb ^ 0x36); opad.push(kb ^ 0x5c);
|
|
|
|
|
+ }
|
|
|
|
|
+ return _sha1Bytes(opad.concat(_sha1Bytes(ipad.concat(msg))));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function _b64(bytes) {
|
|
|
|
|
+ var CH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
|
|
|
+ var out = '';
|
|
|
|
|
+ for (var i = 0; i < bytes.length; i += 3) {
|
|
|
|
|
+ var b0 = bytes[i], b1 = bytes[i+1], b2 = bytes[i+2];
|
|
|
|
|
+ out += CH[b0 >> 2] + CH[((b0 & 3) << 4) | ((b1 === undefined ? 0 : b1) >> 4)];
|
|
|
|
|
+ out += b1 === undefined ? '=' : CH[((b1 & 15) << 2) | ((b2 === undefined ? 0 : b2) >> 6)];
|
|
|
|
|
+ out += b2 === undefined ? '=' : CH[b2 & 63];
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 对象键规则(与前端 obs-sign.ts、手册 §4.2 必须一致):
|
|
|
|
|
+ * deliverables/<agentId>/<YYYYMM>/<deliverableId>/<序号>-<文件名>
|
|
|
|
|
+ */
|
|
|
|
|
+function _artifactKey(agentId, deliverableId, filename, index, stamp) {
|
|
|
|
|
+ var a = String(agentId || 'unknown').replace(/[^\w.\-]/g, '_');
|
|
|
|
|
+ var id = String(deliverableId || 'unknown').replace(/[^\w.\-]/g, '_');
|
|
|
|
|
+ var name = String(filename || 'file').replace(/[/\\\x00-\x1f]/g, '_').replace(/\.{2,}/g, '_').slice(-120);
|
|
|
|
|
+ var seq = Math.max(0, Math.floor(Number(index || 0)));
|
|
|
|
|
+ // 本次搬运的短标识:同一成果重复搬运 → 新对象,不覆盖旧文件
|
|
|
|
|
+ // (踩坑:曾因对象键确定性,一次测试覆盖了真实交付物)
|
|
|
|
|
+ var st = String(stamp || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 10) || 'x';
|
|
|
|
|
+ var d = new Date();
|
|
|
|
|
+ var ym = d.getUTCFullYear() + ('0' + (d.getUTCMonth() + 1)).slice(-2);
|
|
|
|
|
+ return 'deliverables/' + a + '/' + ym + '/' + id + '/' + seq + '-' + st + '-' + name;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 服务端签 PUT;返回容器需要的完整信息 */
|
|
|
|
|
+function _presignPut(key, contentType, ttlSec) {
|
|
|
|
|
+ var expires = Math.floor(Date.now() / 1000) + ttlSec;
|
|
|
|
|
+ var stringToSign = 'PUT\n\n' + contentType + '\n' + expires + '\nx-obs-acl:public-read\n/' + OBS_BUCKET + '/' + key;
|
|
|
|
|
+ var sig = _b64(_hmacSha1(_utf8(OBS_SK), stringToSign));
|
|
|
|
|
+ var qs = '?Expires=' + expires + '&AccessKeyId=' + encodeURIComponent(OBS_AK) +
|
|
|
|
|
+ '&Signature=' + encodeURIComponent(sig);
|
|
|
|
|
+ return {
|
|
|
|
|
+ uploadUrl: 'https://' + OBS_HOST + '/' + key + qs,
|
|
|
|
|
+ publicUrl: OBS_PUBLIC + '/' + key,
|
|
|
|
|
+ headers: { 'Content-Type': contentType, 'x-obs-acl': 'public-read' },
|
|
|
|
|
+ expiresAt: expires
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 自动补登记:把该 agent 名下「有 source、无 url、且没有 pending 任务」的
|
|
|
|
|
+ * 成果附件补进搬运队列。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 幂等:同 deliverableId + source 已有 pending 任务时跳过。
|
|
|
|
|
+ * 安全:只处理**属于该 agent** 的成果;路径来自成果记录本身,容器不参与指定。
|
|
|
|
|
+ */
|
|
|
|
|
+async function _autoEnqueue(user, agentId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var dq = new Parse.Query('AgentDeliverable');
|
|
|
|
|
+ dq.equalTo('agentId', agentId);
|
|
|
|
|
+ dq.descending('createdAt');
|
|
|
|
|
+ dq.limit(20);
|
|
|
|
|
+ var rows = await dq.find({ useMasterKey: true });
|
|
|
|
|
+ var now = Date.now();
|
|
|
|
|
+ for (var i = 0; i < rows.length; i++) {
|
|
|
|
|
+ var d = rows[i];
|
|
|
|
|
+ var arts = d.get('artifacts') || [];
|
|
|
|
|
+ for (var j = 0; j < arts.length; j++) {
|
|
|
|
|
+ var a = arts[j];
|
|
|
|
|
+ var src = (a && typeof a === 'object') ? a.source : (typeof a === 'string' ? a : null);
|
|
|
|
|
+ if (!src) continue;
|
|
|
|
|
+ if (a && typeof a === 'object' && a.url) continue; // 已上云
|
|
|
|
|
+ if (typeof a === 'string' && /^https?:\/\//i.test(a)) continue;
|
|
|
|
|
+ // 已有 pending 任务 → 跳过
|
|
|
|
|
+ var exists = new Parse.Query('FmodeArtifactJob');
|
|
|
|
|
+ exists.equalTo('deliverableId', d.id);
|
|
|
|
|
+ exists.equalTo('source', String(src));
|
|
|
|
|
+ exists.equalTo('status', 'pending');
|
|
|
|
|
+ exists.greaterThan('expiresAt', new Date());
|
|
|
|
|
+ var hit = await exists.first({ useMasterKey: true });
|
|
|
|
|
+ if (hit) continue;
|
|
|
|
|
+ // 建任务(服务端签名)
|
|
|
|
|
+ var fnName = (a && typeof a === 'object' && a.name) ? a.name : String(src).split('/').pop();
|
|
|
|
|
+ var mime = _guessMime(fnName, (a && typeof a === 'object') ? a.mimeType : '');
|
|
|
|
|
+ var objKey = _artifactKey(agentId, d.id, fnName, j,
|
|
|
|
|
+ Date.now().toString(36) + Math.random().toString(36).slice(2, 6));
|
|
|
|
|
+ var signed = _presignPut(objKey, mime, 1800);
|
|
|
|
|
+ var job = new Parse.Object('FmodeArtifactJob');
|
|
|
|
|
+ job.set('deliverableId', d.id);
|
|
|
|
|
+ job.set('agentId', agentId);
|
|
|
|
|
+ job.set('source', String(src));
|
|
|
|
|
+ job.set('filename', String(fnName || 'file'));
|
|
|
|
|
+ job.set('mimeType', mime);
|
|
|
|
|
+ job.set('uploadUrl', signed.uploadUrl);
|
|
|
|
|
+ job.set('publicUrl', signed.publicUrl);
|
|
|
|
|
+ job.set('headers', signed.headers);
|
|
|
|
|
+ job.set('expiresAt', new Date(signed.expiresAt * 1000));
|
|
|
|
|
+ job.set('status', 'pending');
|
|
|
|
|
+ job.set('user', user);
|
|
|
|
|
+ if (d.get('company')) job.set('company', d.get('company'));
|
|
|
|
|
+ var jacl = new Parse.ACL();
|
|
|
|
|
+ jacl.setReadAccess(user.id, true);
|
|
|
|
|
+ jacl.setWriteAccess(user.id, true);
|
|
|
|
|
+ job.setACL(jacl);
|
|
|
|
|
+ await job.save(null, { useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 自动补登记失败不应影响正常领取;静默(可通过队列状态发现)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 按扩展名推断 Content-Type(v0.2.4)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 🔴 踩坑:容器上报时大多不带 mimeType,全部落到 application/octet-stream
|
|
|
|
|
+ * → 浏览器点开是**下载**而不是预览,html 页面也打不开。
|
|
|
|
|
+ * 这里按扩展名兜底,保证 html/图片/文本类能在浏览器里直接看。
|
|
|
|
|
+ */
|
|
|
|
|
+function _guessMime(name, given) {
|
|
|
|
|
+ var g = String(given || '').trim();
|
|
|
|
|
+ if (g && g !== 'application/octet-stream') return g;
|
|
|
|
|
+ var ext = String(name || '').split('.').pop().toLowerCase();
|
|
|
|
|
+ var map = {
|
|
|
|
|
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
|
|
|
|
+ md: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8',
|
|
|
|
|
+ json: 'application/json; charset=utf-8', csv: 'text/csv; charset=utf-8',
|
|
|
|
|
+ css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8',
|
|
|
|
|
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
|
|
|
+ gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon',
|
|
|
|
|
+ pdf: 'application/pdf', zip: 'application/zip',
|
|
|
|
|
+ };
|
|
|
|
|
+ return map[ext] || 'application/octet-stream';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+var UPDATABLE = ['agentId','title','summary','project','projectName','artifacts','tags','deliveredAt','status','extra'];
|
|
|
|
|
+/** 两个 user 是否同公司(2026-11-14):供 pendingArtifacts 权限对齐使用,任一缺失 → false */
|
|
|
|
|
+async function _sameCompanyOf(ownerId, user) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!ownerId || !user) return false;
|
|
|
|
|
+ var owner = await new Parse.Query(Parse.User).get(ownerId, { useMasterKey: true }).catch(function () { return null; });
|
|
|
|
|
+ if (!owner) return false;
|
|
|
|
|
+ var oc = _idOf(owner.get('company'));
|
|
|
|
|
+ var uc = _idOf(user.get('company'));
|
|
|
|
|
+ return !!(oc && uc && oc === uc);
|
|
|
|
|
+ } catch (e) { return false; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handler(request, response) {
|
|
|
|
|
+ (async function () {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var p = params || {};
|
|
|
|
|
+ var user = await _getUser(_sessionToken());
|
|
|
|
|
+ if (!user) return _respond(response, 401, { code: 401, msg: 'SessionToken 无效' });
|
|
|
|
|
+ var act = p.action || 'list';
|
|
|
|
|
+ var superAd = await _isSuper(user);
|
|
|
|
|
+ var uCompany = user.get('company') || null;
|
|
|
|
|
+
|
|
|
|
|
+ if (act === 'report') {
|
|
|
|
|
+ if (!p.title) return _respond(response, 400, { code: 400, msg: 'title required' });
|
|
|
|
|
+ if (!p.agentId && !p.agentObjectId) return _respond(response, 400, { code: 400, msg: 'agentId/agentObjectId required' });
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 解析 agent:先按 objectId,再按 agentId;都没有则自注册 ----
|
|
|
|
|
+ var agent = null;
|
|
|
|
|
+ if (p.agentObjectId) {
|
|
|
|
|
+ var q0 = new Parse.Query('FmodeAgent'); q0.equalTo('objectId', p.agentObjectId);
|
|
|
|
|
+ agent = await q0.first({ useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!agent && p.agentId) {
|
|
|
|
|
+ var q1 = new Parse.Query('FmodeAgent'); q1.equalTo('agentId', p.agentId);
|
|
|
|
|
+ agent = await q1.first({ useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!agent) {
|
|
|
|
|
+ if (!p.agentId) return _respond(response, 400, { code: 400, msg: '新 agent 注册需要 agentId' });
|
|
|
|
|
+ agent = new Parse.Object('FmodeAgent');
|
|
|
|
|
+ agent.set('agentId', p.agentId);
|
|
|
|
|
+ agent.set('user', user);
|
|
|
|
|
+ agent.set('status', 'online');
|
|
|
|
|
+ agent.set('heartbeatAt', new Date());
|
|
|
|
|
+ agent = await agent.save(null, { useMasterKey: true });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 鉴权:仅该 agent 的 owner(v0.0.2 移除超管代报)----
|
|
|
|
|
+ var agentUserId = _idOf(agent.get('user'));
|
|
|
|
|
+ if (agentUserId && agentUserId !== user.id) {
|
|
|
|
|
+ return _respond(response, 403, { code: 403, msg: '仅该容器的 owner 可上报' });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!agentUserId) {
|
|
|
|
|
+ agent.set('user', user);
|
|
|
|
|
+ await agent.save(null, { useMasterKey: true });
|
|
|
|
|
+ agentUserId = user.id;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 写交付物 ----
|
|
|
|
|
+ var obj = new Parse.Object('AgentDeliverable');
|
|
|
|
|
+ for (var i = 0; i < UPDATABLE.length; i++) {
|
|
|
|
|
+ var k = UPDATABLE[i];
|
|
|
|
|
+ if (p[k] === undefined) continue;
|
|
|
|
|
+ if (k === 'deliveredAt') obj.set('deliveredAt', new Date(p[k])); else obj.set(k, p[k]);
|
|
|
|
|
+ }
|
|
|
|
|
+ obj.set('agentId', agent.get('agentId') || p.agentId);
|
|
|
|
|
+ obj.set('agent', agent);
|
|
|
|
|
+ obj.set('agentObjectId', agent.id);
|
|
|
|
|
+ obj.set('user', user);
|
|
|
|
|
+ if (agent.get('company')) obj.set('company', agent.get('company'));
|
|
|
|
|
+ obj.set('reportedAt', new Date());
|
|
|
|
|
+ if (p.deliveredAt === undefined) obj.set('deliveredAt', new Date());
|
|
|
|
|
+ if (!p.status) obj.set('status', 'delivered');
|
|
|
|
|
+ var acl = new Parse.ACL();
|
|
|
|
|
+ acl.setReadAccess(user.id, true);
|
|
|
|
|
+ acl.setWriteAccess(user.id, true);
|
|
|
|
|
+ acl.setPublicReadAccess(false);
|
|
|
|
|
+ acl.setPublicWriteAccess(false);
|
|
|
|
|
+ obj.setACL(acl);
|
|
|
|
|
+ var saved = await obj.save(null, { useMasterKey: true });
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 更新 agent 心跳 ----
|
|
|
|
|
+ agent.set('heartbeatAt', new Date());
|
|
|
|
|
+ agent.set('status', 'online');
|
|
|
|
|
+ await agent.save(null, { useMasterKey: true });
|
|
|
|
|
+
|
|
|
|
|
+ return _respond(response, 200, {
|
|
|
|
|
+ code: 200, id: saved.id, agentId: obj.get('agentId'),
|
|
|
|
|
+ agentObjectId: agent.id, title: p.title
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 成果文件搬运队列 (v0.1.0) ----
|
|
|
|
|
+ if (act === 'enqueueArtifact') {
|
|
|
|
|
+ if (!p.deliverableId || !p.source || !p.filename) {
|
|
|
|
|
+ return _respond(response, 400, { code: 400, msg: 'deliverableId/source/filename required' });
|
|
|
|
|
+ }
|
|
|
|
|
+ // 只能为自己可见的成果建任务(owner 或同公司)
|
|
|
|
|
+ var dq = new Parse.Query('AgentDeliverable');
|
|
|
|
|
+ var deliv = await dq.get(p.deliverableId, { useMasterKey: true }).catch(function () { return null; });
|
|
|
|
|
+ if (!deliv) return _respond(response, 404, { code: 404, msg: 'deliverable not found' });
|
|
|
|
|
+ var ownerId = _idOf(deliv.get('user'));
|
|
|
|
|
+ var dComp = _idOf(deliv.get('company'));
|
|
|
|
|
+ var okAccess = superAd || ownerId === user.id || (!!uCompany && dComp && _idOf(uCompany) === dComp);
|
|
|
|
|
+ if (!okAccess) return _respond(response, 403, { code: 403, msg: '无权为该成果创建搬运任务' });
|
|
|
|
|
+ // 已上云盘的附件不再入队(幂等)
|
|
|
|
|
+ var arts0 = deliv.get('artifacts') || [];
|
|
|
|
|
+ for (var ai = 0; ai < arts0.length; ai++) {
|
|
|
|
|
+ var a0 = arts0[ai];
|
|
|
|
|
+ if (a0 && typeof a0 === 'object' && a0.source === p.source && a0.url) {
|
|
|
|
|
+ return _respond(response, 200, { code: 200, id: null, skipped: true });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ // 同一附件已有未完成任务 → 复用,避免重复搬运(排队期间可能被反复调用)
|
|
|
|
|
+ var dupQ = new Parse.Query('FmodeArtifactJob');
|
|
|
|
|
+ dupQ.equalTo('deliverableId', p.deliverableId);
|
|
|
|
|
+ dupQ.equalTo('source', String(p.source));
|
|
|
|
|
+ dupQ.equalTo('status', 'pending');
|
|
|
|
|
+ dupQ.greaterThan('expiresAt', new Date());
|
|
|
|
|
+ var dup = await dupQ.first({ useMasterKey: true });
|
|
|
|
|
+ if (dup) return _respond(response, 200, { code: 200, id: dup.id, reused: true });
|
|
|
|
|
+ // ---- 服务端签名(v0.2.0)----
|
|
|
|
|
+ // 密钥只存在于本云函数内部,**不再下发给 App**。
|
|
|
|
|
+ // 调用方只报「哪个成果、哪个附件、第几个」,地址由这里签。
|
|
|
|
|
+ if (OBS_AK === OBS_UNSET || OBS_SK === OBS_UNSET) {
|
|
|
|
|
+ return _respond(response, 503, { code: 503, msg: '云盘签名服务未配置' });
|
|
|
|
|
+ }
|
|
|
|
|
+ var fnName = String(p.filename || 'file');
|
|
|
|
|
+ var mime = _guessMime(fnName, p.mimeType);
|
|
|
|
|
+ var seq = Math.max(0, Math.floor(Number(p.index || 0)));
|
|
|
|
|
+ var stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
|
|
|
+ var objKey = _artifactKey(String(p.agentId || deliv.get('agentId') || ''), String(p.deliverableId), fnName, seq, stamp);
|
|
|
|
|
+ var signed = _presignPut(objKey, mime, 1800);
|
|
|
|
|
+ var job = new Parse.Object('FmodeArtifactJob');
|
|
|
|
|
+ job.set('deliverableId', p.deliverableId);
|
|
|
|
|
+ job.set('agentId', String(p.agentId || deliv.get('agentId') || ''));
|
|
|
|
|
+ job.set('source', String(p.source));
|
|
|
|
|
+ job.set('filename', fnName);
|
|
|
|
|
+ job.set('mimeType', mime);
|
|
|
|
|
+ job.set('uploadUrl', signed.uploadUrl);
|
|
|
|
|
+ job.set('publicUrl', signed.publicUrl);
|
|
|
|
|
+ job.set('headers', signed.headers);
|
|
|
|
|
+ job.set('expiresAt', new Date(signed.expiresAt * 1000));
|
|
|
|
|
+ job.set('status', 'pending');
|
|
|
|
|
+ job.set('user', user);
|
|
|
|
|
+ if (deliv.get('company')) job.set('company', deliv.get('company'));
|
|
|
|
|
+ var jacl = new Parse.ACL();
|
|
|
|
|
+ jacl.setReadAccess(user.id, true);
|
|
|
|
|
+ jacl.setWriteAccess(user.id, true);
|
|
|
|
|
+ jacl.setPublicReadAccess(false);
|
|
|
|
|
+ jacl.setPublicWriteAccess(false);
|
|
|
|
|
+ job.setACL(jacl);
|
|
|
|
|
+ var js = await job.save(null, { useMasterKey: true });
|
|
|
|
|
+ return _respond(response, 200, { code: 200, id: js.id });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (act === 'pendingArtifacts') {
|
|
|
|
|
+ if (!p.agentId) return _respond(response, 400, { code: 400, msg: 'agentId required' });
|
|
|
|
|
+ // 容器用自己的 sessionToken 调;也要校验它确实是这个 agent 的 owner
|
|
|
|
|
+ var aq = new Parse.Query('FmodeAgent'); aq.equalTo('agentId', p.agentId);
|
|
|
|
|
+ var ag = await aq.first({ useMasterKey: true });
|
|
|
|
|
+ if (!ag) return _respond(response, 404, { code: 404, msg: 'agent not found' });
|
|
|
|
|
+ var agOwner = _idOf(ag.get('user'));
|
|
|
|
|
+ // 🔴 2026-11-14 对齐 enqueueArtifact(用户裁决):原来仅 owner,多人共用容器时同公司成员
|
|
|
|
|
+ // 能建任务(enqueueArtifact 放行)却领不到地址,形成「任务建了没人执行」的死结。
|
|
|
|
|
+ var agIsOwner = agOwner && agOwner === user.id;
|
|
|
|
|
+ var agSameCompany = agIsOwner ? true : await _sameCompanyOf(agOwner, user);
|
|
|
|
|
+ if (!superAd && !agIsOwner && !agSameCompany) {
|
|
|
|
|
+ return _respond(response, 403, { code: 403, msg: '该 agent 不属于你所在的公司, 无权领取任务' });
|
|
|
|
|
+ }
|
|
|
|
|
+ // ---- 自动补登记(v0.2.4)----
|
|
|
|
|
+ // 容器的 cron 调用本 action 时,顺便把「本容器尚未上云」的成果补入队列。
|
|
|
|
|
+ // 🔴 动机:入队原先只由 App 触发,App 没打开成果就永远停在容器里
|
|
|
|
|
+ // (实测:容器做完登录页、上报成功,但没人入队)。
|
|
|
|
|
+ // 现在容器自己就有能力把产出送上云,不需要用户先打开 App。
|
|
|
|
|
+ await _autoEnqueue(user, String(p.agentId));
|
|
|
|
|
+
|
|
|
|
|
+ var jq = new Parse.Query('FmodeArtifactJob');
|
|
|
|
|
+ jq.equalTo('agentId', String(p.agentId));
|
|
|
|
|
+ jq.equalTo('status', 'pending');
|
|
|
|
|
+ jq.greaterThan('expiresAt', new Date()); // 过期的不发,等 App 重签
|
|
|
|
|
+ jq.ascending('createdAt');
|
|
|
|
|
+ jq.limit(200);
|
|
|
|
|
+ var jobs = await jq.find({ useMasterKey: true });
|
|
|
|
|
+ return _respond(response, 200, {
|
|
|
|
|
+ code: 200,
|
|
|
|
|
+ jobs: jobs.map(function (j) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: j.id,
|
|
|
|
|
+ deliverableId: j.get('deliverableId'),
|
|
|
|
|
+ agentId: String(j.get('agentId')),
|
|
|
|
|
+ source: j.get('source'),
|
|
|
|
|
+ filename: j.get('filename'),
|
|
|
|
|
+ mimeType: j.get('mimeType'),
|
|
|
|
|
+ uploadUrl: j.get('uploadUrl'),
|
|
|
|
|
+ publicUrl: j.get('publicUrl'),
|
|
|
|
|
+ headers: j.get('headers') || {},
|
|
|
|
|
+ expiresAt: Math.floor((j.get('expiresAt') || new Date()).getTime() / 1000),
|
|
|
|
|
+ };
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (act === 'ackArtifact') {
|
|
|
|
|
+ if (!p.objectId) return _respond(response, 400, { code: 400, msg: 'objectId required' });
|
|
|
|
|
+ var jq2 = new Parse.Query('FmodeArtifactJob');
|
|
|
|
|
+ var job2 = await jq2.get(p.objectId, { useMasterKey: true }).catch(function () { return null; });
|
|
|
|
|
+ if (!job2) return _respond(response, 404, { code: 404, msg: 'job not found' });
|
|
|
|
|
+ var jobOwner = _idOf(job2.get('user'));
|
|
|
|
|
+ if (jobOwner && jobOwner !== user.id) return _respond(response, 403, { code: 403, msg: '仅任务创建者可回执' });
|
|
|
|
|
+ if (p.ok) {
|
|
|
|
|
+ job2.set('status', 'done');
|
|
|
|
|
+ var url2 = String(p.publicUrl || job2.get('publicUrl') || '');
|
|
|
|
|
+ // 回写成果的 artifacts[i].url —— 只改匹配 source 的那一项
|
|
|
|
|
+ var dq2 = new Parse.Query('AgentDeliverable');
|
|
|
|
|
+ var deliv2 = await dq2.get(job2.get('deliverableId'), { useMasterKey: true }).catch(function () { return null; });
|
|
|
|
|
+ if (deliv2) {
|
|
|
|
|
+ var src2 = job2.get('source');
|
|
|
|
|
+ var arts2 = deliv2.get('artifacts') || [];
|
|
|
|
|
+ var changed = false;
|
|
|
|
|
+ for (var bi = 0; bi < arts2.length; bi++) {
|
|
|
|
|
+ var b0 = arts2[bi];
|
|
|
|
|
+ if (typeof b0 === 'string') {
|
|
|
|
|
+ if (b0 === src2) {
|
|
|
|
|
+ arts2[bi] = { name: job2.get('filename'), url: url2, type: '', source: src2 };
|
|
|
|
|
+ changed = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (b0 && b0.source === src2) {
|
|
|
|
|
+ b0.url = url2; changed = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (changed) { deliv2.set('artifacts', arts2); await deliv2.save(null, { useMasterKey: true }); }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ job2.set('status', 'failed');
|
|
|
|
|
+ job2.set('error', String(p.error || '上传失败'));
|
|
|
|
|
+ }
|
|
|
|
|
+ await job2.save(null, { useMasterKey: true });
|
|
|
|
|
+ return _respond(response, 200, { code: 200 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 未知 action 必须报错(v0.2.0 修复)----
|
|
|
|
|
+ // 🔴 实测:删掉 issueSigningCredential 后它仍返回 200,调用方会误以为成功。
|
|
|
|
|
+ // 只接受已知动作,其余一律 400。
|
|
|
|
|
+ var KNOWN = ['report', 'list', 'byProject', 'enqueueArtifact', 'pendingArtifacts', 'ackArtifact'];
|
|
|
|
|
+ if (KNOWN.indexOf(act) < 0) {
|
|
|
|
|
+ return _respond(response, 400, { code: 400, msg: 'unknown action: ' + act });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- list / byProject(沿用 v0.0.1)----
|
|
|
|
|
+ var q = new Parse.Query('AgentDeliverable');
|
|
|
|
|
+ if (p.agentId) q.equalTo('agentId', p.agentId);
|
|
|
|
|
+ if (p.project) q.equalTo('project', p.project);
|
|
|
|
|
+ if (!superAd) { if (uCompany) { q.equalTo('company', uCompany); } else { q.equalTo('user', user); } }
|
|
|
|
|
+ if (p.before) q.lessThan('deliveredAt', new Date(p.before));
|
|
|
|
|
+ q.descending('deliveredAt'); q.limit(Math.min(p.limit || 50, 200));
|
|
|
|
|
+ var rows = await q.find({ useMasterKey: true });
|
|
|
|
|
+ var items = rows.map(function (r) {
|
|
|
|
|
+ return { id: r.id, agentId: r.get('agentId'), title: r.get('title'), summary: r.get('summary'),
|
|
|
|
|
+ project: r.get('project'), projectName: r.get('projectName'), artifacts: r.get('artifacts') || [],
|
|
|
|
|
+ tags: r.get('tags') || [], deliveredAt: r.get('deliveredAt'), status: r.get('status') || 'delivered', extra: r.get('extra') };
|
|
|
|
|
+ });
|
|
|
|
|
+ if (act === 'byProject') {
|
|
|
|
|
+ var groups = {};
|
|
|
|
|
+ for (var j = 0; j < items.length; j++) {
|
|
|
|
|
+ var it = items[j]; var key = it.project || '未归类';
|
|
|
|
|
+ if (!groups[key]) groups[key] = { project: key, projectName: it.projectName || key, count: 0, items: [] };
|
|
|
|
|
+ groups[key].count++; groups[key].items.push(it);
|
|
|
|
|
+ }
|
|
|
|
|
+ return _respond(response, 200, { code: 200, groups: Object.keys(groups).map(function (k2) { return groups[k2]; }) });
|
|
|
|
|
+ }
|
|
|
|
|
+ return _respond(response, 200, { code: 200, count: items.length, deliverables: items });
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ return _respond(response, 500, { code: 500, msg: String(e && e.message || e) });
|
|
|
|
|
+ }
|
|
|
|
|
+ })();
|
|
|
|
|
+}
|
|
|
|
|
+function _respond(response, status, body) {
|
|
|
|
|
+ if (response && typeof response.status === 'function') { response.status(status); response.json(body); }
|
|
|
|
|
+ else if (response && typeof response.success === 'function') { status === 200 ? response.success(body) : response.error(body); }
|
|
|
|
|
+ return body;
|
|
|
|
|
+}
|