|
|
@@ -0,0 +1,578 @@
|
|
|
+// FmodeAgent 状态上报云函数 (v2 路由云函数化 · 免重启)
|
|
|
+// 调用: POST /api/functions {token, path:"/fmodeagent/status", params:{...}}
|
|
|
+// params.op = 'update' | 'query'(默认) | 'meta'; 亦可按入参自动判别
|
|
|
+// update: {agentId, field, value, companyId?} 白名单字段级更新+lastField记录
|
|
|
+// query: {scope: mine|company|all, agentId?, fields?, companyId?}
|
|
|
+// 响应格式与旧 REST /api/fmodeagent/status 完全一致(看板无缝切换)
|
|
|
+//
|
|
|
+// ===== v0.0.3 (round7 2026-09-06) 变更说明 =====
|
|
|
+// 1. UPDATABLE 白名单扩列: 新增 botName/botAvatar/ownerName/ownerMobile/balance/channels/extra
|
|
|
+// (reporter v3 采集: 机器人身份/主人身份/new-api余额/通道开关/其余原始数据JSON)
|
|
|
+// 2. params.companyId 显式规则(放宽写明, 版本注释): 三者之一可写 ——
|
|
|
+// ① 系统超管(FMODE_AGENT_SUPERADMIN) ② 用户无公司(userCompany 为空) ③ 用户属于该公司(companyId===用户公司)
|
|
|
+// 其余契约(四级ACL/三视角/meta/owner归属校验)不变
|
|
|
+//
|
|
|
+// ===== v0.0.4 (round8 2026-09-06) 变更说明 (只增不改, v0.0.3 全部 ACL/白名单逻辑保留) =====
|
|
|
+// 1. 主人信息以记录的 user 指针为唯一真相: 查询侧 q.include('user'),
|
|
|
+// ownerName = user.name || user.nickname || user.realName
|
|
|
+// ownerMobile = user.mobile || user.mobilePhoneNumber
|
|
|
+// ownerObjectId 一并返回(每条 agent JSON 附 userObjectId)
|
|
|
+// 说明: reporter 侧 ownerName/ownerMobile 仍按上报原样保留(容器 token 失效时为空,
|
|
|
+// 云函数以 user 指针实时兜底, 看板取值不依赖上报态)
|
|
|
+// 2. balance 改为主人 token 实时计算: user 对象上直接取 fmodeApiToken||newapiToken,
|
|
|
+// 调 GET https://api.fmode.cn/v1/dashboard/billing/subscription + /usage (Bearer 该 key),
|
|
|
+// 返回 {hardLimitUsd, usedUsd, remainUsd, remainRmb=remainUsd*7.2}
|
|
|
+// 安全约定: key 绝不入库(不 set 到任何 Parse 对象) / 绝不进日志 / 仅函数内存瞬时使用
|
|
|
+// 失败或无 token → balance=null(看板显「未配置」)
|
|
|
+// 3. botName = 记录.botName || channels.wecomBotId 前 8 位兜底 || agentId
|
|
|
+// botAvatar = 记录.botAvatar || DEFAULT_BOT_AVATAR
|
|
|
+// 4. 查询响应附 balanceSource('owner_token'|'none') 便于运维定位
|
|
|
+//
|
|
|
+// ===== v0.0.5 (round9/platform-v5 2026-09-07) 变更说明 (只增不改, v0.0.4 全部 ACL/白名单逻辑保留) =====
|
|
|
+// 1. 查询条目透传 reporter v6 上报的通道配置: channels.matrixConfig={homeserver,userId,serverName}
|
|
|
+// 与 channels.wecomConfig={botId,corpName} 原样附到 agent JSON (看板/App 悬浮展示)
|
|
|
+// 2. 余额回退: 云函数实时余额失败(无 fmodeApiToken)时, 回退 reporter v6① sessionToken 通道
|
|
|
+// 上报的 {remainCny,usedCny,totalCalls,source:'session'}; balanceSource 透传
|
|
|
+// 'session'|'apikey'|'owner_token'|'none' —— 无 key 容器也能显示余额
|
|
|
+// 3. UPDATABLE 白名单已含 channels/balance, 无需扩列; 生产 parse 禁重启, 本函数经
|
|
|
+// Parse REST PUT /parse/classes/Function/xlosi3m46q 热更
|
|
|
+// ===== v0.0.6 (TASK-matrix-v3 P2a 2026-09-08) 变更说明 (只增不改) =====
|
|
|
+// 1. UPDATABLE/FIELDS 白名单扩列: sessionStatus (reporter v8 探活上报:
|
|
|
+// valid|expired|unknown — FMODE_SESSION_TOKEN 调 /parse/users/me, 看板/App 健康灯)
|
|
|
+// ===== v0.0.7 (TASK-matrix-v4 P1/P4e 2026-09-08) 变更说明 (只增不改) =====
|
|
|
+// 1. FIELDS 白名单扩列: gender (male/female/unknown, reporter v9 上报, App TTS 声线用)
|
|
|
+// 2. botAvatar 兜底升级: 记录值 > BotRegistry(by channels.wecomBotId) > DEFAULT;
|
|
|
+// reporter v9 起 cron 注入的默认飞马 logo 不再直传覆盖, 注册表头像可透出
|
|
|
+// ===== v0.0.8 (TASK-matrix-v42 P1 2026-09-09) 变更说明 (只增不改) =====
|
|
|
+// 1. 余额口径 ¥化 (任务书 P1: balanceCny 权威字段):
|
|
|
+// _fetchBalance 主通道 sk- 原返回 {hardLimitUsd,usedUsd,remainUsd,remainRmb=USD*7.2}
|
|
|
+// (new-api subscription hard_limit 是 100000000 占位值 → remainUsd 无意义)。
|
|
|
+// 改调 newapi GET https://api.fmode.cn/api/usage/token/ (Bearer sk-) →
|
|
|
+// data.total_available = quota → remainCny = quota / 500000 * rate
|
|
|
+// (QUOTA_PER_UNIT=500000 与 api.fmode.cn/api/status quota_per_unit 一致;
|
|
|
+// rate 取 options.USDExchangeRate, 默认 6.8 — future-server enterprise-billing-service
|
|
|
+// quotaToCny 同公式; 平台网关 /api/status price=7.3 做上限参考不采用)
|
|
|
+// 兼容: total_available 为负(unlimited+已超)也如实上报; usedCny=total_used/500000*rate
|
|
|
+// 2. 余额兜底链 (查询侧): owner_token(sk-) 实时 → reporter session 通道 remainCny →
|
|
|
+// 旧 remainRmb → null(未配置)。App 端 balanceText 只认 remainCny → ¥xx.xx
|
|
|
+// (v3 旧 apikey 通道的 remainRmb 由 _balanceFromReport 升级换算 remainCny)
|
|
|
+var CLASS_NAME = 'FmodeAgent';
|
|
|
+var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
|
|
|
+var FIELDS = ['status','currentTask','currentAction','progress','name','containerRef','host','heartbeatAt',
|
|
|
+ 'botName','botAvatar','gender','ownerName','ownerMobile','balance','channels','extra','sessionStatus',
|
|
|
+ // v0.1.0: 用量两字段 (只增不删, 规则 17)
|
|
|
+ 'usage','usageSessions'];
|
|
|
+// ===== v0.1.0 (fmode-app 一期) 新增: token 用量 =====
|
|
|
+// 白名单与容器侧 USAGE_FIELDS 成对维护 (校验: node scripts/check-reporter-fields.mjs)
|
|
|
+var USAGE_FIELDS = ['sessionId','title','model','messageCount','toolCallCount',
|
|
|
+ 'inputTokens','outputTokens','cacheReadTokens','reasoningTokens',
|
|
|
+ 'estimatedCostUsd','costStatus','startedAt'];
|
|
|
+var DEFAULT_BOT_AVATAR = 'https://app.fmode.cn/logo/feima.ico';
|
|
|
+var BOT_REGISTRY = 'BotRegistry';
|
|
|
+var BILLING_BASE = 'https://api.fmode.cn/v1/dashboard/billing';
|
|
|
+var NEWAPI_TOKEN_USAGE = 'https://api.fmode.cn/api/usage/token/'; // v0.0.8: quota 权威来源
|
|
|
+var QUOTA_PER_UNIT = 500000; // v0.0.8: api.fmode.cn/api/status quota_per_unit 同值
|
|
|
+var USD2RMB = 7.2;
|
|
|
+var USD2RMB_DEFAULT = 6.8; // v0.0.8: options.USDExchangeRate 缺省 (future-server 同默认)
|
|
|
+
|
|
|
+function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
|
|
|
+function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
|
|
|
+
|
|
|
+function _sessionToken() {
|
|
|
+ /*
|
|
|
+ * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
|
|
|
+ * X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
|
|
|
+ * request.body.token 都拿不到 → 恒返回 null → 所有请求 401
|
|
|
+ * (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
|
|
|
+ * 这里加上请求头来源(只增不改,旧调用方式保持有效)。
|
|
|
+ */
|
|
|
+ var h = request && request.headers ? request.headers : {};
|
|
|
+ if (h['x-parse-session-token']) return h['x-parse-session-token'];
|
|
|
+ if (params && params.sessionToken) return params.sessionToken;
|
|
|
+ if (request && 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 && !(params && params.sessionToken)) return request.user;
|
|
|
+ try {
|
|
|
+ var q = new Parse.Query(Parse.Session);
|
|
|
+ q.equalTo('sessionToken', t);
|
|
|
+ var s = await q.first({ useMasterKey: true });
|
|
|
+ return s ? s.get('user') : null;
|
|
|
+ } catch (e) { return null; }
|
|
|
+}
|
|
|
+async function _roleUserIds(name) {
|
|
|
+ try {
|
|
|
+ var q = new Parse.Query(Parse.Role);
|
|
|
+ q.equalTo('name', name);
|
|
|
+ var role = await q.first({ useMasterKey: true });
|
|
|
+ if (!role) return [];
|
|
|
+ var us = await role.getUsers().query().find({ useMasterKey: true });
|
|
|
+ return us.map(function (u) { return u.id; });
|
|
|
+ } catch (e) { return []; }
|
|
|
+}
|
|
|
+async function _userCompany(u) {
|
|
|
+ try {
|
|
|
+ var ptr = u.get('company');
|
|
|
+ if (!ptr) return null;
|
|
|
+ var name = '';
|
|
|
+ try { var c = await ptr.fetch({ useMasterKey: true }); name = (c && c.get('name')) || ''; } catch (_) {}
|
|
|
+ return { id: ptr.id, name: name, pointer: ptr };
|
|
|
+ } catch (e) { return null; }
|
|
|
+}
|
|
|
+function _buildACL(userId, companyId) {
|
|
|
+ var acl = new Parse.ACL();
|
|
|
+ acl.setPublicReadAccess(false); acl.setPublicWriteAccess(false);
|
|
|
+ acl.setReadAccess(userId, true); acl.setWriteAccess(userId, true);
|
|
|
+ acl.setRoleReadAccess(SUPER_ROLE, true); acl.setRoleWriteAccess(SUPER_ROLE, true);
|
|
|
+ if (companyId) {
|
|
|
+ acl.setRoleReadAccess(companyId + '_Admin', true);
|
|
|
+ acl.setRoleWriteAccess(companyId + '_Admin', true);
|
|
|
+ acl.setRoleReadAccess(companyId + '_Public', true);
|
|
|
+ }
|
|
|
+ return acl;
|
|
|
+}
|
|
|
+async function _findOrRegister(agentId, user, company, acl) {
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
+ q.equalTo('agentId', agentId);
|
|
|
+ var obj = await q.first({ useMasterKey: true });
|
|
|
+ if (!obj) {
|
|
|
+ var A = Parse.Object.extend(CLASS_NAME);
|
|
|
+ obj = new A();
|
|
|
+ obj.set('agentId', agentId);
|
|
|
+ obj.set('user', user);
|
|
|
+ if (company && company.pointer) obj.set('company', company.pointer);
|
|
|
+ obj.setACL(acl);
|
|
|
+ await obj.save(null, { useMasterKey: true });
|
|
|
+ }
|
|
|
+ return obj;
|
|
|
+}
|
|
|
+function _agentJSON(a, names) {
|
|
|
+ var o = a.toJSON();
|
|
|
+ o.heartbeatAgeSec = a.updatedAt ? Math.floor((Date.now() - a.updatedAt.getTime()) / 1000) : null;
|
|
|
+ var c = a.get('company');
|
|
|
+ if (c) o.company = { objectId: c.id, name: (names && names[c.id]) || '' };
|
|
|
+ return o;
|
|
|
+}
|
|
|
+async function _companyNames(list) {
|
|
|
+ var ids = [], seen = {};
|
|
|
+ list.forEach(function (a) { var c = a.get('company'); if (c && !seen[c.id]) { seen[c.id] = 1; ids.push(c.id); } });
|
|
|
+ var map = {};
|
|
|
+ for (var i = 0; i < ids.length; i++) {
|
|
|
+ try { var c = await new Parse.Query('Company').get(ids[i], { useMasterKey: true }); map[ids[i]] = (c && c.get('name')) || ''; }
|
|
|
+ catch (_) { map[ids[i]] = ''; }
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+}
|
|
|
+
|
|
|
+// ===== v0.0.4: 主人信息以 user 指针为唯一真相 =====
|
|
|
+function _ownerFromUser(u) {
|
|
|
+ if (!u) return { ownerName: '', ownerMobile: '', userObjectId: null };
|
|
|
+ return {
|
|
|
+ ownerName: u.get('name') || u.get('nickname') || u.get('realName') || '',
|
|
|
+ ownerMobile: u.get('mobile') || u.get('mobilePhoneNumber') || '',
|
|
|
+ userObjectId: u.id
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+// ===== v0.0.4: 主人 token 余额 (key 只进内存, 不入库不入日志) =====
|
|
|
+// 执行器用 new Function(context={Parse,request,response,params,user}) 包装, 作用域内无 require;
|
|
|
+// Deno 运行时有全局 fetch, 故用 fetch + AbortController
|
|
|
+// ===== v0.0.8 余额主通道: sk- → newapi /api/usage/token/ → quota → ¥ (任务书 P1 balanceCny 口径)
|
|
|
+// 旧 subscription+usage 通道保留为 fallback (hard_limit_usd=100000000 是占位 → remainUsd 无意义)
|
|
|
+function _httpsGetJson(url, headers, timeoutMs) {
|
|
|
+ return new Promise(function (resolve) {
|
|
|
+ try {
|
|
|
+ if (typeof fetch !== 'function') { resolve(null); return; }
|
|
|
+ var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
|
+ var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, timeoutMs || 8000) : null;
|
|
|
+ fetch(url, { headers: headers || {}, signal: ctrl ? ctrl.signal : undefined })
|
|
|
+ .then(function (res) { return res.text(); })
|
|
|
+ .then(function (txt) {
|
|
|
+ if (t) clearTimeout(t);
|
|
|
+ try { resolve(JSON.parse(txt)); } catch (e) { resolve(null); }
|
|
|
+ })
|
|
|
+ .catch(function () { if (t) clearTimeout(t); resolve(null); });
|
|
|
+ } catch (e) { resolve(null); }
|
|
|
+ });
|
|
|
+}
|
|
|
+async function _fetchBalance(user) {
|
|
|
+ try {
|
|
|
+ if (!user) return { balance: null, source: 'none' };
|
|
|
+ var key = user.get('fmodeApiToken') || user.get('newapiToken') || '';
|
|
|
+ if (!key) return { balance: null, source: 'none' };
|
|
|
+ // v0.0.4a: 进程级 TTL 缓存(存 globalThis, 跨请求复用; 只存结果不存 key),
|
|
|
+ // 防止看板每次刷新对 billing 网关造成 N 记录×2 次调用风暴(会触发 429 限流)
|
|
|
+ var cache = null;
|
|
|
+ try { cache = globalThis.__FM8_BAL_CACHE = globalThis.__FM8_BAL_CACHE || {}; } catch (e) { cache = {}; }
|
|
|
+ var ck = _keyHash(key);
|
|
|
+ var hit = cache[ck];
|
|
|
+ if (hit && (Date.now() - hit.ts) < 300000) {
|
|
|
+ return { balance: hit.balance, source: 'owner_token' };
|
|
|
+ }
|
|
|
+ var hdr = { Authorization: 'Bearer ' + key };
|
|
|
+ // v0.0.8 主通道: newapi token usage → total_available(quota) → ¥ (balanceCny 口径)
|
|
|
+ var tu = await _httpsGetJson(NEWAPI_TOKEN_USAGE, hdr);
|
|
|
+ if (tu && tu.data && tu.data.total_available !== undefined && tu.data.total_available !== null) {
|
|
|
+ // 汇率: 平台 options.USDExchangeRate (6.8 缺省; 与 future-server quotaToCny 同公式同默认)
|
|
|
+ var balanceCny = {
|
|
|
+ remainCny: Math.round(tu.data.total_available / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
|
|
|
+ usedCny: Math.round((parseFloat(tu.data.total_used) || 0) / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
|
|
|
+ totalCalls: parseInt(tu.data.request_count, 10) || null,
|
|
|
+ quota: Math.round(parseFloat(tu.data.total_available) || 0),
|
|
|
+ source: 'owner_token'
|
|
|
+ };
|
|
|
+ try { cache[ck] = { ts: Date.now(), balance: balanceCny }; } catch (e) {}
|
|
|
+ return { balance: balanceCny, source: 'owner_token' };
|
|
|
+ }
|
|
|
+ // v0.0.8 fallback: 旧 subscription+usage (remainRmb 形态, App/查询侧再降级)
|
|
|
+ // 注: new-api hard_limit_usd=100000000 是 unlimited 占位 → 换算出 ¥7.2e8 假大数,
|
|
|
+ // 视为"无有效记账"返回 null (P1: 无记账→未配置)
|
|
|
+ var sub = await _httpsGetJson(BILLING_BASE + '/subscription', hdr);
|
|
|
+ if (!sub || sub.hard_limit_usd === undefined) return { balance: null, source: 'none' };
|
|
|
+ var hard = parseFloat(sub.hard_limit_usd) || 0;
|
|
|
+ if (hard >= 1e8) return { balance: null, source: 'none' }; // unlimited 占位 → 未配置
|
|
|
+ var usage = await _httpsGetJson(BILLING_BASE + '/usage', hdr);
|
|
|
+ var used = 0;
|
|
|
+ if (usage && usage.total_usage !== undefined && usage.total_usage !== null) {
|
|
|
+ used = (parseFloat(usage.total_usage) || 0) / 100.0; // 美分 → USD
|
|
|
+ }
|
|
|
+ var remain = Math.max(hard - used, 0);
|
|
|
+ var balance = {
|
|
|
+ hardLimitUsd: Math.round(hard * 100) / 100,
|
|
|
+ usedUsd: Math.round(used * 100) / 100,
|
|
|
+ remainUsd: Math.round(remain * 100) / 100,
|
|
|
+ remainRmb: Math.round(remain * USD2RMB * 100) / 100
|
|
|
+ };
|
|
|
+ try { cache[ck] = { ts: Date.now(), balance: balance }; } catch (e) {}
|
|
|
+ return { balance: balance, source: 'owner_token' };
|
|
|
+ } catch (e) { return { balance: null, source: 'none' }; }
|
|
|
+}
|
|
|
+
|
|
|
+// 简单字符串哈希(仅作缓存键, 不存原 token)
|
|
|
+function _keyHash(s) {
|
|
|
+ var h = 5381;
|
|
|
+ for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
|
|
|
+ return 'k' + h.toString(36);
|
|
|
+}
|
|
|
+
|
|
|
+// ===== v0.0.4: 查询条目增强 (user 指针主人 + 余额 + botName/botAvatar 兜底) =====
|
|
|
+async function _agentJSONv4(a, names) {
|
|
|
+ var o = _agentJSON(a, names);
|
|
|
+ var u = a.get('user'); // 查询时 include('user')
|
|
|
+ var own = _ownerFromUser(u);
|
|
|
+ o.ownerName = own.ownerName || o.ownerName || '';
|
|
|
+ o.ownerMobile = own.ownerMobile || o.ownerMobile || '';
|
|
|
+ o.userObjectId = own.userObjectId;
|
|
|
+ var bal = await _fetchBalance(u);
|
|
|
+ o.balance = bal.balance; // 主人 token 实时计算, 覆盖 reporter 旧值(契约: 云函数为准)
|
|
|
+ o.balanceSource = bal.source;
|
|
|
+ var ch = {};
|
|
|
+ try { ch = typeof a.get('channels') === 'string' ? JSON.parse(a.get('channels') || '{}') : (a.get('channels') || {}); } catch (e) { ch = {}; }
|
|
|
+ o.botName = a.get('botName') || (ch && ch.wecomBotId ? String(ch.wecomBotId).slice(0, 8) : '') || o.agentId || '';
|
|
|
+ // v0.0.7: 头像链 记录值(排除默认飞马) > BotRegistry(by wecomBotId) > DEFAULT
|
|
|
+ var recAvatar = a.get('botAvatar');
|
|
|
+ if (recAvatar === DEFAULT_BOT_AVATAR) recAvatar = null;
|
|
|
+ var gender = a.get('gender');
|
|
|
+ if (recAvatar || !gender) {
|
|
|
+ try {
|
|
|
+ var BotReg = Parse.Object.extend(BOT_REGISTRY);
|
|
|
+ var q = new Parse.Query(BotReg);
|
|
|
+ if (ch && ch.wecomBotId) q.equalTo('botid', String(ch.wecomBotId));
|
|
|
+ else { q.equalTo('agentId', o.agentId || ''); }
|
|
|
+ q.limit(1);
|
|
|
+ var reg = await q.first({ useMasterKey: true });
|
|
|
+ if (reg) {
|
|
|
+ if (!recAvatar && reg.get('avatar')) recAvatar = reg.get('avatar');
|
|
|
+ if (!gender && reg.get('gender')) gender = reg.get('gender');
|
|
|
+ }
|
|
|
+ } catch (e) { /* BotRegistry 不可用不阻塞查询 */ }
|
|
|
+ }
|
|
|
+ o.botAvatar = recAvatar || DEFAULT_BOT_AVATAR;
|
|
|
+ o.gender = gender || 'unknown';
|
|
|
+ return o;
|
|
|
+}
|
|
|
+
|
|
|
+// ===== v0.0.5 (round9/platform-v5 2026-09-07) 通道配置透传 =====
|
|
|
+// reporter v6③ 采集: channels.matrix 真活检测(GET {MATRIX_HOMESERVER}/_matrix/client/versions)
|
|
|
+// + channels.matrixConfig={homeserver,userId,serverName} + channels.wecomConfig={botId,corpName}
|
|
|
+// + balance={remainCny,usedCny,totalCalls,source:'session'}(reporter v6① sessionToken 通道)
|
|
|
+// 查询条目透传规则(只增不改, v0.0.4 全部逻辑保留):
|
|
|
+// 1) _balanceFromReport: 云函数实时余额失败(balance=null)时回退 reporter 上报的
|
|
|
+// {remainCny,usedCny,totalCalls,source} —— 无 fmodeApiToken 主人的容器也能显示余额;
|
|
|
+// balanceSource 透传 'session'|'apikey'|'owner_token'|'none' 便于运维定位
|
|
|
+// 2) channels.matrixConfig / channels.wecomConfig 原样透传到 agent JSON
|
|
|
+// (看板/App 悬浮显示具体配置参数)
|
|
|
+function _parseCh(a) {
|
|
|
+ try {
|
|
|
+ var raw = a.get('channels');
|
|
|
+ return typeof raw === 'string' ? JSON.parse(raw || '{}') : (raw || {});
|
|
|
+ } catch (e) { return {}; }
|
|
|
+}
|
|
|
+function _balanceFromReport(a, ch) {
|
|
|
+ try {
|
|
|
+ var raw = a.get('balance');
|
|
|
+ var b = typeof raw === 'string' ? JSON.parse(raw || 'null') : raw;
|
|
|
+ if (!b || typeof b !== 'object') return null;
|
|
|
+ if (b.remainCny === undefined && b.remainCny === null && b.remainRmb === undefined) return null;
|
|
|
+ // v0.0.8 (P1): 只认 remainCny 权威; 旧 remainRmb(USD*7.2) 形态如实换算透传 (App 只显 ¥)
|
|
|
+ // remainRmb ≥ 7e8 = hard_limit 占位假大数 → 视为无记账 (null)
|
|
|
+ var remainCny = b.remainCny !== undefined && b.remainCny !== null ? b.remainCny
|
|
|
+ : (b.remainRmb !== undefined && b.remainRmb !== null ? (Math.abs(b.remainRmb) >= 7e8 ? null : b.remainRmb) : null);
|
|
|
+ if (remainCny === null && b.remainCny === undefined) return null;
|
|
|
+ return {
|
|
|
+ remainCny: remainCny,
|
|
|
+ usedCny: b.usedCny !== undefined ? b.usedCny : (b.usedUsd !== undefined ? b.usedUsd : null),
|
|
|
+ totalCalls: b.totalCalls !== undefined ? b.totalCalls : null,
|
|
|
+ source: b.source || 'none'
|
|
|
+ };
|
|
|
+ } catch (e) { return null; }
|
|
|
+}
|
|
|
+async function _agentJSONv5(a, names) {
|
|
|
+ var o = await _agentJSONv4(a, names);
|
|
|
+ var ch = _parseCh(a);
|
|
|
+ if (ch && typeof ch === 'object') {
|
|
|
+ if (ch.matrixConfig && typeof ch.matrixConfig === 'object') o.matrixConfig = ch.matrixConfig;
|
|
|
+ if (ch.wecomConfig && typeof ch.wecomConfig === 'object') o.wecomConfig = ch.wecomConfig;
|
|
|
+ }
|
|
|
+ if (!o.balance && o.balanceSource !== 'owner_token') {
|
|
|
+ var rep = _balanceFromReport(a, ch);
|
|
|
+ if (rep) { o.balance = rep; o.balanceSource = rep.source; }
|
|
|
+ }
|
|
|
+ return o;
|
|
|
+}
|
|
|
+
|
|
|
+// ===== v0.1.0 (fmode-app 一期): 用量落库 =====
|
|
|
+// 存储决策: 落在 FmodeAgent 表的 usage 字段 (JSON 字符串), **不另建表**。
|
|
|
+// 理由: 用量是「分身快照属性」(每个分身一份当前用量摘要), 符合 rules/05 规则 17
|
|
|
+// 「字段只增不删」; 前端实况面板可直接消费, 无需联表。
|
|
|
+// 字段级更新该字段需在 FIELDS 白名单内, 故 usage 也加入 FIELDS (见上)。
|
|
|
+function _pickUsage(list) {
|
|
|
+ var out = [];
|
|
|
+ if (!list || !list.length) return out;
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
+ var src = list[i] || {};
|
|
|
+ var o = {};
|
|
|
+ for (var j = 0; j < USAGE_FIELDS.length; j++) {
|
|
|
+ var k = USAGE_FIELDS[j];
|
|
|
+ if (src[k] !== undefined) o[k] = src[k];
|
|
|
+ }
|
|
|
+ if (o.sessionId) out.push(o);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+// 汇总: 供前端/看板直接读「总数」, 免去前端遍历
|
|
|
+function _sumUsage(list) {
|
|
|
+ var s = { sessionCount: list.length, inputTokens: 0, outputTokens: 0,
|
|
|
+ cacheReadTokens: 0, reasoningTokens: 0, totalTokens: 0,
|
|
|
+ estimatedCostUsd: 0, toolCallCount: 0, messageCount: 0 };
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
+ var u = list[i];
|
|
|
+ s.inputTokens += (parseFloat(u.inputTokens) || 0);
|
|
|
+ s.outputTokens += (parseFloat(u.outputTokens) || 0);
|
|
|
+ s.cacheReadTokens += (parseFloat(u.cacheReadTokens) || 0);
|
|
|
+ s.reasoningTokens += (parseFloat(u.reasoningTokens) || 0);
|
|
|
+ s.estimatedCostUsd += (parseFloat(u.estimatedCostUsd) || 0);
|
|
|
+ s.toolCallCount += (parseFloat(u.toolCallCount) || 0);
|
|
|
+ s.messageCount += (parseFloat(u.messageCount) || 0);
|
|
|
+ }
|
|
|
+ s.totalTokens = s.inputTokens + s.outputTokens;
|
|
|
+ s.estimatedCostUsd = Math.round(s.estimatedCostUsd * 10000) / 10000;
|
|
|
+ s.updatedAt = new Date().toISOString();
|
|
|
+ return s;
|
|
|
+}
|
|
|
+
|
|
|
+async function handler(request, response) {
|
|
|
+ try {
|
|
|
+ var op = (params && (params.op || params.action)) || null;
|
|
|
+ var user = await _getUser(_sessionToken());
|
|
|
+ if (!user) return _fail(401, 'SessionToken 无效或已过期');
|
|
|
+ var superIds = await _roleUserIds(SUPER_ROLE);
|
|
|
+ var superAdmin = superIds.indexOf(user.id) >= 0;
|
|
|
+ var userCompany = await _userCompany(user);
|
|
|
+
|
|
|
+ // ---- meta 自检(兼容独立 meta 函数, 本函数亦可 params.op='meta') ----
|
|
|
+ if (op === 'meta') {
|
|
|
+ var qm = new Parse.Query(CLASS_NAME); qm.equalTo('user', user);
|
|
|
+ var mineCount = await qm.count({ useMasterKey: true });
|
|
|
+ var companyCount = 0;
|
|
|
+ if (userCompany) {
|
|
|
+ var qc = new Parse.Query(CLASS_NAME); qc.equalTo('company', userCompany.pointer);
|
|
|
+ companyCount = await qc.count({ useMasterKey: true });
|
|
|
+ }
|
|
|
+ return _ok({
|
|
|
+ user: { objectId: user.id, username: user.get('username') },
|
|
|
+ isSuperAdmin: superAdmin,
|
|
|
+ company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null,
|
|
|
+ agentCount: { mine: mineCount, company: companyCount },
|
|
|
+ superAdminMembers: superIds.length
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 字段级更新(POST /status 语义) ----
|
|
|
+ if (op === 'update' || (params && params.agentId && params.field !== undefined && params.field !== null)) {
|
|
|
+ var agentId = params.agentId, field = params.field, value = params.value, companyId = params.companyId;
|
|
|
+ if (!agentId) return _fail(400, '缺少 agentId');
|
|
|
+ if (!field) return _fail(400, '缺少 field(要更新的字段名)');
|
|
|
+ if (FIELDS.indexOf(field) < 0) return _fail(400, '字段 ' + field + ' 不允许通过该接口更新(白名单: ' + FIELDS.join(',') + ')');
|
|
|
+ // v0.0.3 companyId 显式规则(三者之一可写, 放宽并写明):
|
|
|
+ // ① 超管 ② 用户无公司 ③ 用户属于该公司(companyId===用户公司id, 即下方"不符"分支之外的情形)
|
|
|
+ var company = userCompany;
|
|
|
+ if (companyId && (!userCompany || companyId !== userCompany.id)) {
|
|
|
+ var allowed = superAdmin || !userCompany || (userCompany && userCompany.id === companyId);
|
|
|
+ if (!allowed) return _fail(403, 'companyId(' + companyId + ') 与用户归属公司(' + (userCompany && userCompany.id) + ')不符且非超管(允许: 超管/无公司用户/本公司)');
|
|
|
+ try {
|
|
|
+ var comp = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
|
|
|
+ company = { id: comp.id, name: (comp && comp.get('name')) || '', pointer: comp };
|
|
|
+ } catch (e) { return _fail(400, 'companyId 无效: ' + companyId); }
|
|
|
+ }
|
|
|
+ var acl0 = _buildACL(user.id, company && company.id);
|
|
|
+ var agent = await _findOrRegister(agentId, user, company, acl0);
|
|
|
+ var owner = agent.get('user');
|
|
|
+ if (!((owner && owner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
|
|
|
+ // 四级 ACL 围绕"记录归属人"构造(超管代写时保留原 owner 本人 RW, 不被写入者顶替)
|
|
|
+ var acl = _buildACL(owner ? owner.id : user.id, company && company.id);
|
|
|
+ agent.set(field, value);
|
|
|
+ agent.set('lastField', field);
|
|
|
+ agent.set('lastValue', typeof value === 'string' ? value : JSON.stringify(value));
|
|
|
+ if (company && company.pointer) agent.set('company', company.pointer);
|
|
|
+ agent.setACL(acl);
|
|
|
+ agent.set('updatedAtClient', new Date());
|
|
|
+ await agent.save(null, { useMasterKey: true });
|
|
|
+ return _ok({
|
|
|
+ agentId: agentId, field: field, objectId: agent.id,
|
|
|
+ company: company ? { objectId: company.id, name: company.name } : null,
|
|
|
+ superAdmin: superAdmin
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- v0.1.0 用量落库 (POST /status 语义, op='usage') ----
|
|
|
+ // 权限: 与 update 分支同口径 —— 归属校验(本人或超管) + company 三规则
|
|
|
+ if (op === 'usage' && params && Array.isArray(params.usage)) {
|
|
|
+ var uAgentId = params.agentId;
|
|
|
+ if (!uAgentId) return _fail(400, '缺少 agentId');
|
|
|
+ var uCompanyId = params.companyId;
|
|
|
+ var uCompany = userCompany;
|
|
|
+ if (uCompanyId && (!userCompany || uCompanyId !== userCompany.id)) {
|
|
|
+ var uAllowed = superAdmin || !userCompany || (userCompany && userCompany.id === uCompanyId);
|
|
|
+ if (!uAllowed) return _fail(403, 'companyId 与用户归属公司不符且非超管');
|
|
|
+ try {
|
|
|
+ var uComp = await new Parse.Query('Company').get(uCompanyId, { useMasterKey: true });
|
|
|
+ uCompany = { id: uComp.id, name: (uComp && uComp.get('name')) || '', pointer: uComp };
|
|
|
+ } catch (e) { return _fail(400, 'companyId 无效: ' + uCompanyId); }
|
|
|
+ }
|
|
|
+ var uAcl = _buildACL(user.id, uCompany && uCompany.id);
|
|
|
+ var uAgent = await _findOrRegister(uAgentId, user, uCompany, uAcl);
|
|
|
+ var uOwner = uAgent.get('user');
|
|
|
+ if (!((uOwner && uOwner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
|
|
|
+ var picked = _pickUsage(params.usage);
|
|
|
+ var summary = _sumUsage(picked);
|
|
|
+ uAgent.set('usage', JSON.stringify(summary)); // 汇总(前端直读)
|
|
|
+ uAgent.set('usageSessions', JSON.stringify(picked)); // 明细(按会话)
|
|
|
+ if (uCompany && uCompany.pointer) uAgent.set('company', uCompany.pointer);
|
|
|
+ uAgent.setACL(_buildACL(uOwner ? uOwner.id : user.id, uCompany && uCompany.id));
|
|
|
+ uAgent.set('updatedAtClient', new Date());
|
|
|
+ await uAgent.save(null, { useMasterKey: true });
|
|
|
+ return _ok({
|
|
|
+ agentId: uAgentId, objectId: uAgent.id,
|
|
|
+ sessionCount: picked.length, totalTokens: summary.totalTokens,
|
|
|
+ company: uCompany ? { objectId: uCompany.id, name: uCompany.name } : null
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 查询(GET /status 语义, scope 三视角) ----
|
|
|
+ var _companyScopeUser = null; // v0.1.1 FALLBACK: 公司视角时记住当前用户,用于补自己的容器
|
|
|
+ var scope = (params && params.scope) || 'mine';
|
|
|
+ var qAgentId = params && params.agentId;
|
|
|
+ var q = new Parse.Query(CLASS_NAME);
|
|
|
+ if (scope === 'all') {
|
|
|
+ if (!superAdmin) return _fail(403, 'scope=all 仅系统超管可用');
|
|
|
+ // v0.1.2 ②: 超管可按 companyId 过滤(多账套切换用)
|
|
|
+ if (params && params.companyId) {
|
|
|
+ var _allComp;
|
|
|
+ try {
|
|
|
+ _allComp = await new Parse.Query('Company').get(params.companyId, { useMasterKey: true });
|
|
|
+ } catch (_ace) {
|
|
|
+ // v0.1.2 ③: 非法 companyId 明确报错(此前被静默忽略,返回全量)
|
|
|
+ return _fail(400, 'companyId 无效: ' + params.companyId);
|
|
|
+ }
|
|
|
+ q.equalTo('company', _allComp);
|
|
|
+ }
|
|
|
+ } else if (scope === 'company') {
|
|
|
+ var cid = (params && params.companyId) || (userCompany && userCompany.id);
|
|
|
+ if (cid && userCompany && cid !== userCompany.id && !superAdmin) return _fail(403, '无权查看其他公司账套(' + cid + ')');
|
|
|
+ if (!cid) return _fail(400, '无法确定公司账套(用户无 company 且未传 companyId)');
|
|
|
+ var scopeCompany;
|
|
|
+ try {
|
|
|
+ scopeCompany = await new Parse.Query('Company').get(cid, { useMasterKey: true });
|
|
|
+ } catch (_sce) {
|
|
|
+ return _fail(400, 'companyId 无效: ' + cid);
|
|
|
+ }
|
|
|
+ q.equalTo('company', scopeCompany);
|
|
|
+ // v0.1.1 FALLBACK: 标记「公司视角」,稍后补上「缺 company 但属于自己的容器」
|
|
|
+ _companyScopeUser = user;
|
|
|
+ } else {
|
|
|
+ q.equalTo('user', user);
|
|
|
+ }
|
|
|
+ if (qAgentId) q.equalTo('agentId', qAgentId);
|
|
|
+ if (params && params.fields) q.containedIn('status', String(params.fields).split(','));
|
|
|
+ q.include('user'); // v0.0.4: 主人信息以记录 user 指针为唯一真相
|
|
|
+ // v0.1.2 ①: 分页 —— 传了 limit/skip 才生效,不传保持原行为(1000 条)
|
|
|
+ // limit 上限 500 防拖库
|
|
|
+ var _limit = parseInt(params && params.limit, 10);
|
|
|
+ var _skip = parseInt(params && params.skip, 10);
|
|
|
+ var _paged = (_limit > 0);
|
|
|
+ if (_paged) q.limit(Math.min(_limit, 500));
|
|
|
+ else q.limit(1000);
|
|
|
+ if (_skip > 0) { try { q.skip(_skip); } catch (_ske) { return _fail(400, 'skip 参数不受支持'); } }
|
|
|
+ q.descending('updatedAt');
|
|
|
+ var _total = 0;
|
|
|
+ if (_paged || _skip > 0) {
|
|
|
+ // 🔴 count 是 Parse 标准 API;若环境不支持则降级为「本页条数」(不影响分页主流程)
|
|
|
+ try { _total = await q.count({ useMasterKey: true }); } catch (_tce) { _total = -1; }
|
|
|
+ }
|
|
|
+ var list = await q.find({ useMasterKey: true });
|
|
|
+ // v0.1.1 FALLBACK: 公司视角下,补上「company 为空但属于自己」的容器
|
|
|
+ // 兜底语义(用户裁决):缺 company 的容器**只有主人能看到**,同公司看不到
|
|
|
+ // 实现:额外查一次 user = 我 且 company 为空的记录,去重合并
|
|
|
+ if (_companyScopeUser) {
|
|
|
+ try {
|
|
|
+ var _selfQ = new Parse.Query(CLASS_NAME);
|
|
|
+ _selfQ.equalTo('user', _companyScopeUser);
|
|
|
+ _selfQ.include('user');
|
|
|
+ _selfQ.limit(500);
|
|
|
+ var _rawSelf = await _selfQ.find({ useMasterKey: true });
|
|
|
+ // 只用原版验证过的 API:查出来后过滤掉「已有 company」的(那些已在主查询里)
|
|
|
+ var _selfList = [];
|
|
|
+ for (var _rk = 0; _rk < _rawSelf.length; _rk++) {
|
|
|
+ if (!_rawSelf[_rk].get('company')) _selfList.push(_rawSelf[_rk]);
|
|
|
+ }
|
|
|
+ var _seen = {};
|
|
|
+ for (var _si = 0; _si < list.length; _si++) _seen[list[_si].id] = 1;
|
|
|
+ for (var _sj = 0; _sj < _selfList.length; _sj++) {
|
|
|
+ if (!_seen[_selfList[_sj].id]) list.push(_selfList[_sj]);
|
|
|
+ }
|
|
|
+ } catch (_se) { /* 兜底失败不影响主查询结果 */ }
|
|
|
+ }
|
|
|
+ var names = await _companyNames(list);
|
|
|
+ var agents = [];
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
+ agents.push(await _agentJSONv5(list[i], names)); // v0.0.5: v4 为主 + 通道配置透传/上报余额回退
|
|
|
+ }
|
|
|
+ if (qAgentId) {
|
|
|
+ if (!agents.length) return _fail(404, 'Agent 不存在或不在该视角范围内');
|
|
|
+ return _ok({ agent: agents[0], scope: scope, isSuperAdmin: superAdmin, company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null });
|
|
|
+ }
|
|
|
+ return _ok({
|
|
|
+ count: agents.length, agents: agents, scope: scope,
|
|
|
+ // v0.1.2 ①: 分页时返回分页前的总数(不传 limit/skip 时为 0,表示未分页)
|
|
|
+ total: _total,
|
|
|
+ isSuperAdmin: superAdmin,
|
|
|
+ company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ return _fail(500, (e && e.message) || String(e));
|
|
|
+ }
|
|
|
+}
|