fmodeagent-status.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. // FmodeAgent 状态上报云函数 (v2 路由云函数化 · 免重启)
  2. // 调用: POST /api/functions {token, path:"/fmodeagent/status", params:{...}}
  3. // params.op = 'update' | 'query'(默认) | 'meta'; 亦可按入参自动判别
  4. // update: {agentId, field, value, companyId?} 白名单字段级更新+lastField记录
  5. // query: {scope: mine|company|all, agentId?, fields?, companyId?}
  6. // 响应格式与旧 REST /api/fmodeagent/status 完全一致(看板无缝切换)
  7. //
  8. // ===== v0.0.3 (round7 2026-09-06) 变更说明 =====
  9. // 1. UPDATABLE 白名单扩列: 新增 botName/botAvatar/ownerName/ownerMobile/balance/channels/extra
  10. // (reporter v3 采集: 机器人身份/主人身份/new-api余额/通道开关/其余原始数据JSON)
  11. // 2. params.companyId 显式规则(放宽写明, 版本注释): 三者之一可写 ——
  12. // ① 系统超管(FMODE_AGENT_SUPERADMIN) ② 用户无公司(userCompany 为空) ③ 用户属于该公司(companyId===用户公司)
  13. // 其余契约(四级ACL/三视角/meta/owner归属校验)不变
  14. //
  15. // ===== v0.0.4 (round8 2026-09-06) 变更说明 (只增不改, v0.0.3 全部 ACL/白名单逻辑保留) =====
  16. // 1. 主人信息以记录的 user 指针为唯一真相: 查询侧 q.include('user'),
  17. // ownerName = user.name || user.nickname || user.realName
  18. // ownerMobile = user.mobile || user.mobilePhoneNumber
  19. // ownerObjectId 一并返回(每条 agent JSON 附 userObjectId)
  20. // 说明: reporter 侧 ownerName/ownerMobile 仍按上报原样保留(容器 token 失效时为空,
  21. // 云函数以 user 指针实时兜底, 看板取值不依赖上报态)
  22. // 2. balance 改为主人 token 实时计算: user 对象上直接取 fmodeApiToken||newapiToken,
  23. // 调 GET https://api.fmode.cn/v1/dashboard/billing/subscription + /usage (Bearer 该 key),
  24. // 返回 {hardLimitUsd, usedUsd, remainUsd, remainRmb=remainUsd*7.2}
  25. // 安全约定: key 绝不入库(不 set 到任何 Parse 对象) / 绝不进日志 / 仅函数内存瞬时使用
  26. // 失败或无 token → balance=null(看板显「未配置」)
  27. // 3. botName = 记录.botName || channels.wecomBotId 前 8 位兜底 || agentId
  28. // botAvatar = 记录.botAvatar || DEFAULT_BOT_AVATAR
  29. // 4. 查询响应附 balanceSource('owner_token'|'none') 便于运维定位
  30. //
  31. // ===== v0.0.5 (round9/platform-v5 2026-09-07) 变更说明 (只增不改, v0.0.4 全部 ACL/白名单逻辑保留) =====
  32. // 1. 查询条目透传 reporter v6 上报的通道配置: channels.matrixConfig={homeserver,userId,serverName}
  33. // 与 channels.wecomConfig={botId,corpName} 原样附到 agent JSON (看板/App 悬浮展示)
  34. // 2. 余额回退: 云函数实时余额失败(无 fmodeApiToken)时, 回退 reporter v6① sessionToken 通道
  35. // 上报的 {remainCny,usedCny,totalCalls,source:'session'}; balanceSource 透传
  36. // 'session'|'apikey'|'owner_token'|'none' —— 无 key 容器也能显示余额
  37. // 3. UPDATABLE 白名单已含 channels/balance, 无需扩列; 生产 parse 禁重启, 本函数经
  38. // Parse REST PUT /parse/classes/Function/xlosi3m46q 热更
  39. // ===== v0.0.6 (TASK-matrix-v3 P2a 2026-09-08) 变更说明 (只增不改) =====
  40. // 1. UPDATABLE/FIELDS 白名单扩列: sessionStatus (reporter v8 探活上报:
  41. // valid|expired|unknown — FMODE_SESSION_TOKEN 调 /parse/users/me, 看板/App 健康灯)
  42. // ===== v0.0.7 (TASK-matrix-v4 P1/P4e 2026-09-08) 变更说明 (只增不改) =====
  43. // 1. FIELDS 白名单扩列: gender (male/female/unknown, reporter v9 上报, App TTS 声线用)
  44. // 2. botAvatar 兜底升级: 记录值 > BotRegistry(by channels.wecomBotId) > DEFAULT;
  45. // reporter v9 起 cron 注入的默认飞马 logo 不再直传覆盖, 注册表头像可透出
  46. // ===== v0.0.8 (TASK-matrix-v42 P1 2026-09-09) 变更说明 (只增不改) =====
  47. // 1. 余额口径 ¥化 (任务书 P1: balanceCny 权威字段):
  48. // _fetchBalance 主通道 sk- 原返回 {hardLimitUsd,usedUsd,remainUsd,remainRmb=USD*7.2}
  49. // (new-api subscription hard_limit 是 100000000 占位值 → remainUsd 无意义)。
  50. // 改调 newapi GET https://api.fmode.cn/api/usage/token/ (Bearer sk-) →
  51. // data.total_available = quota → remainCny = quota / 500000 * rate
  52. // (QUOTA_PER_UNIT=500000 与 api.fmode.cn/api/status quota_per_unit 一致;
  53. // rate 取 options.USDExchangeRate, 默认 6.8 — future-server enterprise-billing-service
  54. // quotaToCny 同公式; 平台网关 /api/status price=7.3 做上限参考不采用)
  55. // 兼容: total_available 为负(unlimited+已超)也如实上报; usedCny=total_used/500000*rate
  56. // 2. 余额兜底链 (查询侧): owner_token(sk-) 实时 → reporter session 通道 remainCny →
  57. // 旧 remainRmb → null(未配置)。App 端 balanceText 只认 remainCny → ¥xx.xx
  58. // (v3 旧 apikey 通道的 remainRmb 由 _balanceFromReport 升级换算 remainCny)
  59. var CLASS_NAME = 'FmodeAgent';
  60. var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
  61. var FIELDS = ['status','currentTask','currentAction','progress','name','containerRef','host','heartbeatAt',
  62. 'botName','botAvatar','gender','ownerName','ownerMobile','balance','channels','extra','sessionStatus',
  63. // v0.1.0: 用量两字段 (只增不删, 规则 17)
  64. 'usage','usageSessions'];
  65. // ===== v0.1.0 (fmode-app 一期) 新增: token 用量 =====
  66. // 白名单与容器侧 USAGE_FIELDS 成对维护 (校验: node scripts/check-reporter-fields.mjs)
  67. var USAGE_FIELDS = ['sessionId','title','model','messageCount','toolCallCount',
  68. 'inputTokens','outputTokens','cacheReadTokens','reasoningTokens',
  69. 'estimatedCostUsd','costStatus','startedAt'];
  70. var DEFAULT_BOT_AVATAR = 'https://app.fmode.cn/logo/feima.ico';
  71. var BOT_REGISTRY = 'BotRegistry';
  72. var BILLING_BASE = 'https://api.fmode.cn/v1/dashboard/billing';
  73. var NEWAPI_TOKEN_USAGE = 'https://api.fmode.cn/api/usage/token/'; // v0.0.8: quota 权威来源
  74. var QUOTA_PER_UNIT = 500000; // v0.0.8: api.fmode.cn/api/status quota_per_unit 同值
  75. var USD2RMB = 7.2;
  76. var USD2RMB_DEFAULT = 6.8; // v0.0.8: options.USDExchangeRate 缺省 (future-server 同默认)
  77. // v0.0.9 (2026-11-11): 用户余额权威源 —— 与 App Studio 充值页 /dev/studio/balance 同源
  78. var PLATFORM_API = 'https://server.fmode.cn/api/fmode';
  79. function _fail(code, mess) { response.status(code).json({ code: code, mess: mess }); }
  80. function _ok(data) { response.status(200).json(Object.assign({ code: 200 }, data)); }
  81. function _sessionToken() {
  82. /*
  83. * 🔴 2026-10-31 修复:平台不再把 token 塞进 request.body,只保留在
  84. * X-Parse-Session-Token 请求头里。原实现读 params.sessionToken 与
  85. * request.body.token 都拿不到 → 恒返回 null → 所有请求 401
  86. * (实测:同一 token 在 /parse/users/me 返回 200,在本函数返回 401)。
  87. * 这里加上请求头来源(只增不改,旧调用方式保持有效)。
  88. */
  89. var h = request && request.headers ? request.headers : {};
  90. if (h['x-parse-session-token']) return h['x-parse-session-token'];
  91. if (params && params.sessionToken) return params.sessionToken;
  92. if (request && request.body && request.body.token) return request.body.token;
  93. return null;
  94. }
  95. async function _getUser(t) {
  96. if (!t) return null;
  97. if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
  98. try {
  99. var q = new Parse.Query(Parse.Session);
  100. q.equalTo('sessionToken', t);
  101. var s = await q.first({ useMasterKey: true });
  102. return s ? s.get('user') : null;
  103. } catch (e) { return null; }
  104. }
  105. async function _roleUserIds(name) {
  106. try {
  107. var q = new Parse.Query(Parse.Role);
  108. q.equalTo('name', name);
  109. var role = await q.first({ useMasterKey: true });
  110. if (!role) return [];
  111. var us = await role.getUsers().query().find({ useMasterKey: true });
  112. return us.map(function (u) { return u.id; });
  113. } catch (e) { return []; }
  114. }
  115. async function _userCompany(u) {
  116. try {
  117. var ptr = u.get('company');
  118. if (!ptr) return null;
  119. var name = '';
  120. try { var c = await ptr.fetch({ useMasterKey: true }); name = (c && c.get('name')) || ''; } catch (_) {}
  121. return { id: ptr.id, name: name, pointer: ptr };
  122. } catch (e) { return null; }
  123. }
  124. function _buildACL(userId, companyId) {
  125. var acl = new Parse.ACL();
  126. acl.setPublicReadAccess(false); acl.setPublicWriteAccess(false);
  127. acl.setReadAccess(userId, true); acl.setWriteAccess(userId, true);
  128. acl.setRoleReadAccess(SUPER_ROLE, true); acl.setRoleWriteAccess(SUPER_ROLE, true);
  129. if (companyId) {
  130. acl.setRoleReadAccess(companyId + '_Admin', true);
  131. acl.setRoleWriteAccess(companyId + '_Admin', true);
  132. acl.setRoleReadAccess(companyId + '_Public', true);
  133. }
  134. return acl;
  135. }
  136. async function _findOrRegister(agentId, user, company, acl) {
  137. var q = new Parse.Query(CLASS_NAME);
  138. q.equalTo('agentId', agentId);
  139. var obj = await q.first({ useMasterKey: true });
  140. if (!obj) {
  141. var A = Parse.Object.extend(CLASS_NAME);
  142. obj = new A();
  143. obj.set('agentId', agentId);
  144. obj.set('user', user);
  145. if (company && company.pointer) obj.set('company', company.pointer);
  146. obj.setACL(acl);
  147. await obj.save(null, { useMasterKey: true });
  148. }
  149. return obj;
  150. }
  151. function _agentJSON(a, names) {
  152. var o = a.toJSON();
  153. o.heartbeatAgeSec = a.updatedAt ? Math.floor((Date.now() - a.updatedAt.getTime()) / 1000) : null;
  154. var c = a.get('company');
  155. if (c) o.company = { objectId: c.id, name: (names && names[c.id]) || '' };
  156. return o;
  157. }
  158. async function _companyNames(list) {
  159. var ids = [], seen = {};
  160. list.forEach(function (a) { var c = a.get('company'); if (c && !seen[c.id]) { seen[c.id] = 1; ids.push(c.id); } });
  161. var map = {};
  162. for (var i = 0; i < ids.length; i++) {
  163. try { var c = await new Parse.Query('Company').get(ids[i], { useMasterKey: true }); map[ids[i]] = (c && c.get('name')) || ''; }
  164. catch (_) { map[ids[i]] = ''; }
  165. }
  166. return map;
  167. }
  168. // ===== v0.0.4: 主人信息以 user 指针为唯一真相 =====
  169. function _ownerFromUser(u) {
  170. if (!u) return { ownerName: '', ownerMobile: '', userObjectId: null };
  171. return {
  172. ownerName: u.get('name') || u.get('nickname') || u.get('realName') || '',
  173. ownerMobile: u.get('mobile') || u.get('mobilePhoneNumber') || '',
  174. userObjectId: u.id
  175. };
  176. }
  177. // ===== v0.0.4: 主人 token 余额 (key 只进内存, 不入库不入日志) =====
  178. // 执行器用 new Function(context={Parse,request,response,params,user}) 包装, 作用域内无 require;
  179. // Deno 运行时有全局 fetch, 故用 fetch + AbortController
  180. // ===== v0.0.8 余额主通道: sk- → newapi /api/usage/token/ → quota → ¥ (任务书 P1 balanceCny 口径)
  181. // 旧 subscription+usage 通道保留为 fallback (hard_limit_usd=100000000 是占位 → remainUsd 无意义)
  182. function _httpsGetJson(url, headers, timeoutMs) {
  183. return new Promise(function (resolve) {
  184. try {
  185. if (typeof fetch !== 'function') { resolve(null); return; }
  186. var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
  187. var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, timeoutMs || 8000) : null;
  188. fetch(url, { headers: headers || {}, signal: ctrl ? ctrl.signal : undefined })
  189. .then(function (res) { return res.text(); })
  190. .then(function (txt) {
  191. if (t) clearTimeout(t);
  192. try { resolve(JSON.parse(txt)); } catch (e) { resolve(null); }
  193. })
  194. .catch(function () { if (t) clearTimeout(t); resolve(null); });
  195. } catch (e) { resolve(null); }
  196. });
  197. }
  198. async function _fetchBalance(user, sessionToken) {
  199. try {
  200. if (!user) return { balance: null, source: 'none' };
  201. var uid = user.id || '';
  202. /*
  203. * v0.0.9 (2026-11-11) 余额口径修复
  204. *
  205. * 🔴 旧实现(v0.0.8)用 fmodeApiToken 调 /api/usage/token/ 取 total_available 再自行换汇。
  206. * 该 token 实际是 unlimited_quota:true 的无限额度令牌, total_available 不是用户余额
  207. * → 线上实测 App 显示 -76.12, 而 Studio 充值页是 948.09。
  208. *
  209. * 新口径(与 Studio /dev/studio/balance 同源, 不再自行换算):
  210. * ① /api/fmode/billing/query {include:['balance']} → data.balance.availableCny / usedCny
  211. * ② 失败回落 /api/fmode/newapi/user {sessionToken} → data.user.balance / used_balance
  212. * 缓存: 键含用户 objectId(多账号隔离) + TTL 60s(充值后 1 分钟内可见)。
  213. */
  214. var cache = null;
  215. try { cache = globalThis.__FM9_BAL_CACHE = globalThis.__FM9_BAL_CACHE || {}; } catch (e) { cache = {}; }
  216. var ck = 'u:' + uid;
  217. var hit = cache[ck];
  218. if (hit && (Date.now() - hit.ts) < 60000) {
  219. return { balance: hit.balance, source: 'owner_token' };
  220. }
  221. var hdr = { 'Content-Type': 'application/json' };
  222. if (sessionToken) hdr['Authorization'] = 'Bearer ' + sessionToken;
  223. var balanceCny = null;
  224. // ① 平台 billing(权威)
  225. if (sessionToken) {
  226. var bq = await _httpsPostJson(PLATFORM_API + '/billing/query', hdr, { include: ['balance'] });
  227. var bb = bq && bq.data ? bq.data.balance : null;
  228. if (bb && typeof bb.availableCny === 'number') {
  229. balanceCny = {
  230. remainCny: Math.round(bb.availableCny * 100) / 100,
  231. usedCny: typeof bb.usedCny === 'number' ? Math.round(bb.usedCny * 100) / 100 : undefined,
  232. totalCalls: null,
  233. quota: typeof bb.availableQuota === 'number' ? bb.availableQuota : null,
  234. source: 'owner_token'
  235. };
  236. }
  237. }
  238. // ② 回落: newapi 用户信息(Studio 同源)
  239. if (!balanceCny && sessionToken) {
  240. var nu = await _httpsPostJson(PLATFORM_API + '/newapi/user', hdr, { sessionToken: sessionToken });
  241. var uo = nu && nu.data ? nu.data.user : null;
  242. if (uo && typeof uo.balance === 'number') {
  243. balanceCny = {
  244. remainCny: Math.round(uo.balance * 100) / 100,
  245. usedCny: typeof uo.used_balance === 'number' ? Math.round(uo.used_balance * 100) / 100 : undefined,
  246. totalCalls: parseInt(uo.request_count, 10) || null,
  247. quota: parseInt(uo.remaining_quota, 10) || null,
  248. source: 'owner_token'
  249. };
  250. }
  251. }
  252. if (balanceCny) {
  253. try { cache[ck] = { ts: Date.now(), balance: balanceCny }; } catch (e) {}
  254. return { balance: balanceCny, source: 'owner_token' };
  255. }
  256. // ③ 最后回落: 旧令牌通道(仅在无 sessionToken 时发生), 保留但不再优先
  257. var key = user.get('fmodeApiToken') || user.get('newapiToken') || '';
  258. if (!key) return { balance: null, source: 'none' };
  259. var tu = await _httpsGetJson(NEWAPI_TOKEN_USAGE, { Authorization: 'Bearer ' + key });
  260. /* unlimited_quota 令牌的 total_available 不是用户余额, 明确拒绝(旧 bug 根因) */
  261. if (tu && tu.data && tu.data.total_available != null && !tu.data.unlimited_quota) {
  262. var legacy = {
  263. remainCny: Math.round(tu.data.total_available / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
  264. usedCny: Math.round((parseFloat(tu.data.total_used) || 0) / QUOTA_PER_UNIT * USD2RMB_DEFAULT * 100) / 100,
  265. totalCalls: parseInt(tu.data.request_count, 10) || null,
  266. quota: Math.round(parseFloat(tu.data.total_available) || 0),
  267. source: 'owner_token'
  268. };
  269. try { cache[ck] = { ts: Date.now(), balance: legacy }; } catch (e) {}
  270. return { balance: legacy, source: 'owner_token' };
  271. }
  272. return { balance: null, source: 'none' };
  273. } catch (e) { return { balance: null, source: 'none' }; }
  274. }
  275. /** POST JSON 并在失败/非 JSON 时返回 null(与 _httpsGetJson 同风格) */
  276. function _httpsPostJson(url, headers, body, timeoutMs) {
  277. return new Promise(function (resolve) {
  278. try {
  279. if (typeof fetch !== 'function') { resolve(null); return; }
  280. var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
  281. var t = ctrl ? setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, timeoutMs || 8000) : null;
  282. fetch(url, { method: 'POST', headers: headers || {}, body: JSON.stringify(body || {}), signal: ctrl ? ctrl.signal : undefined })
  283. .then(function (res) { return res.text(); })
  284. .then(function (txt) {
  285. if (t) clearTimeout(t);
  286. try { resolve(JSON.parse(txt)); } catch (e) { resolve(null); }
  287. })
  288. .catch(function () { if (t) clearTimeout(t); resolve(null); });
  289. } catch (e) { resolve(null); }
  290. });
  291. }
  292. // 简单字符串哈希(仅作缓存键, 不存原 token)
  293. function _keyHash(s) {
  294. var h = 5381;
  295. for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; }
  296. return 'k' + h.toString(36);
  297. }
  298. // ===== v0.0.4: 查询条目增强 (user 指针主人 + 余额 + botName/botAvatar 兜底) =====
  299. async function _agentJSONv4(a, names, sessionToken) {
  300. var o = _agentJSON(a, names);
  301. var u = a.get('user'); // 查询时 include('user')
  302. var own = _ownerFromUser(u);
  303. o.ownerName = own.ownerName || o.ownerName || '';
  304. o.ownerMobile = own.ownerMobile || o.ownerMobile || '';
  305. o.userObjectId = own.userObjectId;
  306. var bal = await _fetchBalance(u, sessionToken);
  307. o.balance = bal.balance; // 主人 token 实时计算, 覆盖 reporter 旧值(契约: 云函数为准)
  308. o.balanceSource = bal.source;
  309. var ch = {};
  310. try { ch = typeof a.get('channels') === 'string' ? JSON.parse(a.get('channels') || '{}') : (a.get('channels') || {}); } catch (e) { ch = {}; }
  311. o.botName = a.get('botName') || (ch && ch.wecomBotId ? String(ch.wecomBotId).slice(0, 8) : '') || o.agentId || '';
  312. // v0.0.7: 头像链 记录值(排除默认飞马) > BotRegistry(by wecomBotId) > DEFAULT
  313. var recAvatar = a.get('botAvatar');
  314. if (recAvatar === DEFAULT_BOT_AVATAR) recAvatar = null;
  315. var gender = a.get('gender');
  316. if (recAvatar || !gender) {
  317. try {
  318. var BotReg = Parse.Object.extend(BOT_REGISTRY);
  319. var q = new Parse.Query(BotReg);
  320. if (ch && ch.wecomBotId) q.equalTo('botid', String(ch.wecomBotId));
  321. else { q.equalTo('agentId', o.agentId || ''); }
  322. q.limit(1);
  323. var reg = await q.first({ useMasterKey: true });
  324. if (reg) {
  325. if (!recAvatar && reg.get('avatar')) recAvatar = reg.get('avatar');
  326. if (!gender && reg.get('gender')) gender = reg.get('gender');
  327. }
  328. } catch (e) { /* BotRegistry 不可用不阻塞查询 */ }
  329. }
  330. o.botAvatar = recAvatar || DEFAULT_BOT_AVATAR;
  331. o.gender = gender || 'unknown';
  332. return o;
  333. }
  334. // ===== v0.0.5 (round9/platform-v5 2026-09-07) 通道配置透传 =====
  335. // reporter v6③ 采集: channels.matrix 真活检测(GET {MATRIX_HOMESERVER}/_matrix/client/versions)
  336. // + channels.matrixConfig={homeserver,userId,serverName} + channels.wecomConfig={botId,corpName}
  337. // + balance={remainCny,usedCny,totalCalls,source:'session'}(reporter v6① sessionToken 通道)
  338. // 查询条目透传规则(只增不改, v0.0.4 全部逻辑保留):
  339. // 1) _balanceFromReport: 云函数实时余额失败(balance=null)时回退 reporter 上报的
  340. // {remainCny,usedCny,totalCalls,source} —— 无 fmodeApiToken 主人的容器也能显示余额;
  341. // balanceSource 透传 'session'|'apikey'|'owner_token'|'none' 便于运维定位
  342. // 2) channels.matrixConfig / channels.wecomConfig 原样透传到 agent JSON
  343. // (看板/App 悬浮显示具体配置参数)
  344. function _parseCh(a) {
  345. try {
  346. var raw = a.get('channels');
  347. return typeof raw === 'string' ? JSON.parse(raw || '{}') : (raw || {});
  348. } catch (e) { return {}; }
  349. }
  350. function _balanceFromReport(a, ch) {
  351. try {
  352. var raw = a.get('balance');
  353. var b = typeof raw === 'string' ? JSON.parse(raw || 'null') : raw;
  354. if (!b || typeof b !== 'object') return null;
  355. if (b.remainCny === undefined && b.remainCny === null && b.remainRmb === undefined) return null;
  356. // v0.0.8 (P1): 只认 remainCny 权威; 旧 remainRmb(USD*7.2) 形态如实换算透传 (App 只显 ¥)
  357. // remainRmb ≥ 7e8 = hard_limit 占位假大数 → 视为无记账 (null)
  358. var remainCny = b.remainCny !== undefined && b.remainCny !== null ? b.remainCny
  359. : (b.remainRmb !== undefined && b.remainRmb !== null ? (Math.abs(b.remainRmb) >= 7e8 ? null : b.remainRmb) : null);
  360. if (remainCny === null && b.remainCny === undefined) return null;
  361. return {
  362. remainCny: remainCny,
  363. usedCny: b.usedCny !== undefined ? b.usedCny : (b.usedUsd !== undefined ? b.usedUsd : null),
  364. totalCalls: b.totalCalls !== undefined ? b.totalCalls : null,
  365. source: b.source || 'none'
  366. };
  367. } catch (e) { return null; }
  368. }
  369. async function _agentJSONv5(a, names, sessionToken) {
  370. var o = await _agentJSONv4(a, names, sessionToken);
  371. var ch = _parseCh(a);
  372. if (ch && typeof ch === 'object') {
  373. if (ch.matrixConfig && typeof ch.matrixConfig === 'object') o.matrixConfig = ch.matrixConfig;
  374. if (ch.wecomConfig && typeof ch.wecomConfig === 'object') o.wecomConfig = ch.wecomConfig;
  375. }
  376. if (!o.balance && o.balanceSource !== 'owner_token') {
  377. var rep = _balanceFromReport(a, ch);
  378. if (rep) { o.balance = rep; o.balanceSource = rep.source; }
  379. }
  380. return o;
  381. }
  382. // ===== v0.1.0 (fmode-app 一期): 用量落库 =====
  383. // 存储决策: 落在 FmodeAgent 表的 usage 字段 (JSON 字符串), **不另建表**。
  384. // 理由: 用量是「分身快照属性」(每个分身一份当前用量摘要), 符合 rules/05 规则 17
  385. // 「字段只增不删」; 前端实况面板可直接消费, 无需联表。
  386. // 字段级更新该字段需在 FIELDS 白名单内, 故 usage 也加入 FIELDS (见上)。
  387. function _pickUsage(list) {
  388. var out = [];
  389. if (!list || !list.length) return out;
  390. for (var i = 0; i < list.length; i++) {
  391. var src = list[i] || {};
  392. var o = {};
  393. for (var j = 0; j < USAGE_FIELDS.length; j++) {
  394. var k = USAGE_FIELDS[j];
  395. if (src[k] !== undefined) o[k] = src[k];
  396. }
  397. if (o.sessionId) out.push(o);
  398. }
  399. return out;
  400. }
  401. // 汇总: 供前端/看板直接读「总数」, 免去前端遍历
  402. function _sumUsage(list) {
  403. var s = { sessionCount: list.length, inputTokens: 0, outputTokens: 0,
  404. cacheReadTokens: 0, reasoningTokens: 0, totalTokens: 0,
  405. estimatedCostUsd: 0, toolCallCount: 0, messageCount: 0 };
  406. for (var i = 0; i < list.length; i++) {
  407. var u = list[i];
  408. s.inputTokens += (parseFloat(u.inputTokens) || 0);
  409. s.outputTokens += (parseFloat(u.outputTokens) || 0);
  410. s.cacheReadTokens += (parseFloat(u.cacheReadTokens) || 0);
  411. s.reasoningTokens += (parseFloat(u.reasoningTokens) || 0);
  412. s.estimatedCostUsd += (parseFloat(u.estimatedCostUsd) || 0);
  413. s.toolCallCount += (parseFloat(u.toolCallCount) || 0);
  414. s.messageCount += (parseFloat(u.messageCount) || 0);
  415. }
  416. s.totalTokens = s.inputTokens + s.outputTokens;
  417. s.estimatedCostUsd = Math.round(s.estimatedCostUsd * 10000) / 10000;
  418. s.updatedAt = new Date().toISOString();
  419. return s;
  420. }
  421. async function handler(request, response) {
  422. try {
  423. var op = (params && (params.op || params.action)) || null;
  424. var user = await _getUser(_sessionToken());
  425. if (!user) return _fail(401, 'SessionToken 无效或已过期');
  426. var superIds = await _roleUserIds(SUPER_ROLE);
  427. var superAdmin = superIds.indexOf(user.id) >= 0;
  428. var userCompany = await _userCompany(user);
  429. // ---- meta 自检(兼容独立 meta 函数, 本函数亦可 params.op='meta') ----
  430. if (op === 'meta') {
  431. var qm = new Parse.Query(CLASS_NAME); qm.equalTo('user', user);
  432. var mineCount = await qm.count({ useMasterKey: true });
  433. var companyCount = 0;
  434. if (userCompany) {
  435. var qc = new Parse.Query(CLASS_NAME); qc.equalTo('company', userCompany.pointer);
  436. companyCount = await qc.count({ useMasterKey: true });
  437. }
  438. return _ok({
  439. user: { objectId: user.id, username: user.get('username') },
  440. isSuperAdmin: superAdmin,
  441. company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null,
  442. agentCount: { mine: mineCount, company: companyCount },
  443. superAdminMembers: superIds.length
  444. });
  445. }
  446. // ---- 字段级更新(POST /status 语义) ----
  447. if (op === 'update' || (params && params.agentId && params.field !== undefined && params.field !== null)) {
  448. var agentId = params.agentId, field = params.field, value = params.value, companyId = params.companyId;
  449. if (!agentId) return _fail(400, '缺少 agentId');
  450. if (!field) return _fail(400, '缺少 field(要更新的字段名)');
  451. if (FIELDS.indexOf(field) < 0) return _fail(400, '字段 ' + field + ' 不允许通过该接口更新(白名单: ' + FIELDS.join(',') + ')');
  452. // v0.0.3 companyId 显式规则(三者之一可写, 放宽并写明):
  453. // ① 超管 ② 用户无公司 ③ 用户属于该公司(companyId===用户公司id, 即下方"不符"分支之外的情形)
  454. var company = userCompany;
  455. if (companyId && (!userCompany || companyId !== userCompany.id)) {
  456. var allowed = superAdmin || !userCompany || (userCompany && userCompany.id === companyId);
  457. if (!allowed) return _fail(403, 'companyId(' + companyId + ') 与用户归属公司(' + (userCompany && userCompany.id) + ')不符且非超管(允许: 超管/无公司用户/本公司)');
  458. try {
  459. var comp = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  460. company = { id: comp.id, name: (comp && comp.get('name')) || '', pointer: comp };
  461. } catch (e) { return _fail(400, 'companyId 无效: ' + companyId); }
  462. }
  463. var acl0 = _buildACL(user.id, company && company.id);
  464. var agent = await _findOrRegister(agentId, user, company, acl0);
  465. var owner = agent.get('user');
  466. if (!((owner && owner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
  467. // 四级 ACL 围绕"记录归属人"构造(超管代写时保留原 owner 本人 RW, 不被写入者顶替)
  468. var acl = _buildACL(owner ? owner.id : user.id, company && company.id);
  469. agent.set(field, value);
  470. agent.set('lastField', field);
  471. agent.set('lastValue', typeof value === 'string' ? value : JSON.stringify(value));
  472. if (company && company.pointer) agent.set('company', company.pointer);
  473. agent.setACL(acl);
  474. agent.set('updatedAtClient', new Date());
  475. await agent.save(null, { useMasterKey: true });
  476. return _ok({
  477. agentId: agentId, field: field, objectId: agent.id,
  478. company: company ? { objectId: company.id, name: company.name } : null,
  479. superAdmin: superAdmin
  480. });
  481. }
  482. // ---- v0.1.0 用量落库 (POST /status 语义, op='usage') ----
  483. // 权限: 与 update 分支同口径 —— 归属校验(本人或超管) + company 三规则
  484. if (op === 'usage' && params && Array.isArray(params.usage)) {
  485. var uAgentId = params.agentId;
  486. if (!uAgentId) return _fail(400, '缺少 agentId');
  487. var uCompanyId = params.companyId;
  488. var uCompany = userCompany;
  489. if (uCompanyId && (!userCompany || uCompanyId !== userCompany.id)) {
  490. var uAllowed = superAdmin || !userCompany || (userCompany && userCompany.id === uCompanyId);
  491. if (!uAllowed) return _fail(403, 'companyId 与用户归属公司不符且非超管');
  492. try {
  493. var uComp = await new Parse.Query('Company').get(uCompanyId, { useMasterKey: true });
  494. uCompany = { id: uComp.id, name: (uComp && uComp.get('name')) || '', pointer: uComp };
  495. } catch (e) { return _fail(400, 'companyId 无效: ' + uCompanyId); }
  496. }
  497. var uAcl = _buildACL(user.id, uCompany && uCompany.id);
  498. var uAgent = await _findOrRegister(uAgentId, user, uCompany, uAcl);
  499. var uOwner = uAgent.get('user');
  500. if (!((uOwner && uOwner.id === user.id) || superAdmin)) return _fail(403, '无权更新该 Agent(归属校验失败)');
  501. var picked = _pickUsage(params.usage);
  502. var summary = _sumUsage(picked);
  503. uAgent.set('usage', JSON.stringify(summary)); // 汇总(前端直读)
  504. uAgent.set('usageSessions', JSON.stringify(picked)); // 明细(按会话)
  505. if (uCompany && uCompany.pointer) uAgent.set('company', uCompany.pointer);
  506. uAgent.setACL(_buildACL(uOwner ? uOwner.id : user.id, uCompany && uCompany.id));
  507. uAgent.set('updatedAtClient', new Date());
  508. await uAgent.save(null, { useMasterKey: true });
  509. return _ok({
  510. agentId: uAgentId, objectId: uAgent.id,
  511. sessionCount: picked.length, totalTokens: summary.totalTokens,
  512. company: uCompany ? { objectId: uCompany.id, name: uCompany.name } : null
  513. });
  514. }
  515. // ---- 查询(GET /status 语义, scope 三视角) ----
  516. var _companyScopeUser = null; // v0.1.1 FALLBACK: 公司视角时记住当前用户,用于补自己的容器
  517. var scope = (params && params.scope) || 'mine';
  518. var qAgentId = params && params.agentId;
  519. var q = new Parse.Query(CLASS_NAME);
  520. if (scope === 'all') {
  521. if (!superAdmin) return _fail(403, 'scope=all 仅系统超管可用');
  522. // v0.1.2 ②: 超管可按 companyId 过滤(多账套切换用)
  523. if (params && params.companyId) {
  524. var _allComp;
  525. try {
  526. _allComp = await new Parse.Query('Company').get(params.companyId, { useMasterKey: true });
  527. } catch (_ace) {
  528. // v0.1.2 ③: 非法 companyId 明确报错(此前被静默忽略,返回全量)
  529. return _fail(400, 'companyId 无效: ' + params.companyId);
  530. }
  531. q.equalTo('company', _allComp);
  532. }
  533. } else if (scope === 'company') {
  534. var cid = (params && params.companyId) || (userCompany && userCompany.id);
  535. if (cid && userCompany && cid !== userCompany.id && !superAdmin) return _fail(403, '无权查看其他公司账套(' + cid + ')');
  536. if (!cid) return _fail(400, '无法确定公司账套(用户无 company 且未传 companyId)');
  537. var scopeCompany;
  538. try {
  539. scopeCompany = await new Parse.Query('Company').get(cid, { useMasterKey: true });
  540. } catch (_sce) {
  541. return _fail(400, 'companyId 无效: ' + cid);
  542. }
  543. q.equalTo('company', scopeCompany);
  544. // v0.1.1 FALLBACK: 标记「公司视角」,稍后补上「缺 company 但属于自己的容器」
  545. _companyScopeUser = user;
  546. } else {
  547. q.equalTo('user', user);
  548. }
  549. if (qAgentId) q.equalTo('agentId', qAgentId);
  550. if (params && params.fields) q.containedIn('status', String(params.fields).split(','));
  551. q.include('user'); // v0.0.4: 主人信息以记录 user 指针为唯一真相
  552. // v0.1.2 ①: 分页 —— 传了 limit/skip 才生效,不传保持原行为(1000 条)
  553. // limit 上限 500 防拖库
  554. var _limit = parseInt(params && params.limit, 10);
  555. var _skip = parseInt(params && params.skip, 10);
  556. var _paged = (_limit > 0);
  557. if (_paged) q.limit(Math.min(_limit, 500));
  558. else q.limit(1000);
  559. if (_skip > 0) { try { q.skip(_skip); } catch (_ske) { return _fail(400, 'skip 参数不受支持'); } }
  560. q.descending('updatedAt');
  561. var _total = 0;
  562. if (_paged || _skip > 0) {
  563. // 🔴 count 是 Parse 标准 API;若环境不支持则降级为「本页条数」(不影响分页主流程)
  564. try { _total = await q.count({ useMasterKey: true }); } catch (_tce) { _total = -1; }
  565. }
  566. var list = await q.find({ useMasterKey: true });
  567. // v0.1.1 FALLBACK: 公司视角下,补上「company 为空但属于自己」的容器
  568. // 兜底语义(用户裁决):缺 company 的容器**只有主人能看到**,同公司看不到
  569. // 实现:额外查一次 user = 我 且 company 为空的记录,去重合并
  570. if (_companyScopeUser) {
  571. try {
  572. var _selfQ = new Parse.Query(CLASS_NAME);
  573. _selfQ.equalTo('user', _companyScopeUser);
  574. _selfQ.include('user');
  575. _selfQ.limit(500);
  576. var _rawSelf = await _selfQ.find({ useMasterKey: true });
  577. // 只用原版验证过的 API:查出来后过滤掉「已有 company」的(那些已在主查询里)
  578. var _selfList = [];
  579. for (var _rk = 0; _rk < _rawSelf.length; _rk++) {
  580. if (!_rawSelf[_rk].get('company')) _selfList.push(_rawSelf[_rk]);
  581. }
  582. var _seen = {};
  583. for (var _si = 0; _si < list.length; _si++) _seen[list[_si].id] = 1;
  584. for (var _sj = 0; _sj < _selfList.length; _sj++) {
  585. if (!_seen[_selfList[_sj].id]) list.push(_selfList[_sj]);
  586. }
  587. } catch (_se) { /* 兜底失败不影响主查询结果 */ }
  588. }
  589. var requestToken = _sessionToken();
  590. var names = await _companyNames(list);
  591. var agents = [];
  592. for (var i = 0; i < list.length; i++) {
  593. agents.push(await _agentJSONv5(list[i], names, requestToken)); // v0.0.5: v4 为主 + 通道配置透传/上报余额回退
  594. }
  595. if (qAgentId) {
  596. if (!agents.length) return _fail(404, 'Agent 不存在或不在该视角范围内');
  597. return _ok({ agent: agents[0], scope: scope, isSuperAdmin: superAdmin, company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null });
  598. }
  599. return _ok({
  600. count: agents.length, agents: agents, scope: scope,
  601. // v0.1.2 ①: 分页时返回分页前的总数(不传 limit/skip 时为 0,表示未分页)
  602. total: _total,
  603. isSuperAdmin: superAdmin,
  604. company: userCompany ? { objectId: userCompany.id, name: userCompany.name } : null
  605. });
  606. } catch (e) {
  607. return _fail(500, (e && e.message) || String(e));
  608. }
  609. }