feat(chat): 大图改走 /upload,历史里只留 URL
服务端把 `chat.image` **原样落库**,于是内联的 base64 会在**每一页历史**与**每一次
WS `history` 回放**里重传一遍(协同 G3)。B1 的 `POST /api/v1/upload` 早就有了,客户端
一直没用——本次把它接进发送路径。
- `data/api/UploadApi.kt` / `data/api/dto/UploadDto.kt` / `data/repository/UploadRepository.kt`:
upload 三件套。multipart 字段名 `file`(Go 只认这个名,且只把带 `filename` 的部件当文件,
已对着在线后端实测:字段名不对回 `400 {"code":"bad_request","message":"file is required"}`);
走 `apiCall`(这台端点的状态码不承载额外语义,`413`/`415` 的区分在 `error.code` 里)。
仓库另拦两种「服务端帮不了」的情况:`url` 为空**或不是绝对 http(s) 地址**就判解析失败
——视觉层只把 `http(s)://` 当 URL,裸路径会被当成 base64,照发等于把这一轮扔进黑洞。
- `app/AppContainer.kt`:按 baseUrl 缓存接线,与 chatApi 同源(上传产物 URL 必须交给同一个后端)。
- `domain/chat/ChatImageDelivery.kt`:内联 vs 上传的**唯一判据**(纯函数)。
阈值为重编码后的 **64 KiB(含等于内联)**:内联的代价是每次历史取数再多 4/3 × B,
64 KiB 即 +85 KB/次(20 张这种图的会话每次冷启动/重连回放就多传 ≥1.7 MB),而上传只是
多一次往返(局域网毫秒级)——从第二次取数起就稳赚;≤64 KiB 的缩略图省下的量与一次往返同量级,
不值得多一个失败面。阈值必须严格小于本端 1 MiB 预算,否则上传分支是死代码(有测试钉住)。
- `ui/chat/ChatViewModel.kt`:图片发送路径分流(小图内联、大图上传换 URL 再发 REST)。
**上传失败不回退内联**:回退会把「省历史重量」当场作废、还是在用户看不见时换了一条语义不同的路;
这里按上一步骤给可重试文案,并把图与文本还给输入框(`uploadFailureMessage`,纯函数可单测)。
- `data/image/ChatImagePreparer.kt`:产物改为同时给出 `data:` URI 与**同一份**字节,
上传因此不必把刚编出来的东西再 base64 解一遍。
阈值依据、以及「URL 形态到底受不受 5 MiB 上限约束」的核查结论写在 `ChatImageDelivery`
与 `UploadRepository` 的注释里(结论:URL 形态**不受**解码后 5 MiB 约束——网关
`checkChatImageSize` 量的是 `image` 字符串长度,URL 才几十字符;URL 到视觉层才被取回,
上限是 `vision.maxFetchedBody` = 50 MiB。所以走上传**不是**为了绕开上限,本端 1 MiB 预算离
两个 5 MiB 上限都还差两个数量级)。
测试:309 全绿(272 → +37):上传响应/错误体的真实形状(错误体是在线抓包原文)、
multipart 线级形状(部件名、filename、字节原样)、内联阈值与两条不变量、上传失败文案。
`assembleDebug` 通过。**未实测**(本端无模拟器、且不给在线后端留测试文件):
真机上的上传成功链路与 Coil 渲染远程 URL——已静态确认 `coil-network-okhttp` 带
`META-INF/services/coil3.util.FetcherServiceLoaderTarget`(默认 ImageLoader 会自动注册网络
fetcher)、`network_security_config` 放行明文(同源 http URL 可取),但没跑过真机。
This commit is contained in:
1 parent
feeb49ebeb
commit
cc2f266564
11 files changed
+869
-6
No files matched your search
@@ -0,0 +1,51 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
/** 一张**已重编码**的图片该怎么送上去。 */
|
||||
enum class ImageDelivery {
|
||||
/** 直接把 `data:` URI 塞进 `chat.image`:一次请求,但字节永久留在历史里。 */
|
||||
Inline,
|
||||
|
||||
/** 先 `POST /api/v1/upload` 换 URL,再把 URL 当 `chat.image`:多一次往返,历史里只留几十字节。 */
|
||||
Upload,
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联(base64)与上传(URL)的分界(纯函数,VM 与测试共用)。
|
||||
*
|
||||
* ## 这是一笔什么账
|
||||
* 服务端把 `chat.image` 字符串**原样落库**,所以两边的成本不是「发一次」而是「发多少次」:
|
||||
*
|
||||
* | | 发出时 | 之后每一次历史取数 |
|
||||
* |---|---|---|
|
||||
* | 内联 | 请求体里带 base64(4/3 × B 字符) | **再带一遍**(REST 每页、WS `history` 每次回放、每次上拉) |
|
||||
* | 上传 | 多**一次** HTTP 往返;字节同样只传一遍 | 只剩那个 URL(几十字节) |
|
||||
*
|
||||
* 也就是说:上传不是「省一次流量」,是**用一次往返买断此后所有的历史重量**。
|
||||
*
|
||||
* ## 阈值:64 KiB([INLINE_MAX_BYTES],含等于)
|
||||
* 重编码后的字节数 `B`,内联的代价是每次历史取数再多 `4/3 × B`:
|
||||
* - `B = 64 KiB` → 每次取数 +85 KB。一个会话里 20 张这种图,每次冷启动 / 重连回放就要多传
|
||||
* ≥1.7 MB(1 Mbps 下约 14 秒纯图片流量),而那次上传往返在局域网是毫秒、在移动网也就是
|
||||
* 一两百毫秒——**从第二次取数起就稳赚**。
|
||||
* - `B ≈ 32 KiB`(缩略图、小截图)→ 每次取数 +43 KB,省下的量与一次往返本身的量级相当,
|
||||
* 而多出来的一次请求**必然**多一个失败面(上传失败就得让用户重发,内联不会)。
|
||||
* 这种量级下「一次请求发完」更划算,所以留在内联。
|
||||
*
|
||||
* 阈值取整到 64 KiB 而不是更细的数,是因为它本来就是个成本量级的判断,不是服务端限制:
|
||||
* 服务端的两个 5 MiB 上限(`chat.image` 与 `/upload`)离这里都还差两个数量级,
|
||||
* **本决策与「装不装得下」无关**(重编码出口已保证 ≤ [ChatImageBudget.MAX_DECODED_BYTES])。
|
||||
*/
|
||||
object ChatImageDelivery {
|
||||
|
||||
/**
|
||||
* 内联与上传的分界:**重编码后**的字节数(含等于 → 内联)。
|
||||
*
|
||||
* 必须严格小于 [ChatImageBudget.MAX_DECODED_BYTES],否则上传分支永远走不到(死代码);
|
||||
* 这一条由 `ChatImageDeliveryTest` 钉住。
|
||||
*/
|
||||
const val INLINE_MAX_BYTES = 64 * 1024
|
||||
|
||||
/** @param preparedBytes [top.yeij.cyrene.data.image.ChatImagePreparer] 产出的字节数。 */
|
||||
fun forPreparedBytes(preparedBytes: Int): ImageDelivery =
|
||||
if (preparedBytes <= INLINE_MAX_BYTES) ImageDelivery.Inline else ImageDelivery.Upload
|
||||
}
|
||||
Reference in new issue
Block a user