W76:把「分享批量投递零声明」提成可单测的纯函数 + 假后端 http.jsonl 记请求体摘要
两件事都是为了让「分享 3 张图 = 3 条消息、零 interrupt」这条结论有据可查——
一条是代码侧的构造性保证,一条是设备侧的证据工具。
1) ShareRules.declaresSendPreference(fromShare, index)(纯函数)
「这一次投递要不要按『她忙时发消息』偏好声明 interrupt / parallel」原先分在两处手写:
ChatViewModel.sendShared 传 false、emitPlannedMessages 里 && index == 0。两处各是半个判据、
一处都单测不到,而这条边界正是 W25 修掉的「分享多图自相打断」(第 2 张把第 1 张刚起的回合
打掉,见 02 的 F4 与 05 那一节)。提成一处判据后语义不变,穷举整批(0..MAX_IMAGES-1 及越界)
由 ShareSendDeclarationTest 钉住:分享恒 false、手打只在第 0 条 true。
ChatViewModel 不在本次 scope,接线那一行没动(已作为 finding 上报,纯函数暂为唯一出处)。
2) 假后端 out/http.jsonl 记请求体摘要(不只是 headers)
分享多图走 REST 不走 WS(ChatSendRouting.channelFor:hasImage ⇒ Rest),所以「发了几条、
每条带的是正文还是图、有没有 parallel」只能从 HTTP 流水里读。此前它只记 headers,
现在加 body 摘要:结构照留(键 / 布尔 / 数组长度),超过 200 字符的字符串换成 <N chars>,
非 JSON 记 {bytes, note}——图片的 base64 只留长度,不会把一行撑到几 MB。
_read_body 顺带加了缓存:body 只能从 rfile 读一次,先记录再让 handler 读会得到空串。
assembleDebug + testDebugUnitTest 全绿:1674 测试 / 0 失败 / 0 错误 / 0 跳过(134 个测试类)。
This commit is contained in:
1 parent
4d25d9ec69
commit
8babd1d2bb
4 files changed
+147
-8
No files matched your search
@@ -474,8 +474,11 @@ curl -s -X POST "$B/__control/messages?session=web&count=1&role=user&text=&docum
|
||||
- `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}`。
|
||||
- `out/http.jsonl` —— 每条 HTTP 请求:`{method, path, query, headers, body}`。
|
||||
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
|
||||
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
|
||||
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
|
||||
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
|
||||
|
||||
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
||||
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
||||
|
||||
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
|
||||
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
||||
return "<redacted sha256:{}>".format(digest)
|
||||
|
||||
# 请求体摘要里「短到什么程度就原样留着」的那条线。它要装得下正文与显示名,装不下 base64。
|
||||
BODY_SUMMARY_STRING_MAX = 200
|
||||
|
||||
def summarize_body(raw) -> dict | None:
|
||||
"""把请求体记成一份「看得见形状、看不见内容」的摘要。
|
||||
|
||||
`POST /api/v1/chat` 的正文、图片、附件全在 body 里,而图片是一整串 base64:原样写进
|
||||
`out/http.jsonl` 会把一行记录撑到几 MB,也读不下去。所以结构照留(键、布尔、短字符串、
|
||||
数组长度),只把超长字符串换成 `<N chars>`。
|
||||
|
||||
这样「这一条带没带 `image`」「`parallel` 这个键在不在」「三张图是不是三条消息、正文挂在
|
||||
第一条上」全都读得出来,而图片字节本身不落盘——与 [redact] 同一条纪律:日志记事实,不记凭据,
|
||||
也不记大块内容。
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {"bytes": len(raw), "note": "not json"}
|
||||
return _summarize_value(parsed)
|
||||
|
||||
def _summarize_value(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _summarize_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_summarize_value(item) for item in value]
|
||||
if isinstance(value, str) and len(value) > BODY_SUMMARY_STRING_MAX:
|
||||
return "<{} chars>".format(len(value))
|
||||
return value
|
||||
|
||||
|
||||
class JsonlLog:
|
||||
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
||||
@@ -599,11 +630,15 @@ class Store:
|
||||
with self.lock:
|
||||
self.downstream_count += 1
|
||||
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers) -> dict:
|
||||
"""把收到的请求头**记下来**(鉴权头不校验,但要看得见)。
|
||||
def record_http(self, handler_label: str, method: str, path: str, query: dict, headers, body=b"") -> dict:
|
||||
"""把收到的请求头与请求体的**摘要**记下来(鉴权头不校验,但要看得见)。
|
||||
|
||||
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
|
||||
也没有理由把它原文写进一个可能被 commit 的文件。
|
||||
|
||||
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
|
||||
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
|
||||
`out/http.jsonl` 里读得出来,不用再去猜。
|
||||
"""
|
||||
recorded_headers = {}
|
||||
for key, value in headers.items():
|
||||
@@ -623,6 +658,9 @@ class Store:
|
||||
"query": query,
|
||||
"headers": recorded_headers,
|
||||
}
|
||||
summary = summarize_body(body)
|
||||
if summary is not None:
|
||||
record["body"] = summary
|
||||
self.http_log.append(record)
|
||||
with self.lock:
|
||||
self.http_count += 1
|
||||
@@ -1047,16 +1085,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return parts.path, query
|
||||
|
||||
def _read_body(self):
|
||||
"""读出请求体,**并缓存**:`_handle` 记录摘要时要读它,各 handler 里的
|
||||
`_parse_json_body` 随后再读——不缓存的话第二次只会读到空串(body 只能读一次)。"""
|
||||
if getattr(self, "_body_read", False):
|
||||
return self._body_cache
|
||||
self._body_read = True
|
||||
self._body_cache = b""
|
||||
length = self.headers.get("Content-Length")
|
||||
if not length:
|
||||
return b""
|
||||
return self._body_cache
|
||||
try:
|
||||
size = int(length)
|
||||
except ValueError:
|
||||
return b""
|
||||
return self._body_cache
|
||||
if size <= 0:
|
||||
return b""
|
||||
return self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
self._body_cache = self.rfile.read(size) or b""
|
||||
return self._body_cache
|
||||
|
||||
def _parse_json_body(self):
|
||||
raw = self._read_body()
|
||||
@@ -1103,7 +1148,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def _handle(self, method: str):
|
||||
path, query = self._split()
|
||||
self.store.record_http(self._label(), method, path, query, self.headers)
|
||||
# 请求体**先读一遍**(`_read_body` 会缓存它),记录时才能带上摘要:body 只能从 rfile 读
|
||||
# 一次,若先记录、再让 handler 去读,摘要那一份就永远是空的。只对 `/api/v1/**` 的写方法
|
||||
# 这么做——`/__control/**` 是测试者自己敲的 curl,不用记。
|
||||
body = (
|
||||
self._read_body()
|
||||
if path.startswith("/api/v1/") and method in ("POST", "PATCH", "PUT", "DELETE")
|
||||
else b""
|
||||
)
|
||||
self.store.record_http(self._label(), method, path, query, self.headers, body=body)
|
||||
try:
|
||||
if path == "/ws/chat":
|
||||
self._handle_ws(method, query)
|
||||
|
||||
Reference in new issue
Block a user