Files
Cyrene-For-Android/devtools/fake-backend/README.md
T
AskaEth 86a2217858 feat(devtools): 新增可编程假后端(devtools/fake-backend),用来造「特定服务端行为」的端到端场景
为什么要它
客户端有三类行为只在特定服务端行为下才验得到,而真后端不可编排、造场景的代价高到不可做:
1. after 正向增量补洞:要造「WS 握手只回放最近 50 条、库里却有 120 条」这个洞,用真后端得灌 51 条消息、
   每条跑一次模型回合。假后端可以精确、免费、可复现地造出来(--history-replay N)。
2. 打断帧:客户端在「设置=打断 + 会话正忙」时会发 interrupt{scope:"current"}。要验它真的发出去了,
   就得看上行帧——假后端把每条上行帧原样记进 out/upstream.jsonl,这是唯一的第一现场。
3. stream_chunk 回退路径:真后端可能永远不走那条路,假后端可以用 --stream-chunk 指定走。

加了什么
- devtools/fake-backend/fake_backend.py:纯 Python 3 标准库(无第三方依赖)。实现了客户端启动到渲染
  用得到的那一段 REST(login/refresh/logout、sessions 列表与增删改、messages 分页与搜索、read、
  messages 编辑删除、meta/enums、personas、chat)加一个手写 WebSocket(GET /ws/chat?token=…,
  握手后立刻发 history 帧)。鉴权头 X-API-Version / Authorization 一律不校验,但会记进 out/http.jsonl
  (Authorization 与 ?token 只留 sha256 指纹)。
