feat(chat): 加 REST 发送路径 —— 长文与图片(含 clientMsgId 幂等与归一)

客户端此前**只有 WS** 发送,而 WS 帧硬上限 4096 字节、**超限是直接断连**(不是报个错),
所以长文发不出去、图完全发不了。v0.56 给 REST 流式补齐了明确响应面与 clientMsgId,
本次照它接入。

- data/api:ChatApi + ChatDto(窄请求体,不回环整条会话)+ ChatRepository
  —— **刻意不走 `apiCall`**:它把 4xx/5xx 一律折进 `Result.failure` 并丢掉状态码,
  那样 `202`(已排队)与 `409`(队满)的差别会在那一刻消失;改为映射成 ChatAck 密封结果
  (Accepted / Queued / QueueFull / Duplicate / Unavailable / Rejected)
- domain/chat/ChatSendRouting:纯函数路由 —— **UTF-8 字节数 ≤ 3584 且无图 → WS,否则 REST**。
  阈值按**字节**不按字符(中文 1 字 3 字节);预留 512 字节覆盖帧壳。短文本仍走 WS 是刻意的:
  那条路**绕开有损总线**,而超 WS 上限的代价是**断连**而非报错,两边风险不对称
- domain/chat/ChatImageBudget + data/image/ChatImagePreparer:解码 → 等比缩放 → 重编码为
  JPEG/PNG data URI;超预算自动降尺寸重来;**失败返回 null,绝不退回原图 base64**
- clientMsgId:每次发送 `"local-" + UUID`(**随机不自增**——服务端判重窗口是**整个会话历史
  且无过期清理**,进程重启后复用旧值会让那条消息永远发不出去);REST 与 WS 两路都带
- 归一:带 clientMsgId 的消息**只按 id 匹配**(文本不同也命中;匹配不到就什么都不吃,
  **绝不回落到文本**——那会吃掉一条无关气泡);不带键的老行才回落「第一条同文本」
- ack 分支:200 已受理(等 message_persisted)/ 202 已排队(出「已排队 N 条」)/
  **409 既未受理也未落库 → 回滚气泡并把文本与图片还回输入框**(不能当「已排队」,
  那会让气泡永远挂着「发送中…」)/ duplicate 不是错误(收气泡;本地没有该 messageId 就补拉一页)/
  500、503 回滚 / 400 image_too_large 换短文案
- 接下行帧 `chat_duplicate`;图片一律走 REST(`WsOutbound.Chat.image` 仍无生产者)

测试:269 全绿(218 → +51)。含真实抓包的四段响应(200 / 202 / duplicate / 400 image_too_large,
且服务端文案里的 6990572 与本端 SERVER_MAX_CHARS 做成对断言)、路由阈值边界
(1194 汉字 = 3582 字节走 WS、1195 = 3585 走 REST,并直接断言阈值处真实
`WsOutbound.Chat(...)` 序列化后 ≤ 4096 字节)、归一规则、六种 ack 映射。

真机(真后端 127.0.0.1:10942):**图片全链路通过** —— 选图 → 重编码(源 7.8 KB PNG →
1 KB JPEG,magic ffd8ffe0)→ `POST /api/v1/chat`(1558 字节体)→
`200 {"data":{"stream":true}}` → 气泡由「发送中…」归一为「来自 安卓」→ 昔涟回复**准确描述了那张图**。
另 WS 短文本路径未回归(无 POST,且 clientMsgId 被 v0.56 原样回填)。

未验证:`409 chat_busy` 与 `500` 非真实抓包(要填满主人后端的待处理队列、等于真跑 5 个回合,
判断不值得;形状与文案逐字取自后端源码,测试注释里已写明);UI 上的 202/409 未端到端跑过;
降尺寸「减半重来」那步未在设备上跑过(测试图 64×64 只走到重编码)。
This commit is contained in:
AskaEth committed 2026-10-04 16:57:22 +08:00
1 parent 4c14028c70
commit e642b0c18c
19 files changed
+1615 -35

No files matched your search

