Quellcode durchsuchen

feat(api): 字段兼容层 + 更正 reporter 部署误判

App 侧兼容非标准容器上报(用户裁决「两边都做」):
- channels 数组 → 对象:["matrix","wecom"] → {matrix:true,wecom:true}
  修 matrixReady 误判为 false 导致的「未接入」
- cpuLoad1 → cpuPercent:由 cpuLoad1/cpuCores*100 派生并夹到 0~100
  核数未知时不派生(宁可显示「未上报」不编假数字)
- heartbeatAt 三级回落:heartbeatAt → host.probedAt → updatedAtClient.iso
  修在线容器被显示成「心跳超时」
- models.ts: ChannelConfig 补 wecom / wecomBotId

文档更正(推翻上一轮判断):
- 「reporter 未部署」→「部署在非标准路径 + 用复刻脚本」
  反证:host.hostname=e14260fdce46(容器自身 Docker 短 ID)、
  uptimeSeconds=2294950、gateway=running 均为容器内部读数,
  且 updatedAt 持续刷新 → 是它自己在上报;容器按预设路径 find
  得出「没部署」属路径假阴性
- 新增问题 6「验证过算法但没落库」:容器自述已验证 cpuPercent=16.4,
  但上报体里无此键 → 验收口径改为回读平台值,禁止自算自证
- 新增第六章「回复容器本轮回答的提示词」(可直接发)

.gitignore: 排除 .tmp-v/(诊断脚本含 masterKey 明文)

验证:ng test 499(+15 归一用例)/ build / verify:ui 11 / verify:docs 46

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct vor 1 Woche
Ursprung
Commit
40d4ed5c81

+ 3 - 0
.gitignore

