Преглед изворни кода

docs: 台账归属定论 —— App 补登记 vs 容器登记是两套 user 桶

右栏「成果」里的台账记录由 App 侧 agent-file-binding 用用户 token
补登记(uploadedBy:'agent' + roomId),不是容器自己登记的。

这解释了「容器 listByUrl 查 0 条」与「App 里看得到」并存:
云函数按 token 解析出的 user 硬过滤,容器 token 与用户 token
落在不同桶里。容器侧登记因此对 App 无实际作用,也无需修复。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct пре 18 часа
родитељ
комит
ce8edd6f48

+ 101 - 0
docs/03-interface/台账归属定论-20261114.md

@@ -0,0 +1,101 @@
+# 台账归属:为什么 App 看得到、容器自己查不到(2026-11-14 定论)
+
+## 一句话结论
+
+**右栏「成果」里的台账记录,是 App 用自己的 user token 补登记的,不是容器登记的。**
+
+两条路互不相通,所以「容器用自己 token 查 0 条」和「App 里看得到」同时成立,**不是矛盾,是两套 user 桶**。
+
+---
+
+## 证据链
+
+### 1. 云函数按 `user` 硬过滤
+
+`cloud/functions/fmode-file-hub.js`:
+
+```js
+// _getUser(t):token → Parse.Session → user
+q.equalTo('user', user);      // 查询侧(listBySession / listByUrl)
+obj.set('user', user);        // 写入侧(registerFile)
+```
+
+`user` 由调用方 token 决定。**容器用容器 token 写、App 用用户 token 读**,天然对不上。
+
+### 2. App 有一条补偿登记链路
+
+`src/app/core/agent-file-binding.service.ts:142`:
+
+```ts
+await this.api.registerFile({
+  threadRoot: sessionId,
+  roomId,
+  name: this.fileNameOf(url),
+  url,
+  mimeType: this.mimeOf(url),
+  uploadedBy: 'agent',
+});
+```
+
+- 用**用户 token**(`ParseApi.callFunction` 带的是平台 sessionToken)
+- `roomId` 取当前会话房间 → 归属正确
+- 5 次退避重试(30s × 次数,封顶 5 分钟)
+- 失败进 `errors()`,桌面右栏显示「成果未记入本会话」
+
+### 3. 触发条件
+
+`desktop/pages/chat/chat.ts` 的 effect 里对 `messages()` 里 `sender === 'agent'` 的
+s3 直链调用 `bindPending(roomId, sessionId, urls)`,只处理 `isAgentOutputUrl()`
+(https + CLOUD_HOSTS + `/deliverables/` 前缀)通过的链接。
+
+### 4. 实测对照
+
+| 查询方 | `listByUrl` / `listBySession` |
+|---|---|
+| kangshifu 容器自己(容器 token) | 8 条(它自己的桶) |
+| App(用户 token,ksftest01) | 0 条(该账号下确实没有) |
+| App 右栏(用户 token,用户本人) | **有** —— 因为补登记写的就是这个桶 |
+
+---
+
+## 对「容器侧台账」的判断
+
+**kangshifu 的容器侧登记(第 4 项)在功能上不需要修**,原因:
+
+1. App 已经有补偿链路,容器漏登记不会导致用户看不到成果
+2. 容器登记到自己的 user 桶,对任何用户都查不到 —— 除了占用存储,没有收益
+3. 用户裁决(2026-11-14):「查不到是正常的,你就当现在的台账是成果了的」
+
+**但有一个前提必须守住**:补偿链路要求
+- 链接能通过 `isAgentOutputUrl()`(即必须是 s3 直链 + `/deliverables/` 前缀)
+- 当前会话能拿到 `roomId`
+
+只要这两条成立,容器登记与否都不影响 App。
+
+---
+
+## 遗留风险(未处理,如实记录)
+
+| 风险 | 说明 |
+|---|---|
+| 补偿链路失败时用户会看不到成果 | 5 次重试耗尽后进 `errors`,右栏有提示但**没有自动恢复** |
+| `roomId` 拿不到的场景 | 建房失败降级到共享房间时,归属会错。当前建房是硬失败,理论上不会发生 |
+| 容器登记的 8 条记录 | 仍留在 kangshifu 自己的 user 桶,属无效数据,未清理(无影响) |
+
+---
+
+## 如果将来要让「容器自己登记」真的生效
+
+需要二选一:
+
+**方案 A:容器用用户 token 登记**
+容器必须能拿到**当前用户在平台上的 sessionToken**。但容器是多用户共享的,
+一个容器同时服务多个用户 → **无法知道这条消息属于哪个用户**。
+除非 App 在每条消息里带上自己的 token(等于把凭据交给容器,不可接受)。
+
+**方案 B:云函数接受「服务端代登记」**
+新增一个内部 action,允许容器用**容器身份**登记,云函数侧按 `roomId` 反查
+归属用户(`FmodeChatThread.roomId → user`)。这样容器只提供 roomId,user 由服务端补。
+
+> **当前不做**:方案 B 要改云函数并重新部署,而 App 侧补偿已经覆盖了需求。
+> 除非出现「补偿链路失效」的实际故障,否则不启动。

