| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624 |
- // 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 同默认)
- // v0.0.9 (2026-11-11): 用户余额权威源 —— 与 App Studio 充值页 /dev/studio/balance 同源
- var PLATFORM_API = 'https://server.fmode.cn/api/fmode';
- 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, sessionToken) {
- try {
- if (!user) return { balance: null, source: 'none' };
- var uid = user.id || '';
- /*
- * v0.0.9 (2026-11-11) 余额口径修复
- *
- * 🔴 旧实现(v0.0.8)用 fmodeApiToken 调 /api/usage/token/ 取 total_available 再自行换汇。
- * 该 token 实际是 unlimited_quota:true 的无限额度令牌, total_available 不是用户余额
- * → 线上实测 App 显示 -76.12, 而 Studio 充值页是 948.09。
- *
- * 新口径(与 Studio /dev/studio/balance 同源, 不再自行换算):
- * ① /api/fmode/billing/query {include:['balance']} → data.balance.availableCny / usedCny
- * ② 失败回落 /api/fmode/newapi/user {sessionToken} → data.user.balance / used_balance
- * 缓存: 键含用户 objectId(多账号隔离) + TTL 60s(充值后 1 分钟内可见)。
- */
- var cache = null;
- try { cache = globalThis.__FM9_BAL_CACHE = globalThis.__FM9_BAL_CACHE || {}; } catch (e) { cache = {}; }
- var ck = 'u:' + uid;
- var hit = cache[ck];
- if (hit && (Date.now() - hit.ts) < 60000) {
- return { balance: hit.balance, source: 'owner_token' };
- }
- var hdr = { 'Content-Type': 'application/json' };
- if (sessionToken) hdr['Authorization'] = 'Bearer ' + sessionToken;
- var balanceCny = null;
- // ① 平台 billing(权威)
- if (sessionToken) {
- var bq = await _httpsPostJson(PLATFORM_API + '/billing/query', hdr, { include: ['balance'] });
- var bb = bq && bq.data ? bq.data.balance : null;
- if (bb && typeof bb.availableCny === 'number') {
- balanceCny = {
- remainCny: Math.round(bb.availableCny * 100) / 100,
- usedCny: typeof bb.usedCny === 'number' ? Math.round(bb.usedCny * 100) / 100 : undefined,
- totalCalls: null,
- quota: typeof bb.availableQuota === 'number' ? bb.availableQuota : null,
- source: 'owner_token'
- };
- }
- }
- // ② 回落: newapi 用户信息(Studio 同源)
- if (!balanceCny && sessionToken) {
- var nu = await _httpsPostJson(PLATFORM_API + '/newapi/user', hdr, { sessionToken: sessionToken });
- var uo = nu && nu.data ? nu.data.user : null;
- if (uo && typeof uo.balance === 'number') {
- balanceCny = {
- remainCny: Math.round(uo.balance * 100) / 100,
- usedCny: typeof uo.used_balance === 'number' ? Math.round(uo.used_balance * 100) / 100 : undefined,
- totalCalls: parseInt(uo.request_count, 10) || null,
- quota: parseInt(uo.remaining_quota, 10) || null,
- source: 'owner_token'
- };
- }
- }
- if (balanceCny) {
- try { cache[ck] = { ts: Date.now(), balance: balanceCny }; } catch (e) {}
- return { balance: balanceCny, source: 'owner_token' };
- }
- // ③ 最后回落: 旧令牌通道(仅在无 sessionToken 时发生), 保留但不再优先
- var key = user.get('fmodeApiToken') || user.get('newapiToken') || '';
- if (!key) return { balance: null, source: 'none' };
- var tu = await _httpsGetJson(NEWAPI_TOKEN_USAGE, { Authorization: 'Bearer ' + key });
- /* unlimited_quota 令牌的 total_available 不是用户余额, 明确拒绝(旧 bug 根因) */
- if (tu && tu.data && tu.data.total_available != null && !tu.data.unlimited_quota) {
- var legacy = {
- 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: legacy }; } catch (e) {}
- return { balance: legacy, source: 'owner_token' };
- }
- return { balance: null, source: 'none' };
- } catch (e) { return { balance: null, source: 'none' }; }
- }
- /** POST JSON 并在失败/非 JSON 时返回 null(与 _httpsGetJson 同风格) */
- function _httpsPostJson(url, headers, body, 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, { method: 'POST', headers: headers || {}, body: JSON.stringify(body || {}), 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); }
- });
- }
- // 简单字符串哈希(仅作缓存键, 不存原 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, sessionToken) {
- 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, sessionToken);
- 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, sessionToken) {
- var o = await _agentJSONv4(a, names, sessionToken);
- 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 requestToken = _sessionToken();
- var names = await _companyNames(list);
- var agents = [];
- for (var i = 0; i < list.length; i++) {
- agents.push(await _agentJSONv5(list[i], names, requestToken)); // 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));
- }
- }
|