فهرست منبع

fix: 无会话时发附件也登记台账(右栏文件看不到)

## 问题
进入容器但尚未建会话时发送附件:上传成功、消息发出、气泡正常显示,
但右栏「文件」页签里没有这条(listBySession 只按 threadRoot 查)。
刷新后尤其明显,用户会以为文件丢了。

## 根因
登记语句包在 `if (sid && ...)` 里,`sid = this._sessionId()` 为 null 时直接跳过。
而上传与发送走的是另一条兜底路径(sendReal 里有 ensureSessionForSend),
两者不一致 —— 文件上去了、台账却没记。

## 修法
- `ledgerRoot = sid ?? ensureSessionForSend()?.id`:先确保会话存在再登记
- 发送附件时也用同一个 `ledgerRoot` 作 thread 根,
  保证「台账记的根」与「容器收到的根」一致

## 验证
新增回归测试「无会话时发附件也必须登记台账」,
npm test 1728 SUCCESS。

## 附带
新增《给主容器-重启lj-test网关》文档,记录一个长期误区:
lj-test 的 gateway 不在 s6 监督下(gateway-default 是空槽),
`/tmp/gw-restart.log` 被 s6 接管后架空,真实日志在 /opt/data/logs/gateway.log。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 روز پیش
والد
کامیت
74951132d3

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

@@ -7984,3 +7984,16 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | 容器复核反馈 | 容器侧实机复核 v1 任务书,报 5 处问题,已全部吸收进 v2:aiohttp 依赖结论写反(裸 python3 无、venv 有)→改双后端+venv 解释器;`file-cloud.fmode.cn` 证书过期→白名单剔除(App 侧同步);`/opt/hermes` 在 overlay 不持久→要求固化进镜像;`resp.read()` 大小上限失效→改流式计数;重定向绕过 SSRF→`allow_redirects=False` |
 | 未验证 | 补丁落地后的正向探测;宿主机位置与镜像固化方案;200MB 是否超出 OBS 单次 PUT 能力;真机双端手工走查 |
 | 关联文档 | [任务书](../03-interface/容器侧改造-附件直读S3地址-任务书-20261113.md) · [发给宿主机提示词](../03-interface/发给宿主机-容器附件直读改造-提示词-20261113.md) · [API-契约](../03-interface/api/API-契约.md) §2.5 |
+
+## 2026-11-13 · 群聊方案梳理(暂停记录)
+
+| 项 | 内容 |
+|---|---|
+| 动作 | 向 lj-test 取证完整运行逻辑,据此设计群聊与容器协同方案;中途因新问题暂停 |
+| 取证方式 | 直接对话 `agent-node-lj-test`,48 段回复 994 行,全部带代码行号/进程环境证据 |
+| **推翻的文档结论** | ① `MATRIX_REQUIRE_MENTION` 文档说 false,**实际生效 True**(`.env` 未进进程,回落默认值)② 群聊上下文「聋」:不 @ 的消息在 `adapter.py:2050` 直接丢弃,**不写 session、无群历史** ③ 群房间每条无 thread 消息合成独立 thread = 独立会话 ④ 群内文件**必须 @ 才下载**(gate 在下载之前)⑤ 并发**不会串台**(`contextvars` 保证)⑥ `max_concurrent_sessions=None` 无上限 ⑦ `busy_input_mode=interrupt` 同会话后到者打断前者 |
+| **新发现的阻断项** | 容器**完全无鉴权**:env allowlist 不存在 / pairing store 空 / config.yaml matrix 段无 policy / 日志 0 条拦截记录 → 任何知道 mxid 的人都能驱动它。用户已裁决「安全优先,配白名单」 |
+| 群聊架构决策 | 弃「伪群聊拆私聊」为主链路,改「真群房间 + thread 作话题根 + App 侧补引用历史 + 群内文件自动 @」 |
+| 方案文档 | 计划文件 `eventual-squishing-goose.md`(含完整实施顺序) |
+| 暂停原因 | 用户反馈「容器无法返回文件、成果里也没有」,优先排查此问题 |
+| 未完成 | ① 鉴权白名单配置(待查 `.env` 加载机制)② P0 缺陷 3 处(`_paginating` 锁泄漏、`GroupLiveService` 单值全局、群回填只翻 50 条)③ 群房间主链路 ④ 恢复群聊入口 6 处 |

+ 170 - 0
docs/03-interface/给主容器-重启lj-test网关-20261113.md