@@ -67,6 +67,9 @@ src/environments/*.local.ts
 # 🔴 本地备份与临时产物(含云函数源码/凭据,绝不入库)
 .local-backup/
 
+# 🔴 诊断/溯源临时脚本(含 masterKey 明文,绝不入库)
+.tmp-v/
+
 # 🔴 会话记录导出(含 masterKey/token/密码明文,绝不入库)
 fmode-app-2-claude-session/
 *.7z

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

@@ -778,3 +778,19 @@
 | 澄清(非缺陷) | TURN 404(`/voip/turnServer`)是 matrix-js-sdk 自动探测语音中继,SPEC FR-11 已裁决语音不做 → **无影响** |
 | 验证证据 | `ng test` **484** / `build` 通过 / `verify:ui` **11/11** / `verify:docs` **46 文件** |
 | 待容器处理 | ① 部署 reporter(用原版 v10)② 改用 `xlosi3m46q` ③ 字段对齐(`channels` 对象、`host.cpuPercent`、`usage`/`heartbeatAt`/`progress`)|
+
+### 2026-10-20 — App 侧字段兼容层(两边都做)+ 更正「reporter 未部署」误判
+
+| 项 | 内容 |
+|---|---|
+| **用户裁决** | 「两边都做」——App 加兼容层(立即生效)+ 容器改标准格式(长期对齐)|
+| **🆕 关键更正(推翻上一条留痕)** | 🔴 上一轮判断「reporter 根本没部署」**是错的**。平台库实测反证:<br>· `FmodeAgent[agent-node-lj-test].updatedAt` **44 秒前**、`lastField=channels`,**持续刷新**<br>· `host.hostname = e14260fdce46` —— 这是该容器**自己的 Docker 短 ID**;`uptimeSeconds=2294950`(26 天)、`gateway="running"` 均为**容器内部读数**<br>· 这些值平台推不出,**排除平台代报** → **就是它自己在上报**<br>**容器排查为何出错**:它按预设路径 `/opt/data/git-repos/agent-status-reporter/scripts/status-report.py` 去 `find`,脚本不在那儿 → 得出「没部署」= **路径假阴性**。正确做法是查运行态(`ps aux` + `/proc/<pid>/cmdline`)|
+| **对照证据(老容器 = 契约标准)** | `agent-node` 的 `host` 键 = `cpuPercent/diskPercent/memPercent`(标准);`agent-node-lj-test` = `hostname/cpuLoad1/cpuLoad5/uptimeSeconds/gateway/probedAt`(复刻格式)。→ **原版 reporter 存在且可用**,「换回原版」路径可行 |
+| **容器自证幻觉(新增问题 6)** | 容器称「cpuPercent 算法已验证 16.4」,但平台库里**连 `cpuPercent` 键都没有**(只有裸 `cpuLoad1=5.65`)。它在调试里自己算过,但那个值**从未进上报体**。<br>**修法**:验收口径改为**回读平台值**比对,禁止自算自证 |
+| **✏️ App 兼容层 ①:`channels` 数组 → 对象** | ✏️ `parse-api.ts` 新增 `normalizeChannels()`:数组元素视为「已点亮通道名」,`["matrix","wecom"]` → `{matrix:true,wecom:true}`;空数组 → `undefined`(视为未上报,而非"零通道"误导);对象形态(契约标准)原样透传 |
+| **✏️ App 兼容层 ②:`cpuLoad1` → `cpuPercent`** | ✏️ 新增 `normalizeHost()`:`cpuPercent` 优先;缺失时由 `cpuLoad1 / cpuCores * 100` 派生并夹到 0~100。🔴 **核数未知时不猜** —— 宁可返回 `undefined` 让 UI 显示「未上报」,也不假定核数编个假数字 |
+| **✏️ App 兼容层 ③:`heartbeatAt` 三级回落** | ✏️ 新增 `normalizeHeartbeat()`:`heartbeatAt` → `host.probedAt`(容器实际用的键)→ `updatedAtClient.iso`。不回落会让在线容器被显示成「心跳超时/离线」|
+| **✏️ 类型补全** | ✏️ `models.ts`:`ChannelConfig` 补 `wecom` / `wecomBotId`(数组归一后可得)|
+| **🆕 输出文档(更新)** | 《容器初始化流程优化建议-20261020.md》:<br>· 问题 1 **更正**为「部署在非标准路径 + 用复刻脚本」(附反证与 4 条自证线索)<br>· **新增问题 6**「验证过算法但没落库」(自证幻觉)<br>· **新增第六章**「回复容器本轮回答的提示词」(可直接发,含纠正误判 + 运行态排查法 + 回读验收口径)|
+| 验证证据 | `ng test` **499**(+15 归一用例)/ `build` 通过 / `verify:ui` **11/11** / `verify:docs` **46 文件** |
+| 新增文件 | `src/app/core/api/normalize.spec.ts`(15 用例:channels 数组/对象/空数组、cpuPercent 派生/优先/夹取/核数未知、heartbeatAt 三级回落、综合场景)|

+ 164 - 11
docs/03-interface/容器初始化流程优化建议-20261020.md

@@ -56,23 +56,56 @@
 
 ---
 
-## 二、🔴 本次实测发现的 5 个问题
+## 二、🔴 本次实测发现的问题(6 个)
 
 > 全部来自 `agent-node-lj-test` 容器的真实排查
 
-### 问题 1 · **reporter 根本没部署**(最严重)
+### 问题 1 · **reporter 部署在非标准路径 + 用复刻脚本**(原判断已更正)
 
-**现象**:
+> 🔴 **本问题已按 2026-10-20 实测更正**。初版文档判断「reporter 根本没部署」是**错误**的。
+
+**原判断依据**(容器自述 + 路径探查):
 ```
 /opt/data/git-repos/          → ❌ 目录不存在
-status-report.py              → ❌ 全盘找不到
+find / -iname "status-report.py" → ❌ 只有一份诊断文档
 crontab                       → ❌ 容器没装
 AGENT_ID                      → ❌ 空
 ```
+→ 据此认为「从不向 FmodeAgent 写记录 → App 完全看不到它」
 
-**结果**:容器从不向 `FmodeAgent` 表写记录 → **App 完全看不到它**
+**更正依据**(平台库实测,决定性反证):
+```
+FmodeAgent[agent-node-lj-test].host =
+  {"hostname":"e14260fdce46","memPercent":51.0,"cpuLoad1":5.65,"cpuLoad5":5.66,
+   "diskPercent":33.3,"uptimeSeconds":2294950,"gateway":"running","probedAt":"2026-10-20T11:05:34+08:00"}
+updatedAt = 44 秒前 · lastField = "channels" · 持续刷新
+```
 
-**根因**:workshop 的 Harness 流程**没包含 reporter 部署步骤**
+**为什么能确定「就是它自己在发」**(排除了平台代报):
+
+| 证据 | 说明 |
+|---|---|
+| `hostname = e14260fdce46` | 这正是 `agent-node-lj-test` **自己的 Docker 短 ID**(本机 `hostname`)。平台侧不知道这个值 |
+| `uptimeSeconds = 2294950`(≈26 天)| 容器内部计时器读数,外部推不出 |
+| `gateway = "running"` | 容器内 Hermes gateway 的**本地**健康状态 |
+| `memPercent` 实时波动 | 每次读数不同 → 是有进程在采样,不是缓存 |
+
+→ **结论**:reporter **在跑**,只是
+① 脚本放在**非 `/opt/fmode-app-v2` 预设的路径**(容器用错误路径查找 → 假阴性);
+② 用的是**复刻版**上报脚本,字段命名自定,**没对齐平台契约**(见问题 2、3)。
+
+**与标准容器的对照**(`agent-node` 老容器 = 契约标准):
+
+| 容器 | `host` 键 | 判定 |
+|---|---|---|
+| `agent-node` | `cpuPercent` / `diskPercent` / `memPercent` | ✅ 契约标准 |
+| `agent-node-lj-test` | `hostname` / `cpuLoad1` / `cpuLoad5` / `memPercent` / `diskPercent` / `uptimeSeconds` / `gateway` / `probedAt` | ❌ 复刻格式 |
+
+→ 原版 reporter **确实存在且可用**(老容器在跑),这印证了「拿到原版脚本替换复刻版」这条路可行。
+
+**教训(写给排查流程)**:
+> 「找不到文件」≠「进程没在跑」。判断上报方是否存活,必须**读平台侧回读值并按容器自证字段(hostname/uptime)反查**,
+> 不能只按预设路径 `find`。预设路径本身可能是错的。
 
 ---
 
@@ -138,6 +171,51 @@ FmodeAgent.company  → rpnXZC1loU(脑控科技)
 
 ---
 
+### 问题 6 · **容器「验证过算法」但实际没落库**(2026-10-20 新增)
+
+**容器自述**:
+> 「我实测复现了契约算法(本机 56 核):`cpuPercent = cpuLoad1 / nproc * 100 = 9.2 / 56 * 100 = 16.4`」
+
+**平台库实测**:
+```json
+"host": {"cpuLoad1": 5.65, "cpuLoad5": 5.66, ...}   ← 连 cpuPercent 这个键都没有
+```
+
+→ 它**算过**,但算的是**自己心里推的数**,从未写进上报体。这是「自证幻觉」:
+**调试时算了一遍 ≠ 上报链路里跑的是这个值**。
+
+**同类问题**(它自述中的其他误区):
+
+| 它的自述 | 实测 | 性质 |
+|---|---|---|
+| 「balance 拿不到,因 sessionToken 过期 401」 | ✅ 属实(`remainCny:null, source:none`) | 真阻塞 |
+| 「usage 数据源已确认可取(2118 万 token)」 | ✅ `state.db` 的 `sessionmodelusage` 表真在 | 真可行 |
+| 「cpuPercent 算法已验证 16.4」 | ❌ 上报体里**无此键** | **自证幻觉** |
+| 「本机没有部署 reporter」 | ❌ 在跑(hostname/uptime 自证) | **路径假阴性** |
+
+**修法(写进验收口径)**:
+
+```markdown
+### 🔴 上报自检必须「回读」,不许「自算」
+
+❌ 错误做法:自己算一遍 cpuPercent=16.4,然后报告"已验证"
+✅ 正确做法:上报后**回读平台值**,与期望逐字段比对:
+
+  1. 上报一次
+  2. 读回:
+     GET /parse/classes/FmodeAgent?where={"agentId":"<你的agentId>"}
+  3. 断言(逐条,缺一不可):
+     □ host 里存在 **cpuPercent** 键(不是 cpuLoad1)
+     □ channels 是 **对象**(不是数组)
+     □ heartbeatAt 有值
+     □ usage.totalTokens > 0
+  4. 任一条不满足 → 报告「字段未落库:<字段名>」,禁止报告"已验证"
+```
+
+> **一句话**:验收读的必须是**平台落库值**,不是容器内存里算出来的值。
+
+---
+
 ## 三、优化建议(按优先级)
 
 ### P0 · 在 Harness 流程中**新增「reporter 部署」步骤**
@@ -145,7 +223,7 @@ FmodeAgent.company  → rpnXZC1loU(脑控科技)
 **新增一步(建议放在 Harness 03 之后)**:
 
 ```markdown
-## Harness 03.5 · 状态上报(reporter)部署
+### Harness 03.5 · 状态上报(reporter)部署
 
 在容器内执行(不需要你手动操作,交给数字分身):
 
@@ -175,7 +253,7 @@ FmodeAgent.company  → rpnXZC1loU(脑控科技)
 **明确写进提示词**:
 
 ```markdown
-## 状态上报接口(唯一正确用法)
+### 状态上报接口(唯一正确用法)
 
 POST https://server.fmode.cn/api/functions
 Body: {
@@ -225,7 +303,7 @@ progress:0-100
 **新增自检步骤**:
 
 ```markdown
-## 初始化自检:身份三方对齐
+### 初始化自检:身份三方对齐
 
 登录后、绑定机器人前,确认以下三项**指向同一个账号**:
 
@@ -265,7 +343,7 @@ progress:0-100
 **在每个 Harness 步骤末尾加验收标准**:
 
 ```markdown
-## 验收(做完必须逐条确认)
+### 验收(做完必须逐条确认)
 
 □ reporter 进程在跑(ps aux | grep status-report)
 □ 手动上报返回 {"code":200,"field":"host"}
@@ -336,8 +414,83 @@ REPORTER-OK 云函数=<xlosi3m46q> host=<cpuPercent有> channels=<对象> 落库
 
 ---
 
-## 六、关联文档
+## 六、回复容器本轮回答的提示词(可直接发)
+
+> 用途:容器上一轮回答里有 **1 个错误结论 + 1 个自证幻觉**,需纠正后继续。
+> 已把「你的进程在跑」和「验收要回读」两件事写进提示词。
+
+```markdown
+【先纠正两个事实判断】
+
+1. ❌ 你说「本机没有部署 reporter」——**错,你的进程在跑**。
+   平台库实测(44 秒前仍在刷新):
+     FmodeAgent[agent-node-lj-test].host = {
+       "hostname":"e14260fdce46",     ← 这是你自己的 Docker 短 ID
+       "memPercent":51.0, "cpuLoad1":5.65, "cpuLoad5":5.66,
+       "diskPercent":33.3, "uptimeSeconds":2294950, "gateway":"running"
+     }
+   这些值(自己的 hostname、26 天 uptime、gateway 本地状态)只有容器内部拿得到,
+   不可能是平台代报 → **就是你在上报**。
+
+   你的排查是**假阴性**:你按预设路径 /opt/data/git-repos/agent-status-reporter/scripts/
+   去找,脚本不在那儿,你就得出"没部署"。但进程在跑。
+
+   → 请改用**运行态**排查,不要用路径排查:
+     ① ps aux | grep -i "status\|report" | grep -v grep
+     ② ls -l /proc/<pid>/cwd  → 看进程实际工作目录
+     ③ ls -l /proc/<pid>/cmdline  → 看实际跑的是哪个脚本
+     ④ 找到脚本真实路径后,`grep -n "cpuPercent\|channels" <脚本真实路径>` 定位改哪几行
+
+2. ❌ 你说「cpuPercent 算法已验证 16.4」——**自证幻觉**。
+   平台库里你的 host **根本没有 cpuPercent 这个键**,只有裸 cpuLoad1=5.65。
+   你在调试里自己算了一遍,但那个值**从未进入上报体**。
+
+   → 验收标准改为**回读比对**,不许自算自证:
+     □ host 里有 cpuPercent 键
+     □ channels 是对象不是数组
+     □ heartbeatAt 有值
+     □ usage.totalTokens > 0
+
+【你三个阻塞项的实际判定】
+
+| 你说的 | 判定 |
+|---|---|
+| sessionToken 过期 401 | ✅ 真问题,你自己重登换新 |
+| usage 数据源可取 | ✅ 可行,接上就行(非阻塞) |
+| cpuPercent 算法已验证 | ❌ 幻觉,值没落库 |
+
+【现在要做的 —— 优先顺序】
+
+第 1 步(最高优先):找到你**实际在跑的那份脚本**并改字段
+  按上面 ①~④ 定位真实路径,然后改这几处:
+  · host:发 cpuPercent(= cpuLoad1 / 核数 * 100),保留 cpuLoad1 作附加键无所谓
+  · channels:数组改对象
+      {"homeserver":"https://matrix.fmode.cn","matrix":true,
+       "matrixConfig":{"userId":"@agent-node-lj-test:fmode.cn","channelState":"connected"},
+       "wecom":true,"wecomBotId":"aibF7SNq4qV2dNwHypwp-eGUNPelYqiJ3kV"}
+  · heartbeatAt:每次上报写 ISO 时间戳(你现在只有 host.probedAt)
+  · usage:从 state.db 的 sessionmodelusage 聚合后上报
+
+第 2 步:改完**回读验证**(不要自算)
+  上报一次后读回,逐条断言上面 4 个 □
+
+第 3 步:报告
+  REPORTER-FIELDS-FIXED
+  脚本真实路径=<...>
+  host.cpuPercent=<回读值>
+  channels=<对象/数组>
+  heartbeatAt=<回读值>
+  usage.totalTokens=<回读值>
+
+【纪律】
+· 改脚本前先备份原文件
+· token 只输出指纹,不输出明文
+· **禁止报告"已验证"除非你贴的是平台回读值**
+```
+
+---
 
+## 七、关联文档
 - `docs/03-interface/reporter部署方案-20261020.md`(部署细节)
 - `docs/02-design/architecture/账号与账套体系设计.md`(归属模型)
 - `docs/02-design/plans/2026-10-19-数据层治理与新容器接入.md`(数据治理)

+ 4 - 0
src/app/core/api/models.ts

@@ -53,6 +53,10 @@ export interface ChannelConfig {
       source?: string;
     };
   };
+  /** 企业微信通道是否点亮(数组形态归一后得出) */
+  wecom?: boolean;
+  /** 企微机器人 BotID(数组形态下仅知通道名、无此值,故可选) */
+  wecomBotId?: string;
 }
 
 /**

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

@@ -0,0 +1,185 @@
+import { ParseApi } from './parse-api';
+
+/**
+ * 字段归一单测(2026-10-20 新增兼容层)
+ *
+ * 🔴 背景:实测 `agent-node-lj-test` 容器上报的字段名/结构与平台契约不符:
+ *   · `channels` 上报数组 `["wecom"]`(契约要求对象)→ 导致 matrixReady=false
+ *   · `host` 上报 `cpuLoad1`(契约要求 `cpuPercent`)→ CPU 不显示
+ *   · `heartbeatAt` 缺失(容器用 `host.probedAt`)
+ *
+ * 本测试覆盖 `mapAgent()` 内的三个归一函数(经 fetchAgents 走通)。
+ */
+describe('parse-api 字段归一(兼容非标准容器)', () => {
+  let api: ParseApi;
+  let calls: { url: string; init: RequestInit }[] = [];
+
+  function stubFetch(body: unknown): void {
+    calls = [];
+    spyOn(window, 'fetch').and.callFake((input: RequestInfo | URL, init?: RequestInit) => {
+      calls.push({ url: String(input), init: init ?? {} });
+      return Promise.resolve({
+        ok: true,
+        status: 200,
+        json: () => Promise.resolve(body),
+      } as unknown as Response);
+    });
+  }
+
+  beforeEach(() => {
+    api = new ParseApi(() => 'r:t', () => 'all');
+  });
+
+  describe('channels 数组 → 对象', () => {
+    it('["wecom"] → {wecom:true}(matrix 未点亮)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["wecom"]' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.channels?.wecom).toBe(true);
+      expect(a.channels?.matrix).toBeFalsy();
+    });
+
+    it('["matrix","wecom"] → 两者都点亮', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["matrix","wecom"]' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.channels?.matrix).toBe(true);
+      expect(a.channels?.wecom).toBe(true);
+    });
+
+    it('🔴 数组含 matrix → matrixReady=true(修复"未接入"误判)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '["matrix"]' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.matrixReady).toBe(true);
+    });
+
+    it('对象形态(契约标准)原样通过', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          {
+            agentId: 'a',
+            channels: '{"matrix":true,"homeserver":"https://matrix.fmode.cn","matrixConfig":{"userId":"@x:fmode.cn"}}',
+          },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.channels?.matrix).toBe(true);
+      expect(a.channels?.matrixConfig?.userId).toBe('@x:fmode.cn');
+      expect(a.matrixReady).toBe(true);
+    });
+
+    it('空数组 → undefined(不是"已接入")', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', channels: '[]' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.channels).toBeUndefined();
+      expect(a.matrixReady).toBe(false);
+    });
+  });
+
+  describe('host 归一:cpuLoad1 → cpuPercent', () => {
+    it('🔴 cpuLoad1=8, cpuCores=8 → cpuPercent=100', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', host: '{"cpuLoad1":8,"cpuCores":8,"memPercent":50,"diskPercent":30}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBe(100);
+    });
+
+    it('🔴 核数未知时不算 cpuPercent(不编假数字)', async () => {
+      // 实测契约口径是 cpuLoad1 / 核数;核数缺失时宁可"未上报",也不假定核数
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', host: '{"cpuLoad1":8,"memPercent":50,"diskPercent":30}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBeUndefined();
+      expect(a.host?.memPercent).toBe(50); // 其余指标仍保留
+    });
+
+    it('cpuLoad1=9.2, cpuCores=56 → cpuPercent≈16.4', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          { agentId: 'a', host: '{"cpuLoad1":9.2,"cpuCores":56,"memPercent":55.6,"diskPercent":33.3}' },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBeCloseTo(16.4, 1);
+    });
+
+    it('契约标准的 cpuPercent 优先(不被覆盖)', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', host: '{"cpuPercent":42,"cpuLoad1":99,"memPercent":10}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBe(42);
+    });
+
+    it('越界负载被夹到 0~100', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', host: '{"cpuLoad1":999,"cpuCores":2}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.host?.cpuPercent).toBe(100);
+    });
+
+    it('三项全无 → undefined(UI 显示"未上报"而非 0%)', async () => {
+      stubFetch({ code: 200, agents: [{ agentId: 'a', host: '{"hostname":"x"}' }] });
+      const [a] = await api.fetchAgents();
+      expect(a.host).toBeUndefined();
+    });
+  });
+
+  describe('heartbeatAt 归一', () => {
+    it('契约字段 heartbeatAt 优先', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', heartbeatAt: '2026-10-20T10:00:00Z', host: '{"probedAt":"2026-01-01T00:00:00Z"}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.heartbeatAt).toBe('2026-10-20T10:00:00Z');
+    });
+
+    it('缺失时回落 host.probedAt(容器实际用的键)', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', host: '{"probedAt":"2026-10-20T10:34:07+08:00"}' }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00');
+    });
+
+    it('再缺失时回落 updatedAtClient.iso', async () => {
+      stubFetch({
+        code: 200,
+        agents: [{ agentId: 'a', updatedAtClient: { iso: '2026-10-20T02:33:55.213Z' } }],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.heartbeatAt).toBe('2026-10-20T02:33:55.213Z');
+    });
+  });
+
+  describe('综合:非标准容器的完整映射', () => {
+    it('数组 channels + cpuLoad1 + probedAt → 全部归一正确', async () => {
+      stubFetch({
+        code: 200,
+        agents: [
+          {
+            agentId: 'agent-node-lj-test',
+            botName: 'lj-test',
+            status: 'running',
+            currentTask: 'periodic status reporter',
+            channels: '["matrix","wecom"]',
+            host: '{"hostname":"e14260fdce46","cpuLoad1":9.2,"cpuCores":56,"memPercent":55.6,"diskPercent":33.3,"probedAt":"2026-10-20T10:34:07+08:00"}',
+            balance: '{"source":"none"}',
+          },
+        ],
+      });
+      const [a] = await api.fetchAgents();
+      expect(a.id).toBe('agent-node-lj-test');
+      expect(a.matrixReady).toBe(true); // ← 修复前是 false
+      expect(a.host?.cpuPercent).toBeCloseTo(16.4, 1); // ← 修复前是 undefined
+      expect(a.host?.memPercent).toBe(55.6);
+      expect(a.heartbeatAt).toBe('2026-10-20T10:34:07+08:00'); // ← 修复前是 undefined
+    });
+  });
+});

