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。
This commit is contained in:
1 parent
df8df67742
commit
4f860aa499
11 files changed
+308
-21
No files matched your search
@@ -0,0 +1,72 @@
|
||||
# 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` 是真后端的常规端口。
|
||||
@@ -0,0 +1,90 @@
|
||||
"""W41 回归用的窄代理:HTTP 全放行,只把 /ws/chat 掐掉。
|
||||
|
||||
用途:模拟「WS 断了但 HTTP 通」——W41 三级阶梯应该在这种情况下
|
||||
探测 /api/v1/health 成功、回落 POST /api/v1/chat 把消息发出去。
|
||||
|
||||
仅本机开发用;这个文件**在 git 里**(`devtools/ws-block-proxy/`),不再放 `app/build/`——
|
||||
那里被 gitignore,一次 `gradlew clean` 就把它扫没了。用法见同目录的 README.md。
|
||||
用法:python ws_block_proxy.py <listen_port> <target_port>
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import socketserver
|
||||
import sys
|
||||
|
||||
LISTEN = int(sys.argv[1])
|
||||
TARGET = int(sys.argv[2])
|
||||
BLOCK = "/ws/chat"
|
||||
|
||||
HOP_BY_HOP = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade",
|
||||
}
|
||||
|
||||
|
||||
class Handler(socketserver.BaseRequestHandler):
|
||||
def handle(self):
|
||||
self.request.settimeout(30)
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = self.request.recv(4096)
|
||||
if not chunk:
|
||||
return
|
||||
buf += chunk
|
||||
head, _, rest = buf.partition(b"\r\n\r\n")
|
||||
lines = head.decode("latin-1").split("\r\n")
|
||||
method, path, _ = lines[0].split(" ", 2)
|
||||
|
||||
if path.split("?")[0] == BLOCK:
|
||||
body = b"ws blocked by test proxy"
|
||||
self.request.sendall(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\n"
|
||||
b"Content-Type: text/plain\r\n"
|
||||
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body
|
||||
)
|
||||
print("BLOCKED %s %s" % (method, path), flush=True)
|
||||
return
|
||||
|
||||
headers = {}
|
||||
for line in lines[1:]:
|
||||
k, _, v = line.partition(":")
|
||||
if k.lower() in HOP_BY_HOP:
|
||||
continue
|
||||
headers[k.strip()] = v.strip()
|
||||
|
||||
length = int(headers.get("Content-Length") or 0)
|
||||
body = rest
|
||||
while len(body) < length:
|
||||
more = self.request.recv(length - len(body))
|
||||
if not more:
|
||||
break
|
||||
body += more
|
||||
|
||||
conn = http.client.HTTPConnection("127.0.0.1", TARGET, timeout=60)
|
||||
try:
|
||||
conn.request(method, path, body=body if length else None, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
payload = resp.read()
|
||||
out = ["HTTP/1.1 %d %s" % (resp.status, resp.reason)]
|
||||
for k, v in resp.getheaders():
|
||||
if k.lower() in HOP_BY_HOP or k.lower() == "content-length":
|
||||
continue
|
||||
out.append("%s: %s" % (k, v))
|
||||
out.append("Content-Length: %d" % len(payload))
|
||||
self.request.sendall(("\r\n".join(out) + "\r\n\r\n").encode("latin-1") + payload)
|
||||
print("PASS %s %s -> %d" % (method, path, resp.status), flush=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.request.sendall(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
|
||||
print("ERR %s %s -> %r" % (method, path, exc), flush=True)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingTCPServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("proxy %d -> 127.0.0.1:%d, blocking %s" % (LISTEN, TARGET, BLOCK), flush=True)
|
||||
Server(("127.0.0.1", LISTEN), Handler).serve_forever()
|
||||
Reference in new issue
Block a user