@@ -0,0 +1,170 @@
+# 给主容器:重启 `agent-node-lj-test` 的 Gateway
+
+> 日期:2026-11-13
+> 目标:让插件新代码生效(代码已改好、`py_compile` 通过,但没重启)
+> 预计中断:**3 秒**(稳妥预期 30-60 秒)
+
+---
+
+## 〇、为什么需要这次重启
+
+容器 `agent-node-lj-test` 的 `fmode-deliverable-hook.py` 已修复「无 threadRoot 就整批丢弃」的 bug,
+但 gateway 进程仍在跑旧代码,因此**文件上云链路依然不通**。
+
+---
+
+## 一、目标容器定位(⚠️ 先核对,别找错)
+
+之前有过多起「按 docker 容器名找错容器」的事故。**只认这两项**:
+
+```bash
+docker exec agent-node-lj-test sh -c '
+echo "AGENT_ID    = $(grep -E "^AGENT_ID=" /opt/data/.env | cut -d= -f2-)"
+echo "MATRIX_USER = $(grep -E "^MATRIX_USER_ID=" /opt/data/.env | cut -d= -f2-)"
+'
+```
+
+| 项 | 期望值 |
+|---|---|
+| `AGENT_ID` | `agent-node-lj-test` |
+| `MATRIX_USER` | `@zgdcmgnqcd:fmode.cn` |
+
+> ⚠️ docker 容器名是 `agent-node-lj-test` 的那台,`AGENT_ID` 可能为空(那是退役的旧身份)。
+> 真正目标的 docker 名可能是 `agent-node-u19170119836`。**以 AGENT_ID + MATRIX_USER 为准。**
+
+---
+
+## 二、🔴 关键:不要用 s6 重启(那是空槽)
+
+容器侧实测:
+
+```
+/run/service/gateway-default  →  down (exitcode 0) 7070 seconds   ← 7000+ 秒前就挂了,是空的
+/run/s6/services/main-hermes  →  只 exec sleep infinity,也是空的
+真正跑 gateway 的是 PID 94769,由 rc.init 的 `hermes gateway run --replace` 启动,不在 s6 监督下
+```
+
+**所以 `s6-svc -r /run/service/gateway-default` 重启的是一个本来就没运行的备用槽,等于没重启。**
+
+> 这也解释了历史上「改了配置说重启了、实际没生效」的原因。
+
+---
+
+## 三、执行步骤(在容器内,需 root)
+
+### 1. 记录重启前状态
+
+```bash
+echo "=== 旧 PID ==="
+ps -eo pid,lstart,cmd | grep "gateway run" | grep -v grep
+cat /opt/data/gateway.lock 2>/dev/null
+```
+
+### 2. 优雅重启
+
+```bash
+# ① 拿到当前 PID(应是 94769,若不同则用实际值)
+OLD=$(pgrep -f "hermes gateway run" | head -1)
+echo "OLD=$OLD"
+
+# ② 优雅退出
+kill -TERM "$OLD"
+
+# ③ 等它放锁(最多 20 秒)
+for i in $(seq 1 20); do
+  kill -0 "$OLD" 2>/dev/null || break
+  sleep 1
+done
+
+# ④ 20 秒不退再强杀
+kill -KILL "$OLD" 2>/dev/null || true
+sleep 1
+
+# ⑤ 确认进程已死、锁已释放,再清锁
+kill -0 "$OLD" 2>/dev/null && echo "STILL_ALIVE" || echo "EXITED"
+rm -f /opt/data/gateway.lock
+
+# ⑥ 重新拉起(与原启动方式一致)
+cd /opt/hermes && setsid nohup /opt/hermes/.venv/bin/hermes gateway run --replace \
+  > /tmp/gw-restart.log 2>&1 < /dev/null &
+
+sleep 8
+```
+
+> ⚠️ 第 ⑤ 步的 `rm -f gateway.lock` **必须在确认进程已死之后**,否则会两个进程抢锁。
+
+### 3. 恢复 s6 监督(推荐,一次性理顺)
+
+```bash
+/command/s6-svc -u /run/service/gateway-default
+sleep 3
+/command/s6-svstat /run/service/gateway-default   # 期望 up (pid …)
+```
+
+> 若 `gateway-default` 拉起后因锁冲突立刻退出,说明 94769 还没死透 —— 回到第 2 步再等。
+
+---
+
+## 四、验证(三项都要对上)
+
+### 1. 进程层
+
+```bash
+# 新 PID 启动时间必须晚于现在
+ps -o pid,lstart,etime,cmd -C python3 | grep "gateway run"
+cat /opt/data/gateway.lock     # pid 应是新 PID
+```
+
+### 2. 日志层(**决定性**:这两个关键字是本次改动独有的,旧代码不会打)
+
+```bash
+tail -100 /tmp/gw-restart.log | grep -E "strip_media|已上云|MEDIA|Traceback|connected"
+```
+
+期望看到三通道连上:`matrix` / `api_server` / `wecom` connected,且**无 Traceback**。
+
+### 3. 行为层(发一条消息触发)
+
+让主人在 App 里**随便发一句话**给这个容器,然后:
+
+```bash
+grep -n "strip_media" /tmp/gw-restart.log | tail -3
+```
+
+出现 `strip_media: 已剥离 MEDIA 标记` = 新插件已加载。
+若仍是老行为(只有 `deliverable-inject: 删除非白名单 URL` 而无 `strip_media`)= 新代码没进去。
+
+---
+
+## 五、回滚(万一启动失败)
+
+```bash
+# 看失败原因
+tail -50 /tmp/gw-restart.log
+
+# 代码回滚(hook 有备份)
+ls /opt/data/scripts/fmode-deliverable-hook.py.bak* 2>/dev/null
+# 恢复后重新执行第三节的重启步骤
+```
+
+> 插件目录 `/opt/data/plugins/` 和 `/opt/data/scripts/` 都在**持久卷**,不会因重启丢失。
+
+---
+
+## 六、回交内容
+
+1. §一 指纹核对的原始输出
+2. §三 第 1 步的旧 PID、第 2 步的 `EXITED` 确认
+3. §四 三项验证的**原始输出**
+4. 重启完成时间
+
+**不要只回「已重启」。** 判据是 §四.2 的三通道 connected + 无 Traceback。
+
+---
+
+## 七、安全提醒
+
+`/tmp/gw-env.txt`(如果存在)里有明文 `WECOM_SECRET`、`FMODE_API_KEY`、`FMODE_SESSION_TOKEN`、dashboard 密码。
+**不要把该文件内容复制进日志、聊天记录或提交。**
+
+> 本项目已裁决「密钥允许明文存放、集中存放即可」,但没必要扩散到日志里。