+ 87 - 11
src/app/core/api/parse-api.ts

@@ -4,6 +4,7 @@ import {
   Agent,
   AgentStatus,
   AuthSession,
+  ChannelConfig,
   Deliverable,
   DeliverableGroup,
   MatrixProvision,
@@ -437,21 +438,94 @@ function friendlySendCodeError(raw: string | undefined, status: number): string
   return `验证码发送失败(HTTP ${status})`;
 }
 
+/**
+ * `channels` 归一:兼容**数组形态**的非标准容器上报。
+ *
+ * 🔴 实测(`agent-node-lj-test`):容器发的是 `["wecom"]`,而平台契约是**对象**
+ *    `{"matrix":true,"matrixConfig":{...},"wecom":true,...}`。
+ *    不归一 → `channels.matrix` 为 `undefined` → `matrixReady=false` → UI 显示「未接入」,
+ *    但该容器 Matrix 通道其实**是通的**(实测双向消息可达)。
+ *
+ * 数组语义:元素即**已点亮的通道名**,故 `["matrix","wecom"]` → 两者为 true。
+ * 空数组视为「未上报通道」而非「零通道」(返回 undefined,避免 UI 显示"未接入"误导)。
+ */
+function normalizeChannels(v: unknown): ChannelConfig | undefined {
+  const parsed = parseJsonLoose<unknown>(v);
+  if (parsed == null) return undefined;
+
+  if (Array.isArray(parsed)) {
+    const names = parsed.filter((x): x is string => typeof x === 'string');
+    if (names.length === 0) return undefined;
+    return {
+      matrix: names.includes('matrix'),
+      wecom: names.includes('wecom'),
+    };
+  }
+
+  // 对象形态:契约标准,原样透传(含 matrixConfig 细节)
+  return parsed as ChannelConfig;
+}
+
+/**
+ * `host` 归一:兼容 `cpuLoad1`(裸负载)→ `cpuPercent`(百分比)。
+ *
+ * 🔴 实测:容器发 `{"cpuLoad1":5.65,"cpuLoad5":5.66,"memPercent":51,...}`,
+ *    缺 `cpuPercent` 键 → CPU 不显示(memPercent/diskPercent 因键名对上才正常)。
+ *
+ * 换算口径 `cpuLoad1 / 核数 * 100`(实测 56 核实测 9.2 → 16.4 与容器自证一致)。
+ * ⚠️ 核数未知时**不猜**:宁可返回 undefined 让 UI 显示「未上报」,也不编一个假数字。
+ *    核数来源优先级:`cpuCores`(容器自报)→ 无。
+ */
+function normalizeHost(v: unknown): Agent['host'] {
+  const h = parseJsonLoose<Record<string, unknown>>(v);
+  if (!h) return undefined;
+
+  const num = (x: unknown): number | undefined =>
+    typeof x === 'number' && Number.isFinite(x) ? x : undefined;
+
+  // 契约标准的 cpuPercent 优先,不被派生值覆盖
+  let cpuPercent = num(h['cpuPercent']);
+  if (cpuPercent == null) {
+    const load = num(h['cpuLoad1']);
+    const cores = num(h['cpuCores']);
+    if (load != null && cores != null && cores > 0) {
+      cpuPercent = Math.min(100, Math.max(0, (load / cores) * 100));
+    }
+  }
+
+  const memPercent = num(h['memPercent']);
+  const diskPercent = num(h['diskPercent']);
+
+  // 三项指标全无 → 视为未上报(避免 UI 显示 0% 误导为"机器空闲")
+  if (cpuPercent == null && memPercent == null && diskPercent == null) return undefined;
+
+  return { cpuPercent, memPercent, diskPercent };
+}
+
+/**
+ * `heartbeatAt` 归一(三级回落)。
+ *
+ * 🔴 实测:容器不发 `heartbeatAt`,心跳时间藏在 `host.probedAt`
+ *    (采集时刻);两者皆无时回落 `updatedAtClient.iso`(云函数写入时刻)。
+ *    不回落的后果:UI 判「心跳超时」→ 在线容器被显示成离线。
+ */
+function normalizeHeartbeat(raw: Record<string, unknown>, host: unknown): string | undefined {
+  if (raw['heartbeatAt']) return String(raw['heartbeatAt']);
+  const h = parseJsonLoose<{ probedAt?: string }>(host);
+  if (h?.probedAt) return String(h.probedAt);
+  const uac = raw['updatedAtClient'] as { iso?: string } | undefined;
+  if (uac?.iso) return String(uac.iso);
+  return undefined;
+}
+
 /** 云函数原始记录 → `Agent` 领域模型(映射只在此处发生) */
 function mapAgent(raw: Record<string, unknown>): Agent {
-  const channels = parseJsonLoose<{
-    matrix?: boolean;
-    homeserver?: string;
-    matrixConfig?: { userId?: string; homeserver?: string; serverName?: string };
-  }>(raw['channels']);
+  const channels = normalizeChannels(raw['channels']);
   const balance = parseJsonLoose<{ remainCny?: number; remainRmb?: number; pageUrl?: string }>(
     raw['balance'],
   );
-  const host = parseJsonLoose<{
-    cpuPercent?: number;
-    memPercent?: number;
-    diskPercent?: number;
-  }>(raw['host']);
+  const host = normalizeHost(raw['host']);
+  const heartbeatAt = normalizeHeartbeat(raw, raw['host']);
   const user = raw['user'] as Record<string, unknown> | undefined;
 
   return {
@@ -463,7 +537,7 @@ function mapAgent(raw: Record<string, unknown>): Agent {
     currentTask: raw['currentTask'] ? String(raw['currentTask']) : undefined,
     currentAction: raw['currentAction'] ? String(raw['currentAction']) : undefined,
     progress: typeof raw['progress'] === 'number' ? (raw['progress'] as number) : undefined,
-    heartbeatAt: raw['heartbeatAt'] ? String(raw['heartbeatAt']) : undefined,
+    heartbeatAt,
     updatedAt: raw['updatedAt'] ? String(raw['updatedAt']) : undefined,
     host,
     balance: balance
@@ -478,6 +552,8 @@ function mapAgent(raw: Record<string, unknown>): Agent {
           matrix: channels.matrix,
           homeserver: channels.homeserver,
           matrixConfig: channels.matrixConfig,
+          wecom: channels.wecom,
+          wecomBotId: channels.wecomBotId,
         }
       : undefined,
     owner: user