- 控制端点 /__control/**(不碰 /api/v1/**):灌消息(不调模型)、下发 session_busy/idle/interrupted、
  触发一轮回复、pending_full、定向 error、任意帧、踢掉客户端 WS、404 session_not_found、复位、看状态。
  目的是让「造场景」与「看结果」都不需要人手点 UI。
- devtools/fake-backend/ws_probe.py:最小 WebSocket 探针(标准库手写握手 + 帧收发 + ping),
  用来证明握手返回 101 / 收到 history 帧,并往连接上发任意上行帧对账。
- devtools/fake-backend/README.md:起法(Windows + Git Bash)、客户端怎么指过来(adb reverse +
  http://127.0.0.1:18080)、每个场景的可复制命令序列、端点速查、形状出处表、以及「它不做什么」。

边界(README 里也写明了)
不校验鉴权、不落盘成持久数据、不调模型、不是产品的一部分、不实现契约全集(admin 面、附件上传、
observability 推送等一律 404),并且不要拿它当后端行为的事实来源——所有响应形状都是照客户端代码
读出来的(README 第 9 节逐条列出文件:行号),不代表真后端真的这么回。
默认端口 18080,刻意避开真后端的 10942。

形状来源(举几个关键例子):信封 ApiEnvelope.kt:8;鉴权头 ApiClientFactory.kt:115-118;
WS 地址 ChatSocket.kt:287-295;SessionSummaryDto 与排序 SessionDto.kt:16-50 加线上夹具
SessionDtoRealPayloadTest.kt:36-38;after 升序 / before 倒序 / q 与 after 互斥 SessionsApi.kt:71-101;
limit 默认 50 上限 200 MessageRepository.kt:149-150;枚举表逐字取自 MetaDtoRealPayloadTest.kt:24;
404 session_not_found ChatViewModel.kt:1802-1808;stream_chunk 回退 WsEvent.kt:205-228。

验证(本机实跑,输出见提交后的报告)
curl 打通 login/sessions/messages(limit/before/after/q 与三个 400 互斥面)、meta/enums、personas;
ws_probe 握手拿到 101 与匹配的 Sec-WebSocket-Accept,并收到 history 帧;灌 120 条后握手只回放最近 50 条
(msg-000071..msg-000120),用 after 游标正向走两页正好补回 msg-000021..msg-000120、第三页为空;
--stream-chunk 下下发的是 stream_chunk×3 + stream_end(没有 message.* 帧);打断帧原样落进
out/upstream.jsonl;ping/pong 与 HTTP keep-alive 均正常。
2026-10-05 08:01:48 +08:00

263 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cyrene 假后端(`devtools/fake-backend/`)
一个**可编程的假后端**:用来把「需要特定服务端行为才验得到」的客户端行为变成可复现的测试。
纯 Python 3 标准库,零第三方依赖,不需要 Gradle、不需要模拟器里的真后端。
它**不是**产品的一部分,也**不是**后端行为的权威来源 —— 所有响应形状都是从**客户端代码**里读出来的
(出处逐条列在下面「形状出处」一节)。它照我们读出来的形状回话,不代表真后端真的这么回。
**它不做什么**(这条比它能做什么更重要):
- **不校验任何鉴权**。`Authorization` / `X-API-Version` 照收不误、一律放行;收收到什么头都会记进 `out/http.jsonl`。
- **不落盘**成任何持久数据(只有 `out/*.jsonl` 的流水日志)。进程一停,消息全没了。
- **不调模型**。回复正文是你在控制端点里给的字符串按字符切片切出来的,没有任何生成。
- **不实现契约的全集**。只实现「启动到渲染」要用到的那一段(见端点表),其余一律 404。
- **不要拿它当后端行为的事实来源**。要判断真后端怎么回,去看 `Cyrene_v2/Docs/api-client.md` 与真后端本身;
这个工具的价值是「造场景 + 记录客户端发了什么」,不是「回答后端是什么」。
---
## 1. 起服务(Windows + Git Bash)
```bash
cd /d/Project/Code/Uni/Cyrene-Android-FB/devtools/fake-backend
python fake_backend.py --port 18080
```
启动后会打印监听地址、回放条数、回复形状、记录目录,然后原地等连接(Ctrl-C 退出)。
> 端口随便挑,**别用 10942** —— 那是真后端的端口,占用或干扰它会打断另一个会话的联调。
常用开关:
| 开关 | 默认 | 作用 |
|---|---|---|
| `--port N` | `18080` | 监听端口 |
| `--host H` | `127.0.0.1` | 监听地址 |
| `--history-replay N` | `50` | WS 握手 `history` 帧**只**回放最近 N 条(比库里的条数小就是那个洞) |
| `--stream-chunk` | 关 | 回复走旧形状 `stream_chunk` + `stream_end`(验客户端的回退分支) |
| `--auto-reply` | 关 | 收到上行 `chat` 帧后自动回一轮(默认关,保持确定性) |
| `--interrupt-ack` | 关 | 收到上行 `interrupt` 帧后回 `session_interrupted{mode:"idle",dropped:0}` |
| `--out-dir DIR` | `<脚本目录>/out` | 记录目录 |
## 2. 客户端怎么指过来
```bash
adb reverse tcp:18080 tcp:18080
```
然后在 App 的登录页(「服务器地址」一栏,`LoginViewModel.kt:57`)填:
```
http://127.0.0.1:18080
```
登录随便填(用户名 / 密码都不校验)。`adb reverse` 把模拟器内的 `127.0.0.1:18080` 转到 PC 上的假后端,
所以 REST 与 `ws://…/ws/chat?token=…` 走的是同一个地址,App 侧不用做任何特殊配置。
想从干净状态重来:设置里退出登录 / 清 App 数据,再打一次 `adb reverse`(它只跟着设备会话活)。
## 3. 场景一:`after` 正向补洞
**要验什么**:WS 重连只回放最近 50 条,断线期间漏掉的那一段由 `GET /messages?after=` 补回来
(客户端 `ChatViewModel.catchUp` → `MessageRepository.loadAfter`,契约 §1 行 6)。
用真后端要灌 51 条消息、每条跑一次模型回合;这里灌 120 条是**免费且瞬时**的。
```bash
# 1) 库里灌 120 条(不调模型,直接进内存)
curl -s -X POST 'http://127.0.0.1:18080/__control/messages?session=web&count=120'
# 2) 看握手到底回放了什么:只剩最近 50 条(msg-000071..msg-000120),前 70 条是洞
python ws_probe.py --port 18080 --listen 1
# 3) 断线前「本端最后看到的那条」——它才是客户端 gapCursor 的来源
curl -s 'http://127.0.0.1:18080/__control/message?session=web&index=20'
# 4) 用它的 createdAt 作 after 游标补洞:第 1 页给回来的正好是那段洞
curl -s 'http://127.0.0.1:18080/api/v1/sessions/web/messages?limit=50&after=<上一步的 createdAt>'
# 5) 用「本页最后一条的 createdAt」继续推进,直到返回空数组(客户端就是这么推进的)
```
**在 App 上跑同一条路**(真正的端到端):
```bash
# App 已经连着假后端时,把它踢下线;客户端会重连,重连触发 catchUp
curl -s -X POST 'http://127.0.0.1:18080/__control/ws-close?code=1001'
# 客户端到底发了哪些 after 请求?看请求流水:
grep -o '"after=[0-9]*"' devtools/fake-backend/out/http.jsonl
```
注意客户端只在「曾经 Connected 过、然后断开、再连上」时才做这次正向补拉
(`ChatViewModel.kt:634-646`),冷启动走的是首屏 REST,不动 `after`。
## 4. 场景二:打断帧(`interrupt`)
**要验什么**:设置档位 = 「打断」且会话**正忙**时,客户端在发消息之前会先发
`interrupt{scope:"current"}`(`InterruptScope.kt:27`、`ChatViewModel.kt:1058-1063`)。
真后端不给你看上行帧,这里**看得到**,而且这是唯一的第一现场。
```bash
# 1) 起服务(回执可选:开了之后打断会立刻回到 idle,方便看界面状态)
python fake_backend.py --port 18080 --interrupt-ack
# 2) 让主会话忙起来 —— 客户端收到 session_busy 才会认为「正忙」
curl -s -X POST 'http://127.0.0.1:18080/__control/busy?session=web&mode=thinking'
# 3) App 里:设置 → 打断档;然后在聊天页再发一条消息
# 4) 证据(每条上行帧一行,含时间戳)
tail -n 2 out/upstream.jsonl
# 也可以用一个不依赖 App 的等价动作,证明记录链路本身是通的:
python ws_probe.py --port 18080 --listen 2 \
--send '{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}'
```
## 5. 场景三:`stream_chunk` 回退路径
**要验什么**:契约 §3.1 的「非流式回退路径」——整段正文只在 `stream_chunk` + `stream_end` 里,
没有 `message.start/delta/end`。真后端可能永远不走那条路,这里可以**指定**走。
```bash
# 1) 起服务,指定回退形状
python fake_backend.py --port 18080 --stream-chunk
# 2) 触发一轮回复(不需要在 App 里打字;text 用 URL 编码传中文)
curl -s -X POST 'http://127.0.0.1:18080/__control/reply?session=web&text=hello&chunks=3'
# 3) 想在连接上原样看到这几帧:
python ws_probe.py --port 18080 --listen 4
```
想临时切回结构化形状,不改启动参数也行:`…/__control/reply?session=web&text=hi&frame=structured`。
## 6. 其它可编排场景(都试过)
```bash
B=http://127.0.0.1:18080
# 会话在服务端没了 → 404 session_not_found(客户端据此置 sessionGone,ChatViewModel.kt:1808)
curl -s -X POST "$B/__control/forget?session=web:5eed0000000000000000000000000001"
curl -s "$B/api/v1/sessions/web:5eed0000000000000000000000000001/messages?limit=1"
# 队列满:广播 pending_full(可选带 clientMsgId / messageId,两个键都能缺席)
curl -s -X POST "$B/__control/pending-full?session=web&mode=thinking&max=5&clientMsgId=local-zz"
# 定向错误帧(与 REST 的 409 chat_busy 同码)
curl -s -X POST "$B/__control/error?session=web&code=chat_busy&clientMsgId=local-zz"
# 忙碌 / 空闲 / 已打断
curl -s -X POST "$B/__control/busy?session=web&mode=executing"
curl -s -X POST "$B/__control/idle?session=web&mode=idle"
curl -s -X POST "$B/__control/interrupted?session=web&mode=idle&dropped=0"
# 任意下行帧(整帧原样广播,形状不对时客户端会安全忽略 —— 正好用来验「坏帧不崩」)
curl -s -X POST "$B/__control/frame?json=%7B%22type%22%3A%22session_idle%22%2C%22sessionId%22%3A%22web%22%2C%22data%22%3A%7B%22mode%22%3A%22idle%22%7D%7D"
# 编辑 / 删除单条消息(REST 回执 + 广播帧都会来)
curl -s -X PATCH "$B/api/v1/messages/msg-000001" -H 'Content-Type: application/json' -d '{"content":"edited"}'
curl -s -X DELETE "$B/api/v1/messages/msg-000002"
# 已读游标(推进后 unreadCount 归零,并广播 session_read)
curl -s -X POST "$B/api/v1/sessions/web/read" -H 'Content-Type: application/json' -d '{}'
# 会话列表 / 改名 / 置顶 / 新建 / 清空 / 复位
curl -s "$B/api/v1/sessions"
curl -s -X PATCH "$B/api/v1/sessions/web" -H 'Content-Type: application/json' --data-binary @body.json
curl -s -X POST "$B/__control/create?session=web:mine&type=sub&pinned=1"
curl -s -X POST "$B/__control/clear?session=web"
curl -s -X POST "$B/__control/reset" # 会话/消息/上行帧记录回到种子状态(out/*.jsonl 不动)
# 看状态 / 看上行帧 / 看 HTTP 请求流水
curl -s "$B/__control/state"
curl -s "$B/__control/upstream?limit=20"
curl -s "$B/__control/requests?limit=20"
```
> 控制端点响应里的 `delivered` = **这一刻**连着 WS 的客户端数。它是**广播**(没有离线队列、没有补发),
> 所以「App 还没连上 / 已经断开」时是 `0` 而帧不会留到下次。想让 App 收到,先确认 `GET /__control/state`
> 里的 `wsClients` 不为 0。
> Git Bash 的坑:命令行里的中文会被转成本地代码页(GBK)再交给子进程,服务端按 UTF-8 解 ⇒ 400 `invalid json body`。
> 所以**带中文的请求体写进文件再用 `--data-binary @file`**,控制端点的参数用 URL 编码(`%E4%BD%A0%E5%A5%BD`)。
## 7. 端点速查
`/api/v1/**`(一律包信封:成功 `{data}` / 失败 `{error:{code,message}}`,`ApiEnvelope.kt:8`):
| 端点 | 备注 |
|---|---|
| `POST /api/v1/auth/login` `/refresh` `/logout` | 回假 token(`role:"owner"`,`expiresAt` 是 **Unix 秒**) |
| `GET /api/v1/sessions` | 排序照真契约:`pinned DESC, type='main' DESC, created_at ASC`;`unreadCount` 按已读游标算 |
| `POST /api/v1/sessions` | 可绑 `personaId`(未知角色 → 400);支持**完全不带 body** |
| `PATCH /api/v1/sessions/{id}` | 局部响应;`{}` → 400;`personaId` → 400;title trim 后空 → 400;>80 字 → 400 |
| `DELETE /api/v1/sessions/{id}` | `web` → 403 |
| `GET /api/v1/sessions/{id}/messages` | 支持 `limit`(默认 50 / 上限 200)/ `before`(倒序)/ `after`(**升序**)/ `q` / `platform`;`after×before`、`after×q` → 400 |
| `POST /api/v1/sessions/{id}/read` | 游标只前进;成功后广播 `session_read` |
| `GET /api/v1/meta/enums` | 载荷逐字取自客户端仓库的线上夹具 |
| `GET /api/v1/personas` | 用户侧角色目录(不含 admin 专属 `thinking`) |
| `PATCH` / `DELETE /api/v1/messages/{id}` | 编辑 / 删除单条,并广播 `message_edited` / `message_deleted` |
| `POST /api/v1/chat` | **额外**实现的 REST 写路径(长文 / 图片 / 附件走这条),回 202 + `ChatAckDto` 形状 |
| `GET /ws/chat?token=…` | 手写 WebSocket;握手后立刻发 `history`(回放条数可配) |
`/__control/**`(同样包信封,但载荷是本工具自己的状态,不是产品契约):
| 端点 | 作用 |
|---|---|
| `POST /__control/messages?session=&count=&role=&text=&prefix=&platform=` | 免费灌 N 条(`text` 支持 `{n}` 占位) |
| `GET /__control/message?session=&index=` | 取第 n 条(1 基),脚本里用来**精确**拿游标 |
| `POST /__control/busy` / `idle` / `interrupted` | 下发 `session_busy` / `session_idle` / `session_interrupted` |
| `POST /__control/reply?session=&text=&chunks=&frame=&persist=` | 下发一轮助手回复(`frame=default\|chunk\|structured`) |
| `POST /__control/pending-full` / `error` / `frame` | 队列满、定向错误帧、任意帧 |
| `POST /__control/ws-close?code=&reason=` | 踢掉所有客户端 WS(用来触发客户端的重连 / 补洞路径) |
| `POST /__control/forget` / `create` / `clear` / `reset` | 会话级造场景 |
| `GET /__control/state` / `upstream` / `requests` | 当前状态、上行帧、HTTP 请求流水 |
## 8. 记录文件(`out/`,每次启动追加,删掉即清空)
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers}`。
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
## 9. 形状出处(它照谁回话)
| 实现 | 依据 |
|---|---|
| 信封 `{data}` / `{error:{code,message}}` | `app/.../data/api/ApiEnvelope.kt:8,18` |
| 鉴权头 `X-API-Version: 1` + `Authorization: Bearer …` | `app/.../data/api/ApiClientFactory.kt:115-118`(**只记录,不校验**) |
| WS 地址 `GET /ws/chat?token=<jwt>` | `app/.../data/ws/ChatSocket.kt:287-295` |
| `LoginResponse`(`expiresAt` 是 Unix **秒**、`refreshToken` 可缺) | `app/.../data/api/dto/LoginDto.kt:12-19` |
| `SessionSummaryDto`(`title` / `lastMessage` / `personaId` 可整键缺席) | `app/.../data/api/dto/SessionDto.kt:16-50` |
| 会话排序 `pinned DESC, main DESC, created_at ASC` | `SessionDto.kt:8-9` + 线上夹具 `SessionDtoRealPayloadTest.kt:36-38` |
| `HistoryMessageDto` | `app/.../data/api/dto/MessageDto.kt:13-46` |
| `after` 升序 / `before` 倒序 / `q` 与 `after` 互斥 | `app/.../data/api/SessionsApi.kt:71-101` + `MessageRepository.kt:79-101` |
| limit 默认 50 / 上限 200 | `app/.../data/repository/MessageRepository.kt:149-150` |
| `EnumTableDto` 的 6/5/9 张表与 `unknown:"display-raw"` | 线上夹具 `MetaDtoRealPayloadTest.kt:24`(逐字) |
| `UserPersonaDto`(用户侧不含 `thinking`) | `PersonaDto.kt:16-26` + `PersonaDtoDecodingTest.kt:32` |
| `404 {code:"session_not_found"}` | `ChatViewModel.kt:1802-1808` |
| `session_read` 载荷只有 `readAt` | `WsEvent.kt:338-356` |
| `message_edited` 的 `data` **就是**消息本体(没有 `message` 包一层) | `WsEvent.kt:676-680` |
| `message_persisted` / `channel_persisted` 的 `data:{message}` | `WsEvent.kt:671-674` |
| `chat_duplicate` / `pending_full` 的可选键整键缺席 | `WsEvent.kt:291-336, 798-809` |
| `interrupt{scope}` 取值与「只取消在跑回合」 | `WsOutbound.kt:59-78` + `InterruptScope.kt:27` |
| `stream_chunk` + `stream_end` 回退形状 | `WsEvent.kt:205-228` + `ChatViewModel.kt:1451-1472` |
## 10. 已知不确定 / 没做
- **`history` 帧壳**:我发了 `{type, sessionId:"web", seq, data:{messages}}`。契约说 v0.36 起每个结构化流帧的帧壳
都带 `sessionId`(`WsEvent.kt:21-27`),但客户端**对 `history` 不读帧壳**(`ChatViewModel.kt:1410-1412`
明确绕过 `isForSession`,改用每条消息自己的 `sessionId`)。所以这一处就算和真后端不一致,也不影响本工具能验的东西。
- **`platform` 过滤**是「相等即收」的近似,不是契约里那套 LIKE 转义语义。客户端当前所有调用点都传 `null`
(`MessageRepository.kt:42,99`),所以这条路径没被真正走过。
- **`q` 搜索**是 ASCII 大小写不敏感的子串匹配;`%` / `_` / `\` 不做通配(我根本不解析通配符)。
- **不实现**:admin 面全部端点、`documents` / `upload`、`voice_input` 的落库、`mode_switch` 的语义、
`observability_*` 推送、`subagent_ask` / `task_output_query`。上行收到这些帧**只记录、不处置**。因此 App 里
管理员页面、附件上传这类路径在这个假后端上跑不通。
- **`message_persisted` 与流帧的先后顺序**是我按「正文流完再落库」安排的,没有对着真后端核过。
- **不做 TLS**(只有 `http://`),也不支持 WebSocket 扩展 / permessage-deflate。
- 控制端点的响应形状(`{"data":{...}}` 里的键)**是自造的**,不要照它写客户端代码。