// 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)); } }