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:
1 parent
4c14028c70
commit
e642b0c18c
19 files changed
+1615
-35
No files matched your search
@@ -0,0 +1,72 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 等比缩放后的目标尺寸(px)。 */
|
||||
data class ScaledSize(val width: Int, val height: Int)
|
||||
|
||||
/**
|
||||
* 发送前图片的**体积预算**与尺寸规划(纯函数,编码器与测试共用;不含任何 Android 依赖)。
|
||||
*
|
||||
* ## 为什么必须缩放 / 重编码
|
||||
* 服务端把 `image` 字符串**原样落库**(api-client §1 行 3 + 协同文档 G3),于是
|
||||
* `GET /sessions/{id}/messages` 的**每一页**、WS 握手 `history` 的**每一次回放**
|
||||
* 都要把整段 base64 再传一遍。所以「发一张 8 MB 的原图」= 以后每次翻历史都重传 11 MB。
|
||||
* 客户端预算是这个成本的唯一闸门,**不是优化**。
|
||||
*
|
||||
* 两层上限:
|
||||
* - [MAX_DECODED_BYTES] = 1 MiB:**本端的**目标(缩放 + 重编码后必须 ≤ 它)——
|
||||
* 服务端 5 MiB 只管「收不收」,管不了「历史有多重」;
|
||||
* - [SERVER_MAX_DECODED_BYTES] = 5 MiB:服务端硬顶(解码后),超限 `400 image_too_large`。
|
||||
* 它只作为**最后一道兜底检查**:真要走上传(`POST /api/v1/upload`)时再放宽。
|
||||
*
|
||||
* 校验按**字符串长度**([SERVER_MAX_CHARS])而不是解码字节数——与服务端入口层同一口径
|
||||
* (`chatImageCharsFor`),这样两端不可能漂移。
|
||||
*/
|
||||
object ChatImageBudget {
|
||||
|
||||
/** 服务端 `image` 解码后上限:5 MiB(与 `POST /api/v1/upload` 同口径)。 */
|
||||
const val SERVER_MAX_DECODED_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** `data:` URI 前缀(`data:image/jpeg;base64,`)的字符余量,与服务端同一取值。 */
|
||||
const val PREFIX_ALLOWANCE_CHARS = 64
|
||||
|
||||
/** 服务端 `image` 字符串长度上限(= base64 膨胀 4/3 + 前缀余量)。 */
|
||||
const val SERVER_MAX_CHARS =
|
||||
((SERVER_MAX_DECODED_BYTES + 2) / 3) * 4 + PREFIX_ALLOWANCE_CHARS
|
||||
|
||||
/** 本端目标:重编码后的**字节**上限,远低于服务端硬顶(见类注释)。 */
|
||||
const val MAX_DECODED_BYTES = 1 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* 最长边的目标像素。1280 与 provider 自己入模型前的缩放口径(约 1300×1300)同量级,
|
||||
* 所以再放大不会有任何可见收益——省的是库与历史的字节,不是 token。
|
||||
*/
|
||||
const val MAX_DIMENSION_PX = 1280
|
||||
|
||||
/** [byteCount] 字节 base64 编码后的字符数(每 3 字节 → 4 字符,末组补满)。 */
|
||||
fun base64Chars(byteCount: Int): Int = ((byteCount + 2) / 3) * 4
|
||||
|
||||
/** 字符串长度是否在服务端上限内(入口层同一判据:`<=` 才算通过)。 */
|
||||
fun withinServerLimit(dataUriChars: Int): Boolean = dataUriChars <= SERVER_MAX_CHARS
|
||||
|
||||
/** 编码后字节数是否已落到本端目标内。 */
|
||||
fun withinTarget(byteCount: Int): Boolean = byteCount <= MAX_DECODED_BYTES
|
||||
|
||||
/**
|
||||
* 等比缩放到最长边不超过 [maxDimension];**只缩不放**(小图保持原尺寸),
|
||||
* 边长至少 1 px(极端细长图缩放后可能算成 0)。
|
||||
*/
|
||||
fun scaledSize(width: Int, height: Int, maxDimension: Int = MAX_DIMENSION_PX): ScaledSize {
|
||||
val w = width.coerceAtLeast(1)
|
||||
val h = height.coerceAtLeast(1)
|
||||
val longest = maxOf(w, h)
|
||||
if (longest <= maxDimension) return ScaledSize(w, h)
|
||||
val ratio = maxDimension.toDouble() / longest
|
||||
return ScaledSize(
|
||||
width = (w * ratio).toInt().coerceAtLeast(1),
|
||||
height = (h * ratio).toInt().coerceAtLeast(1),
|
||||
)
|
||||
}
|
||||
|
||||
/** 拼 `data:` URI([mime] 形如 `image/jpeg`,[base64] 为单行 base64)。 */
|
||||
fun dataUri(mime: String, base64: String): String = "data:$mime;base64,$base64"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一条消息该走哪条上行通道。 */
|
||||
enum class SendChannel {
|
||||
/** 短文本走 WS `chat` 帧:直达、绕过有损总线,且今天就能用。 */
|
||||
Ws,
|
||||
|
||||
/** 长文本 / 任何图片走 REST `POST /api/v1/chat`(`stream:true`)。 */
|
||||
Rest,
|
||||
}
|
||||
|
||||
/**
|
||||
* 上行路由规则(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 为什么必须有这条规则
|
||||
* WS 帧的读上限是**服务端** `wsReadLimit = 4096` 字节/帧,且**超限由传输层直接断连**——
|
||||
* 不是回一个错误,而是把这条连接踢掉(api-client §1 行 3 G3 的诚实边界)。所以
|
||||
* 「长文发不出去」在今天不是「服务端拒绝」,是**发送帧把连接打断**。REST 没有这个上限,
|
||||
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
|
||||
*
|
||||
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
|
||||
*
|
||||
* ## 阈值
|
||||
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
|
||||
* 333 个汉字时就撞上 4096)。上限取 [WS_TEXT_LIMIT_BYTES] = 4096 − 512 字节预留:
|
||||
* 预留覆盖 `{"type","data":{"sessionId","text","deviceId","clientMsgId","image"}}` 的
|
||||
* JSON 外壳(键名 + 值,UTF-8 最坏情况约 150 字节),并留出余量。留得保守是因为
|
||||
* **超限的代价是断连**,而多走一次 REST 的代价只是几十毫秒——风险不对称,宁可早退。
|
||||
*
|
||||
* 边界:`utf8Length(text) <= WS_TEXT_LIMIT_BYTES` → WS;多 1 字节即 REST。
|
||||
*/
|
||||
object ChatSendRouting {
|
||||
|
||||
/** 服务端 WS 读上限(`wsReadLimit`,契约 v0.56 明确记载)。 */
|
||||
const val WS_FRAME_LIMIT_BYTES = 4096
|
||||
|
||||
/** WS `chat` 帧 JSON 外壳的预留:键名 + `sessionId` / `deviceId` / `clientMsgId` 等值的字节数。 */
|
||||
const val WS_ENVELOPE_RESERVE_BYTES = 512
|
||||
|
||||
/** 允许走 WS 的**文本**字节上限(不含图片:有图片一律走 REST)。 */
|
||||
const val WS_TEXT_LIMIT_BYTES = WS_FRAME_LIMIT_BYTES - WS_ENVELOPE_RESERVE_BYTES
|
||||
|
||||
/** 文本的 UTF-8 字节数;`text` 为空返回 0。 */
|
||||
fun utf8Length(text: String): Int = text.toByteArray(Charsets.UTF_8).size
|
||||
|
||||
/**
|
||||
* 这一条该走哪条通道。
|
||||
* @param hasImage 本次是否带图片(带图**永远**走 REST)
|
||||
*/
|
||||
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
|
||||
hasImage -> SendChannel.Rest
|
||||
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
|
||||
else -> SendChannel.Rest
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user