+ 32 - 0
src/app/core/chat.service.spec.ts

@@ -56,6 +56,8 @@ describe('ChatService', () => {
     fetchAgents: jasmine.createSpy('fetchAgents'),
     uploadFile: jasmine.createSpy('uploadFile'),
     requestUploadUrl: jasmine.createSpy('requestUploadUrl'),
+    /* 文件台账登记(2026-11-13:右栏按 threadRoot 查台账,需断言) */
+    registerFile: jasmine.createSpy('registerFile').and.returnValue(Promise.resolve({ id: 'f1' })),
   };
 
   /** 桩:LiveService */
@@ -1024,6 +1026,36 @@ describe('ChatService', () => {
       expect(mxStub.sendFile).not.toHaveBeenCalled();
     });
 
+    /*
+     * 2026-11-13 用户反馈「右栏文件里看不到我发的文件」。
+     *
+     * 根因:登记语句包在 `if (sid && ...)` 里 —— 进入容器但还没建会话时发附件,
+     * 上传与发送都成功,但台账没登记,`listBySession` 查不到 → 右栏为空。
+     * 修法:`sid` 为空时先 `ensureSessionForSend()` 兜底,仍要登记。
+     */
+    it('🔴 无会话时发附件也必须登记台账(右栏才看得到)', async () => {
+      realMode();
+      await svc.open('agent-node-lj-test');
+      apiStub.uploadFile.and.returnValue(
+        Promise.resolve({ key: 'k', publicUrl: 'https://s3.fmode.cn/k' }),
+      );
+      apiStub.registerFile.calls.reset();
+      /* 模拟「进入容器但未建会话」:清空会话指针 */
+      (svc as unknown as { _sessionId: { set: (v: null) => void } })._sessionId.set(null);
+
+      svc.stageFiles([file()]);
+      await svc.sendText('附上');
+
+      const calls = apiStub.registerFile.calls.allArgs();
+      const mine = calls.filter((c: unknown[]) => (c[0] as { uploadedBy?: string }).uploadedBy === 'user');
+      expect(mine.length)
+        .withContext('用户附件必须登记台账,且 threadRoot 不能为空')
+        .toBeGreaterThan(0);
+      expect((mine[0][0] as { threadRoot?: string }).threadRoot)
+        .withContext('threadRoot 必须有值,否则 listBySession 查不到')
+        .toBeTruthy();
+    });
+
     it('上传失败 → 记录错误且不上传中,附件保留待重试', async () => {
       realMode();
       await svc.open('agent-node-lj-test');

+ 15 - 3
src/app/core/chat.service.ts

@@ -2465,11 +2465,22 @@ export class ChatService {
            * 登记失败不阻断发送(文件已经在云盘上了,只是台账缺一条),
            * 但要如实提示 —— 不能让用户以为文件没上去。
            */
-          if (sid && !registered) {
+          /*
+           * 🔴 `sid` 为空时**不能跳过登记**(2026-11-13 修复)。
+           *
+           * 原写法 `if (sid && !registered)`:进入容器但还没建会话时发附件,
+           * 上传与发送都正常、气泡也显示,但台账里没有这条 ——
+           * 右栏「文件」页签查不到(`listBySession` 只按 threadRoot 查),
+           * 刷新后尤其明显,用户会以为文件丢了。
+           *
+           * 修法:先确保会话存在(与 `sendReal` 同一兜底),再登记。
+           */
+          const ledgerRoot = sid ?? this.ensureSessionForSend()?.id;
+          if (ledgerRoot && !registered) {
             try {
               this.setFileProgress(pending[i].id, { phase: 'registering', pct: 100 });
               await this.api.registerFile({
-                threadRoot: sid,
+                threadRoot: ledgerRoot,
                 agentId: this._agentId() ?? undefined,
                 roomId: this.roomId,
                 name: file.name,
@@ -2501,7 +2512,8 @@ export class ChatService {
             file,
             cloudUrl,
             { name: file.name, size: file.size, mimeType },
-            sid ?? undefined,
+            /* 🔴 与台账登记用**同一个根**,否则容器回的归属和台账对不上(2026-11-13) */
+            ledgerRoot ?? undefined,
           );
           this.setFileProgress(pending[i].id, { phase: 'done', pct: 100 });
           /*