+ 249 - 0
docs/03-interface/指令-kangshifu-落地5和7.md

@@ -0,0 +1,249 @@
+# 现在开始**真正动手**做 5 和 7(不是读文件)
+
+上一轮你只做了只读回读,把现有文件读出来贴给我了。**这一轮要改文件、重启、验证。**
+
+---
+
+## 🔴 前置纪律(这次必须遵守)
+
+1. **每步都回读**:写完立刻 `cat` 出来贴原文,不要只说「已完成」。
+2. **不要贴大段无关日志**,每条证据 ≤ 15 行。
+3. 完成后**只回一份结构化报告**,格式见最后一节。
+
+---
+
+# 第 7 项:网关 `finish` 防复发(**先做这个**)
+
+## 问题
+
+网关 `finish` 脚本按退出码决定是否重启。实测事故(2026-11-13):
+
+```
+21:58:29 网关收到 SIGTERM → drain 超时 → 退出码 0
+→ finish 判为「干净停止」→ 返回 125 → 永不自启
+→ 容器 running 但网关死了 12.5 分钟
+```
+
+`drain 超时` 被 s6 当成「正常退出」,但它其实是**异常**。
+
+## 要做的
+
+### 7.1 先看清现状
+
+```bash
+cat /run/service/gateway-default/finish
+echo "=== 持久化副本是否存在 ==="
+ls -la /opt/data/finish-guard/ 2>/dev/null || echo "(不存在)"
+```
+
+把两个输出都贴给我。
+
+### 7.2 写持久化 guard(放 `/opt/data`,重启不丢)
+
+`/opt/hermes` 是 overlay,容器重建就没了。所以 guard 必须放 `/opt/data`。
+
+新建 `/opt/data/finish-guard/finish`:
+
+```sh
+#!/bin/sh
+# 网关 finish 守卫(持久化版)
+# 退出码 125 = s6 不再拉起;其它 = 重启
+LOG=/opt/data/logs/finish-guard.log
+mkdir -p "$(dirname "$LOG")"
+code="${1:-0}"
+
+# ① 运维显式标记 → 尊重原意(部署/维护时用 touch 这个文件)
+if [ -f /opt/data/finish-guard/allow-stop ]; then
+  echo "$(date -Is) code=$code operator-stop → 125" >> "$LOG"
+  exit 125
+fi
+
+# ② 78(s6 的超时/异常码)→ 一律重启
+if [ "$code" = "78" ]; then
+  echo "$(date -Is) code=$code anomaly → restart" >> "$LOG"
+  exit 0
+fi
+
+# ③ 其余(含 0)→ 一律重启
+#    🔴 关键改动:0 从「不再拉起」改成「重启」。
+#    因为 drain 超时也是 0,无法与真正干净退出区分 —— 宁可多拉一次。
+echo "$(date -Is) code=$code → restart" >> "$LOG"
+exit 0
+```
+
+### 7.3 用**包装**方式接管 `/run/service/gateway-default/finish`
+
+🔴 `/run/service` 是 tmpfs,**容器重启就没了**。所以:
+
+1. 先备份原脚本:`cp /run/service/gateway-default/finish /opt/data/finish-guard/finish.orig.sh`
+2. 再写一个**启动时自动装回**的脚本 `/opt/data/finish-guard/install.sh`:
+
+```sh
+#!/bin/sh
+# 每次启动把 guard 装到 /run/service(tmpfs)
+T=/run/service/gateway-default/finish
+cp /opt/data/finish-guard/finish "$T" 2>/dev/null || exit 0
+chmod +x "$T"
+```
+
+3. 找一个**已存在的、开机必跑的入口**调用它(不要新建 s6 服务,容易配错)。先查你有什么:
+
+```bash
+ls /opt/data/*.sh /opt/data/scripts/*.sh 2>/dev/null
+crontab -l 2>/dev/null
+ls /etc/cron.d/ 2>/dev/null
+```
+
+把输出贴给我。**不要自己拍板选哪个入口**——先给我看,我告诉你挂哪。
+
+### 7.4 验证(必须真跑,不要贴代码就说完事)
+
+```bash
+# 手动装一次
+sh /opt/data/finish-guard/install.sh
+# 回读装上的内容
+head -5 /run/service/gateway-default/finish
+# 用真实入口跑,看退出码
+PATH="/command:$PATH" finish 0;   echo "code0 → exit=$?"
+PATH="/command:$PATH" finish 78;  echo "code78 → exit=$?"
+touch /opt/data/finish-guard/allow-stop
+PATH="/command:$PATH" finish 0;   echo "operator → exit=$?"
+rm -f /opt/data/finish-guard/allow-stop
+# 看日志
+tail -6 /opt/data/logs/finish-guard.log
+```
+
+**期望**:`code0 → exit=0`、`code78 → exit=0`、`operator → exit=125`。
+
+---
+
+# 第 5 项:延迟清理 + GC
+
+## 5.1 上传成功后**不立刻删源文件**(加延迟)
+
+现在的行为:上传成功 → 回读 md5 一致 → 立刻删本地源文件。
+
+问题:删早了,出问题无从复查;删晚了,磁盘涨。
+
+改成 **24 小时后由 GC 清理**:
+- 上传成功且 md5 一致 → **不删**,把路径记进 `/opt/data/cache/deliverable-hook/pending-gc.json`
+- 记录格式:`{"path": "...", "url": "...", "ts": 1731600000000}`
+
+## 5.2 加 GC 脚本
+
+`/opt/data/scripts/fmode-deliverable-gc.py`:
+
+```python
+#!/usr/bin/env python3
+"""清理 24 小时前已上云并登记成功的本地源文件。"""
+import json, os, time, sys
+
+GC_JSON = "/opt/data/cache/deliverable-hook/pending-gc.json"
+LOG = "/opt/data/logs/deliverable-gc.log"
+TTL = 24 * 3600 * 1000
+
+def log(msg):
+    os.makedirs(os.path.dirname(LOG), exist_ok=True)
+    with open(LOG, "a") as f:
+        f.write("%s %s\n" % (time.strftime("%Y-%m-%dT%H:%M:%S%z"), msg))
+
+def main():
+    try:
+        with open(GC_JSON) as f:
+            items = json.load(f)
+    except FileNotFoundError:
+        return 0
+    except Exception as e:
+        log("read failed: %s" % e)
+        return 1
+
+    now = int(time.time() * 1000)
+    keep, removed, failed = [], 0, 0
+    for it in items:
+        age = now - int(it.get("ts", 0))
+        if age < TTL:
+            keep.append(it)
+            continue
+        p = it.get("path", "")
+        try:
+            if p and os.path.isfile(p):
+                os.remove(p)
+                removed += 1
+        except Exception as e:
+            failed += 1
+            log("rm failed %s: %s" % (p, e))
+            keep.append(it)   # 删不掉就留着下次再试
+    with open(GC_JSON, "w") as f:
+        json.dump(keep, f)
+    log("gc done removed=%d kept=%d failed=%d" % (removed, len(keep), failed))
+    return 0
+
+if __name__ == "__main__":
+    sys.exit(main())
+```
+
+## 5.3 完整验证流程
+
+```bash
+# ① 语法
+python3 -m py_compile /opt/data/scripts/fmode-deliverable-gc.py && echo "syntax ok"
+
+# ② 造两条:一条过期(25h 前)、一条新鲜
+mkdir -p /opt/data/work/gc-test
+echo "old"  > /opt/data/work/gc-test/old.txt
+echo "new"  > /opt/data/work/gc-test/new.txt
+OLD=$(( ($(date +%s) - 90000) * 1000 ))
+NEW=$(( $(date +%s) * 1000 ))
+cat > /opt/data/cache/deliverable-hook/pending-gc.json <<JSON
+[
+ {"path":"/opt/data/work/gc-test/old.txt","url":"https://s3.fmode.cn/x/old.txt","ts":$OLD},
+ {"path":"/opt/data/work/gc-test/new.txt","url":"https://s3.fmode.cn/x/new.txt","ts":$NEW}
+]
+JSON
+
+# ③ 跑 GC
+python3 /opt/data/scripts/fmode-deliverable-gc.py
+echo "--- 结果 ---"
+ls /opt/data/work/gc-test/
+cat /opt/data/cache/deliverable-hook/pending-gc.json
+tail -3 /opt/data/logs/deliverable-gc.log
+```
+
+**期望**:只剩 `new.txt`,json 里只剩 `new` 那条。
+
+## 5.4 挂定时任务
+
+先**不要**直接写 crontab。把 `crontab -l` 的输出贴给我,我告诉你加哪一行。
+
+---
+
+# 回给我的格式(严格按这个)
+
+```
+## 7.1 现状
+<两个输出原文>
+
+## 7.2-7.3 已写文件
+<cat 出来的实际内容,不要贴你输入的 heredoc>
+
+## 7.4 验证
+code0 → exit=?
+code78 → exit=?
+operator → exit=?
+<finish-guard.log 最后 6 行>
+
+## 5.3 验证
+ls 输出=?
+pending-gc.json 内容=?
+gc.log 最后 3 行=?
+
+## 5.4 待定
+crontab -l 输出=?
+/opt/data 下可用的开机入口=?
+
+## 结论
+7 项:完成 / 未完成(原因)
+5 项:完成 / 未完成(原因)
+```
+
+🔴 **不许说「已完成」而不给回读**。上次你说「台账登记成功 id=5fxeMR18g2」,我查 `listByUrl` 是 0 条。