@@ -0,0 +1,176 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsOutbound
/**
* 上行路由规则与图片预算(纯函数)。
*
* 判据是**UTF-8 字节数**:WS 帧上限是服务端的 `wsReadLimit = 4096` 字节,而超限的后果不是
* 「服务端回个错误」,是**传输层直接断连**——所以这条规则宁早不晚。
*/
class ChatSendPolicyTest {
// —— 路由 ——
@Test
fun `short text goes over the websocket`() {
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("你好", hasImage = false))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor("hello", hasImage = false))
}
@Test
fun `long text goes over REST`() {
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(text, hasImage = false))
}
@Test
fun `any image goes over REST even when the text is one character`() {
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("嗨", hasImage = true))
// 纯图片(空文本)也必须走 REST:base64 不可能塞进 4096 字节的帧。
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor("", hasImage = true))
}
@Test
fun `the threshold is inclusive on the websocket side`() {
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertEquals(ChatSendRouting.WS_TEXT_LIMIT_BYTES, ChatSendRouting.utf8Length(atLimit))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(atLimit, hasImage = false))
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overLimit, hasImage = false))
}
@Test
fun `the threshold counts bytes not characters so CJK falls back earlier`() {
// 中文一个字 3 字节:1194 字 = 3582 字节(仍在限内),1195 字 = 3585 字节(超限)。
// 若按「字符数 ≤ 3584」判,333 个汉字时就该退到 REST 却仍会走 WS —— 这正是要避免的。
val fits = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
val overflows = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3 + 1)
assertEquals(3582, ChatSendRouting.utf8Length(fits))
assertEquals(SendChannel.Ws, ChatSendRouting.channelFor(fits, hasImage = false))
assertEquals(3585, ChatSendRouting.utf8Length(overflows))
assertEquals(SendChannel.Rest, ChatSendRouting.channelFor(overflows, hasImage = false))
}
@Test
fun `an emoji outside the BMP is four bytes like the wire says`() {
// UTF-16 里一个 emoji 是 2 个 char,但 WS 上按 UTF-8 算是 4 字节 —— 用 `length` 会低估一半。
assertEquals(2, "🎐".length)
assertEquals(4, ChatSendRouting.utf8Length("🎐"))
}
// —— 阈值本身与「真实帧不会超 4096」 ——
@Test
fun `the websocket budget leaves room for the frame envelope`() {
assertEquals(4096, ChatSendRouting.WS_FRAME_LIMIT_BYTES)
assertTrue(
"文本上限必须真的给 JSON 外壳留出空间",
ChatSendRouting.WS_TEXT_LIMIT_BYTES < ChatSendRouting.WS_FRAME_LIMIT_BYTES,
)
assertEquals(
ChatSendRouting.WS_FRAME_LIMIT_BYTES - ChatSendRouting.WS_ENVELOPE_RESERVE_BYTES,
ChatSendRouting.WS_TEXT_LIMIT_BYTES,
)
}
@Test
fun `a chat frame at the threshold still fits the 4096 byte read limit`() {
val text = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val frame = WsOutbound.Chat(
text = text,
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
).toJson()
// 真实的 UTF-8 字节数必须 ≤ 服务端读上限,否则这条帧会让服务端**直接断连**。
assertTrue(
"帧 ${ChatSendRouting.utf8Length(frame)} 字节超过了 4096",
ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES,
)
}
@Test
fun `a CJK chat frame at the threshold still fits`() {
// 逐字符碰到上限为止:取最大的、字节数仍 ≤ 上限的中文串。
val text = "昔".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES / 3)
val frame = WsOutbound.Chat(
text = text,
sessionId = "web",
deviceId = "voiceassist:cyrene:v1",
clientMsgId = "local-8f14e45f-ceea-467f-a1b2-9d5d0e3c7a41",
).toJson()
assertTrue(ChatSendRouting.utf8Length(frame) <= ChatSendRouting.WS_FRAME_LIMIT_BYTES)
}
// —— 图片预算 ——
@Test
fun `base64 length follows the 4 to 3 expansion`() {
assertEquals(0, ChatImageBudget.base64Chars(0))
assertEquals(4, ChatImageBudget.base64Chars(1))
assertEquals(4, ChatImageBudget.base64Chars(3))
assertEquals(8, ChatImageBudget.base64Chars(4))
assertEquals(8, ChatImageBudget.base64Chars(6))
}
@Test
fun `the server character cap matches 5 MiB plus the data uri prefix allowance`() {
val expected = ((ChatImageBudget.SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + 64
assertEquals(5 * 1024 * 1024, ChatImageBudget.SERVER_MAX_DECODED_BYTES)
assertEquals(expected, ChatImageBudget.SERVER_MAX_CHARS)
// 服务端 `checkChatImageSize` 的判据是 `len(image) <= limit`:等于上限必须放行。
assertTrue(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS))
assertFalse(ChatImageBudget.withinServerLimit(ChatImageBudget.SERVER_MAX_CHARS + 1))
}
@Test
fun `the client target budget is far below the server hard cap`() {
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
assertFalse(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES + 1))
// 本端目标必须明显更紧:服务端只管「收不收」,管不了「历史有多重」。
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 4 < ChatImageBudget.SERVER_MAX_DECODED_BYTES)
}
@Test
fun `scaling never upscales and keeps the aspect ratio`() {
assertEquals(ScaledSize(800, 600), ChatImageBudget.scaledSize(800, 600))
assertEquals(ScaledSize(1280, 1280), ChatImageBudget.scaledSize(1280, 1280))
assertEquals(ScaledSize(1280, 960), ChatImageBudget.scaledSize(4000, 3000))
assertEquals(ScaledSize(960, 1280), ChatImageBudget.scaledSize(3000, 4000))
}
@Test
fun `scaling a very thin image keeps both sides at least one pixel`() {
val scaled = ChatImageBudget.scaledSize(1, 5000)
assertEquals(1, scaled.width)
assertEquals(1280, scaled.height)
}
@Test
fun `scaling guards against degenerate dimensions`() {
assertEquals(ScaledSize(1, 1), ChatImageBudget.scaledSize(0, 0))
assertEquals(ScaledSize(1, 1280), ChatImageBudget.scaledSize(-3, 5000))
}
@Test
fun `data uri has the shape coil and the server both accept`() {
val uri = ChatImageBudget.dataUri("image/jpeg", "AAAA")
assertEquals("data:image/jpeg;base64,AAAA", uri)
assertTrue(uri.startsWith("data:image/jpeg;base64,"))
}
}