Jelajahi Sumber

fix(api): balance 兼容容器上报的 cny 键

容器回报 REPORTER-FIELDS-FIXED,平台回读复核:
6 条断言全部通过(cpuPercent=4.6 source=top / channels 对象 /
matrix=true / wecomBotId 匹配 / heartbeatAt 43s / usage 1470307)。

但 balance 漏改:容器报 remainCny=63.69,
平台实际 {"source":"session","cny":91.46}
—— 键名是 cny 不是 remainCny,且值也对不上(仍非回读)

App 兼容:remainCny ?? remainRmb ?? cny 三级回落,契约标准优先

另核实:容器报的 mxId @zgdcmgnqcd:fmode.cn 与 FmodeMatrixBinding
一致,我示例里的 @agent-node-lj-test 是错的,容器按真探测上报是正确做法。
App 的 agentMxId 来自 provision 云函数,不从 channels 读,故不影响会话。

验证:ng test 503 / build / verify:ui 11 / verify:docs 47

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 Minggu lalu
induk
melakukan
6282e1232a

+ 14 - 0
docs/00-tracking/操作追踪.md

@@ -827,3 +827,17 @@
 | 验证证据 | `ng test` **499** / `build` 通过 / `verify:ui` **11/11** / `verify:docs` **47 文件** |
 | 未验证项(披露) | 真实详情页的窄屏表现为**等价布局验证**,非在登录态下实测。后端数据(cpuPercent 等)到位后需再目视确认一次 |
 | 环境备注 | Playwright 浏览器走国内镜像安装(`PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright`)。测速:npmmirror **8.32 MB/s** vs 官方 akamai **0.09 MB/s** |
+
+### 2026-10-20 — 容器字段修复验收 + 自查发现漏改 `balance` 键名
+
+| 项 | 内容 |
+|---|---|
+| **容器回报** | `REPORTER-FIELDS-FIXED`:脚本真实路径 `/opt/data/git-repos/agent-status-reporter/scripts/status-report.py`,6 条断言全 PASS |
+| **平台回读复核(逐条实测)** | ✅ `host.cpuPercent=4.6`(`cpuPercentSource="top"`,口径正确)<br>✅ `channels` 是对象<br>✅ `channels.matrix=true`<br>✅ `wecomBotId` 精确匹配<br>✅ `heartbeatAt` 43s 前<br>✅ `usage.totalTokens=1470307` |
+| **🔴 自查发现它漏改的字段** | 它报「balance.remainCny=63.69」,平台实际值 = `{"source":"session","cny":91.46}`:<br>· **键名是 `cny` 不是 `remainCny`** → App 读 `remainCny` = `undefined` → **余额仍不显示**<br>· **值 63.69 ≠ 91.46** → 这个数字**不是回读来的**,仍是自算(虽已比上一轮诚实,标了 `source=session`)|
+| **mxId 不一致核实(它主动提出)** | 它报 `@zgdcmgnqcd:fmode.cn`,我示例给的 `@agent-node-lj-test:fmode.cn`。<br>**查 `FmodeMatrixBinding` 实测:`agentMxId` 就是 `@zgdcmgnqcd:fmode.cn`** → **它是对的,我的示例 mxId 是错的**。<br>另查:App 的 `agentMxId` 来自 **provision 云函数**(`chat.service.ts:204`),**不从容器 `channels` 读** → 不一致**不影响会话建立**,无需改 App |
+| **✏️ App 兼容层补 `cny` 键** | `parse-api.ts` `mapAgent`:`remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny`(三级回落,契约标准优先)|
+| **它本轮修掉的 3 个真问题(已核实)** | ① 企微配置在 `config.yaml` 的 `platforms.wecom` 不在 `.env` → 加 YAML 解析,`wecom:true` 生效<br>② 余额端点 `/v1/user/balance` 404 → 真端点 `POST /api/fmode/newapi/usage`,取 `data.account.balanceCny`<br>③ `heartbeatAt` 裸字符串被拒 → `{"type":"Date","iso":...}` 包装后 200 |
+| 备份 | 容器侧 `status-report.py.bak-20261020120537` ✅;旧循环 pid 7515 已停、新 pid 9182 在跑 ✅ |
+| 验证证据 | `ng test` **503**(+4 balance 归一用例)/ `build` 通过 / `verify:ui` **11/11** / `verify:docs` **47 文件** |
+| 遗留 | `balance` 键名仍需容器改为 `remainCny`(App 已兼容 `cny`,但规范口径应是 `remainCny`);建议下一轮让容器**只报回读原文**,不接受转述 |

+ 26 - 0
src/app/core/api/normalize.spec.ts

@@ -158,6 +158,32 @@ describe('parse-api 字段归一(兼容非标准容器)', () => {
     });
   });
 
+  describe('balance 归一:容器复刻脚本键名', () => {
+    it('🔴 容器发 {"cny":91.46} → remainCny 有值(修复余额不显示)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"source":"session","cny":91.46}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBe(91.46);
+    });
+
+    it('契约标准 remainCny 优先于 cny', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"remainCny":10,"cny":99}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBe(10);
+    });
+
+    it('旧键 remainRmb 仍兼容', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"remainRmb":7.5}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBe(7.5);
+    });
+
+    it('source=none 无金额 → remainCny 为 undefined(UI 显示 —)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', balance: '{"source":"none"}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.balance?.remainCny).toBeUndefined();
+    });
+  });
+
   describe('综合:非标准容器的完整映射', () => {
     it('数组 channels + cpuLoad1 + probedAt → 全部归一正确', async () => {
       stubFetch({

+ 3 - 3
src/app/core/api/parse-api.ts

@@ -521,7 +521,7 @@ function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string
 /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
 function mapAgent(raw: Record<string, unknown>): Agent {
   const channels = normalizeChannels(raw['channels']);
-  const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; pageUrl?: string }>(
+  const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; cny?: number; pageUrl?: string }>(
     raw['balance'],
   );
   const host = normalizeHost(raw['host']);
@@ -542,8 +542,8 @@ function mapAgent(raw: Record<string, unknown>): Agent {
     host,
     balance: balance
       ? {
-          // 映射层归一:兼容旧键 remainRmb
-          remainCny: balance.remainCny ?? balance.remainRmb,
+          // 映射层归一:兼容旧键 remainRmb,以及容器复刻脚本发的 cny
+          remainCny: balance.remainCny ?? balance.remainRmb ?? balance.cny,
           pageUrl: balance.pageUrl,
         }
       : undefined,