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
@@ -228,6 +228,31 @@ object ShareRules {
|
|||||||
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
|
fun truncatedImagesNotice(kept: Int, dropped: Int): String? =
|
||||||
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
|
if (dropped > 0) "一次最多 $MAX_IMAGES 张,超出的没发,只发送前 $kept 张" else null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次「批量投递」里,第 [index] 条要不要声明「她忙时发消息」那个偏好(`interrupt` / `parallel`)。
|
||||||
|
*
|
||||||
|
* ## 两个事实合起来只剩两种答案
|
||||||
|
* - **分享一次都不声明**([fromShare]):分享是「一次把东西倒进去」,N 张图就是 N 条消息;对每张
|
||||||
|
* 图各声明一次,第 2 条就会把第 1 条刚起的回合打掉——**批量动作自相残杀**。这是本端自己的判断
|
||||||
|
* (后端口径 `02` F4:他们那边不需要做任何事)。
|
||||||
|
* - **手打那条只声明第一条**(`index == 0`):一次发送切出来的第 2 条起若也声明,同样是在打断
|
||||||
|
* 自己刚起的上一轮。声明的是「这一次投递要怎样对待正在进行的回合」,一次发送只该有**一个**
|
||||||
|
* 这样的意思。
|
||||||
|
*
|
||||||
|
* 于是答案只有 `false`(分享,恒成立)与「只有第 0 条成立」(手打)两种形态。
|
||||||
|
*
|
||||||
|
* ## 为什么提成一个纯函数
|
||||||
|
* 这条边界原先散在 `ChatViewModel` 里(`applySendPreference && index == 0`,分享那条入口另传
|
||||||
|
* `false`):两处各是半个判据、**一处都单测不到**,而「分享多图绝不互相打断」正是 W76 要一直
|
||||||
|
* 成立的那条边界(见 `05` 的多图自相打断一节)。合并成一处判据之后,入口与投递口用的必须是
|
||||||
|
* 同一条——与 `hasSendableContent`(`ui/chat/SendQueue.kt`)的情形完全一样。口径也与
|
||||||
|
* [truncatedImagesNotice] 一致:两条入口共用的规则,只有一个出处。
|
||||||
|
*
|
||||||
|
* @param fromShare 这条(这一批)是不是分享进来的
|
||||||
|
* @param index 在这次发送切出的消息里的位置(0 = 第一条)
|
||||||
|
*/
|
||||||
|
fun declaresSendPreference(fromShare: Boolean, index: Int): Boolean = !fromShare && index == 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从分享 Intent 的原始字段提取内容。
|
* 从分享 Intent 的原始字段提取内容。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package top.yeij.cyrene.domain.share
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量投递的「声明」边界([ShareRules.declaresSendPreference])。
|
||||||
|
*
|
||||||
|
* 钉住的是一件**不许再走回去**的事:分享进来的 N 张图会落成 N 条消息(契约里 `chat.image` 是单图
|
||||||
|
* 字段),而这 N 条是**一次**投递动作——它们之间绝不许互相打断 / 互相取消(`02` F4 的后端口径、
|
||||||
|
* `05` 的多图自相打断一节)。一旦某一条在忙时声明了 `interrupt`,第 2 条就会把第 1 条刚起的回合打
|
||||||
|
* 掉;声明 `parallel` 同样把「一次倒进去」变成「几条并行回合」。
|
||||||
|
*
|
||||||
|
* 纯函数 ⇒ 不需要组合界面、不需要设备,就能把**整批**(0 到 [ShareRules.MAX_IMAGES] - 1)逐条穷举。
|
||||||
|
*/
|
||||||
|
class ShareSendDeclarationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `share never declares on any message of the batch`() {
|
||||||
|
(0 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||||
|
assertFalse(
|
||||||
|
"分享的第 $index 条不许声明(声明就会打断同一批里的上一条)",
|
||||||
|
ShareRules.declaresSendPreference(fromShare = true, index = index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `share stays silent even past the image limit`() {
|
||||||
|
// 超限那张本来就不发(见 truncatedImagesNotice),但判据对它也不许翻成 true:
|
||||||
|
// 一旦有人把上限放宽而忘了这条,越界位置不该成为唯一声明的那一条。
|
||||||
|
(ShareRules.MAX_IMAGES..ShareRules.MAX_IMAGES + 3).forEach { index ->
|
||||||
|
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the typed send declares on its first message only`() {
|
||||||
|
assertTrue(
|
||||||
|
"手打的第一条仍按「她忙时发消息」偏好投递",
|
||||||
|
ShareRules.declaresSendPreference(fromShare = false, index = 0),
|
||||||
|
)
|
||||||
|
(1 until ShareRules.MAX_IMAGES).forEach { index ->
|
||||||
|
assertFalse(
|
||||||
|
"手打的第 $index 条声明就是在打断自己刚起的上一轮",
|
||||||
|
ShareRules.declaresSendPreference(fromShare = false, index = index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single message batch is the same rule`() {
|
||||||
|
// 只有一条时两种入口的差别正是全部差别:手打声明、分享不声明。
|
||||||
|
assertTrue(ShareRules.declaresSendPreference(fromShare = false, index = 0))
|
||||||
|
assertFalse(ShareRules.declaresSendPreference(fromShare = true, index = 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`。
|
- `out/upstream.jsonl` —— **每条上行帧一行**:`{ts, epochMs, client, type, sessionId, raw, frame}`。
|
||||||
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
|
`raw` 是客户端发来的**原样文本**。「客户端到底发没发那一帧」以这里为准。
|
||||||
- `out/downstream.jsonl` —— 每条下发帧:`{ts, to, origin, raw}`。`origin` 会写清是 `history`、`control:reply` 还是某次广播。
|
- `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 的文件。
|
`Authorization` 只留 sha256 指纹、WS 的 `?token=` 同样只留指纹 —— 就算它是假 token,也没理由把凭据原文写进可能被 commit 的文件。
|
||||||
|
`body` 是**请求体的摘要**,不是原文:结构照留(键、布尔、数字、数组长度),超过 200 字符的字符串换成
|
||||||
|
`"<N chars>"`,非 JSON 的体记成 `{bytes, note}`。这样「发了几条、每条带不带图、有没有 `parallel`」
|
||||||
|
读得出来,而图片的 base64 不会把一行日志撑到几 MB。`body` 只在有请求体时出现。
|
||||||
|
|
||||||
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
挂起的上行消息(`--hold-replies`)**不进文件** —— 它只活在内存里,看 `GET /__control/state` 的
|
||||||
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
`heldMessages`。`out/*.jsonl` 仍照常记「上行帧收到了什么 / 下发帧广播了什么」。
|
||||||
|
|||||||
@@ -281,6 +281,37 @@ def redact(value, keep: int = 8) -> str:
|
|||||||
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
digest = hashlib.sha256(str(value).encode("utf-8")).hexdigest()[:keep]
|
||||||
return "<redacted sha256:{}>".format(digest)
|
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:
|
class JsonlLog:
|
||||||
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
"""一行一条 JSON 的追加日志。每次写都开一次文件,崩了也不丢已写部分。"""
|
||||||
@@ -599,11 +630,15 @@ class Store:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
self.downstream_count += 1
|
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,
|
`Authorization` 只留指纹:那是客户端当前会话的凭据,即使假后端只看 HTTP 2xx,
|
||||||
也没有理由把它原文写进一个可能被 commit 的文件。
|
也没有理由把它原文写进一个可能被 commit 的文件。
|
||||||
|
|
||||||
|
[body] 走 [summarize_body]:图片那串 base64 只留长度,其余结构原样——「这一次发了几条
|
||||||
|
消息、每一条带的是正文还是图、有没有声明 `parallel`」这些产品问题,从此在
|
||||||
|
`out/http.jsonl` 里读得出来,不用再去猜。
|
||||||
"""
|
"""
|
||||||
recorded_headers = {}
|
recorded_headers = {}
|
||||||
for key, value in headers.items():
|
for key, value in headers.items():
|
||||||
@@ -623,6 +658,9 @@ class Store:
|
|||||||
"query": query,
|
"query": query,
|
||||||
"headers": recorded_headers,
|
"headers": recorded_headers,
|
||||||
}
|
}
|
||||||
|
summary = summarize_body(body)
|
||||||
|
if summary is not None:
|
||||||
|
record["body"] = summary
|
||||||
self.http_log.append(record)
|
self.http_log.append(record)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.http_count += 1
|
self.http_count += 1
|
||||||
@@ -1047,16 +1085,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return parts.path, query
|
return parts.path, query
|
||||||
|
|
||||||
def _read_body(self):
|
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")
|
length = self.headers.get("Content-Length")
|
||||||
if not length:
|
if not length:
|
||||||
return b""
|
return self._body_cache
|
||||||
try:
|
try:
|
||||||
size = int(length)
|
size = int(length)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return b""
|
return self._body_cache
|
||||||
if size <= 0:
|
if size <= 0:
|
||||||
return b""
|
return self._body_cache
|
||||||
return self.rfile.read(size) or b""
|
self._body_cache = self.rfile.read(size) or b""
|
||||||
|
return self._body_cache
|
||||||
|
|
||||||
def _parse_json_body(self):
|
def _parse_json_body(self):
|
||||||
raw = self._read_body()
|
raw = self._read_body()
|
||||||
@@ -1103,7 +1148,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def _handle(self, method: str):
|
def _handle(self, method: str):
|
||||||
path, query = self._split()
|
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:
|
try:
|
||||||
if path == "/ws/chat":
|
if path == "/ws/chat":
|
||||||
self._handle_ws(method, query)
|
self._handle_ws(method, query)
|
||||||
|
|||||||
Reference in new issue
Block a user