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,129 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
/**
* 乐观气泡的归一规则(`clientMsgId` 首选、同文本兜底)。
*
* v0.56 之前只能靠「文本相等」猜,而文本是会变的(发送时被 trim、服务端将来会再加工),
* 两条一样的话还会连吃两个气泡。[consumePendingFor] 就是这条规则的唯一实现。
*/
class PendingReconcileTest {
private fun pending(localId: String, text: String) = PendingMessage(localId = localId, text = text)
private fun persisted(
id: String = "m1",
content: String = "你好",
clientMsgId: String? = null,
role: String = "user",
) = HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = content,
clientMsgId = clientMsgId,
createdAt = 0L,
)
@Test
fun `a matching clientMsgId wins even when the persisted text differs`() {
val pending = listOf(pending("local-a", "发送时的文本"), pending("local-b", "另一条"))
val left = consumePendingFor(
pending,
persisted(content = "服务端加工后的文本", clientMsgId = "local-a"),
)
// 文本对不上也照样命中;另一条纹丝不动。
assertEquals(listOf("local-b"), left.map { it.localId })
}
@Test
fun `a persisted message without a clientMsgId still falls back to the first equal text`() {
val pending = listOf(pending("local-a", "别的话"), pending("local-b", "旧服务端的消息"))
val left = consumePendingFor(pending, persisted(content = "旧服务端的消息"))
assertEquals(listOf("local-a"), left.map { it.localId })
}
@Test
fun `a blank clientMsgId counts as absent and uses the text fallback`() {
val pending = listOf(pending("local-a", "同文本"))
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = " "))
assertTrue(left.isEmpty())
}
@Test
fun `an unknown clientMsgId consumes nothing and does not fall back to text`() {
val pending = listOf(pending("local-a", "同文本"))
// 带键的消息属于**某一次具体的发送**:键对不上就说明它不是这一条(可能是别的端发的),
// 此时按文本去吃不等于「对齐」,而是吃掉一个无关的气泡。
val left = consumePendingFor(pending, persisted(content = "同文本", clientMsgId = "local-other"))
assertEquals(listOf("local-a"), left.map { it.localId })
}
@Test
fun `two equal-text bubbles are consumed one at a time and never double consumed`() {
val pending = listOf(pending("local-a", "重复的话"), pending("local-b", "重复的话"))
val afterFirst = consumePendingFor(pending, persisted(id = "m1", content = "重复的话", clientMsgId = "local-a"))
assertEquals(listOf("local-b"), afterFirst.map { it.localId })
// 第二条(键对不上任何现存气泡)不该再吃掉 local-b。
val afterSecond = consumePendingFor(
afterFirst,
persisted(id = "m2", content = "重复的话", clientMsgId = "local-a"),
)
assertEquals(listOf("local-b"), afterSecond.map { it.localId })
}
@Test
fun `replaying the same persisted message changes nothing the second time`() {
val pending = listOf(pending("local-a", "同一条"))
val once = consumePendingFor(pending, persisted(content = "同一条"))
val twice = consumePendingFor(once, persisted(content = "同一条"))
assertTrue(once.isEmpty())
assertTrue(twice.isEmpty())
}
@Test
fun `matching keeps the order of the remaining bubbles`() {
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"), pending("local-c", "丙"))
val left = consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
assertEquals(listOf("local-a", "local-c"), left.map { it.localId })
assertEquals(listOf("甲", "丙"), left.map { it.text })
}
@Test
fun `no match returns the very same list instance`() {
val pending = listOf(pending("local-a", "甲"))
val left = consumePendingFor(pending, persisted(content = "毫不相干", clientMsgId = "local-x"))
// 无命中时不得重建列表:Compose 的 key 稳定性和「什么都没发生」的语义都靠它。
assertSame(pending, left)
}
@Test
fun `the pending input is never mutated`() {
val pending = listOf(pending("local-a", "甲"), pending("local-b", "乙"))
consumePendingFor(pending, persisted(content = "乙", clientMsgId = "local-b"))
assertEquals(listOf("local-a", "local-b"), pending.map { it.localId })
}
}