Files
AskaEth 4f860aa499 fix(chat): W48 聊天页 VM key 带上账号指纹,修换环境后仍显示上个环境的会话
缺陷:换账号或换服务器后,聊天页仍然摆着上一个环境的消息。成因是 ChatScreen 里那个
ChatViewModel 的 owner 是 Activity 级的 ViewModelStore,而登出只是把 ChatHomeScreen
移出组合、并不清理那个 store(ChatViewModel.onCleared 是空的,全仓也没有 ViewModelStore.clear())。
key 里只有 sessionId 时,换环境再进来 viewModel() 会把上一个环境那个还活着的实例原样取回来,
它继续收着那个环境的帧,屏幕上就是别的服务器的消息。主会话的 id 恒为 web,两次登录必然撞上
同一个 key,所以这不是理论风险。

为什么 VM key 要带账号指纹:指纹回答的是「这份本地状态属于哪个环境」。把它抽成纯函数
accountFingerprint(baseUrl, username),与 SessionPreferences 判断快照作废、
SessionsViewModel 落盘快照用的是同一份口径(SessionsViewModel 原本就地拼的那串改成调用
同一个函数,免得两套判据分叉——分叉的那一侧就是漏出去的那一侧)。同一个环境里切会话,
key 未变,各会话的状态照旧各留一份(这正是「切回来状态还在」依赖的);环境一变(换了服务器
或换了账号),key 就变,取到的一定是新实例。

会话内搜索那个小 VM 是同一类残留:它持有的命中就是消息正文,换环境后在主会话上重开搜索,
面板里就是别的服务器的消息。它的 key 同样带上指纹。

为什么本轮不回收旧 VM:要真正回收掉上一个环境那个实例,得自定义 ViewModelStoreOwner 或直接
清 store,而「切回来状态还在」这条体验正绑在 store 不清这件事上,属于要主人拍板的设计取舍。
本轮只保证换环境后取到的是新 VM,回收这件事不动。

顺带的零成本纠错(只改注释的成因描述,行为一字未动):ChatHomeScreen / ChatScreen /
ChatViewModel 里几处「onSessionGone 一被调用,那个会话的 VM 连同它的状态立刻被销毁」是错的——
变的是 key,被销毁的只是承载横幅的那段组合;VM 实例仍在 Activity 级的 store 里活着并继续收帧。
「横幅必须挂在上一层」那条结论本身是对的,原样保留;改的只是它前面的理由。
同时把 devtools 里那个窄代理的路径写对(此前注释指向一个并不存在的路径)。

开发工具挪出会被 clean 扫掉的位置:app/build/e2e-shots/ws_block_proxy.py 躺在 gitignored 的
app/build/ 里,一次 gradlew clean 就没了。挪进 devtools/ws-block-proxy/(与 fake-backend
同级同风格)并配 README,写清用途、用法(python ws_block_proxy.py 18098 10942,配
adb reverse tcp:10942 tcp:18098)与它最有价值的判据:调试时 Web 前端直连后端、不经过
adb reverse,所以代理日志里的每一行都只可能来自 App。脚本行为未改,也没有硬编码任何凭据。

新增单测 ChatViewModelKeyTest 钉住拼法:同账号同地址 => 同 key;换地址 / 换账号 / 换会话
=> 不同 key;username 缺失时退化成 baseUrl 一段,换地址仍然分得开。

验证:gradlew.bat assembleDebug 与 gradlew.bat testDebugUnitTest,
BUILD SUCCESSFUL;单测 1164 条,failures 0、errors 0、skipped 0。
2026-10-05 22:25:57 +08:00

73 lines
3.4 KiB
Markdown
Raw Permalink 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.
# WS 断、HTTP 通的窄代理(`devtools/ws-block-proxy/`)
一个**只掐 `/ws/chat`、其余 HTTP 一律放行**的窄代理:用来把「WebSocket 断了,但 REST 还通」
这个场景变成可复现、可取证的状态。纯 Python 3 标准库,零第三方依赖。
它**不是**产品的一部分,也**不是**后端行为的权威来源。它只做一件事:把发往 `/ws/chat`
的请求就地回 `503`(`ws blocked by test proxy`),别的请求原样转发给真后端,并把
**每一行的去向**打到 stdout。
它**不做什么**:不鉴权、不改写请求体、不落盘、不重试、不实现任何业务端点。
转发时逐行丢掉 hop-by-hop 头(`Connection` / `Upgrade` / `Transfer-Encoding` 等),
请求体按 `Content-Length` 读全再转发——**只要真后端还听得懂,它就照转**。
---
## 1. 用途
W41 的「三级阶梯」(WS → 健康探测 → REST 回落)只在**WS 连不上**时才会走。真实的断线
不可控也不好复现,用这个代理把 WS 单独掐掉即可:客户端应该表现为「重连中…」(而不是「离线」),
然后按阶梯回落 `POST /api/v1/chat` 把消息发出去。
排查时的用处同样直接:**想知道 App 到底发了什么、发没发出去**,看代理日志就行。
## 2. 用法
```bash
python ws_block_proxy.py <listen_port> <target_port>
```
- `<listen_port>`:代理自己监听的端口(**App 要连的那个口**,见下面的 `adb reverse`)
- `<target_port>`:真后端在本机监听的端口
典型用法(真后端在 10942,代理听 18098):
```bash
# 终端 A:代理听 18098,转发给 127.0.0.1:10942
cd /d/Project/Code/Uni/Cyrene-For-Android/devtools/ws-block-proxy
python ws_block_proxy.py 18098 10942
# 终端 B:把设备上的 10942 引到宿主机的 18098(即代理)
adb reverse tcp:10942 tcp:18098
```
App 侧不用改任何配置:它照旧连 `127.0.0.1:10942`,被 `adb reverse` 送到代理。
日志(stdout)长这样:
```
proxy 18098 -> 127.0.0.1:10942, blocking /ws/chat # 启动
BLOCKED GET /ws/chat?token=... # WS 被掐(App 会退避重试)
PASS GET /api/v1/sessions/web/messages?limit=50 -> 200
PASS POST /api/v1/chat -> 200
ERR GET /api/v1/health -> ConnectionRefusedError(...) # 真后端没起来时
```
收尾:撤掉端口映射(`adb reverse --remove tcp:10942`)、Ctrl-C 停代理。
**用完记得撤**——留着的话别的会话连真后端也会经过这个「掐 WS」的口。
## 3. 关键性质(它最有价值的一条)
**调试期间 Web 前端是直连后端(`127.0.0.1:10942`)的,不经过 `adb reverse`。**
所以设备侧的口只有 App 会用,**代理日志里的每一行都只可能来自 App** ——
不存在「这条请求到底是谁发的」这个问题。这是分辨「App 到底发了什么」最干净的判据:
要确认某次发送走的是 WS 还是 REST 回落,看日志里有没有对应的 `PASS POST /api/v1/chat` 就够了,
不需要在客户端加任何日志。
## 4. 注意
- **日志里会带查询串**(例如 `/ws/chat?token=...`),贴给别人之前先抹掉凭据。
脚本本身**不硬编码任何凭据**,也不写任何文件。
- 它**不是**后端行为的事实来源:要判断真后端怎么回,去看真后端本身。
- 端口随便挑,但**别让代理和真后端抢同一个口**;`10942` 是真后端的常规端口。