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:
AskaEth committed 2026-10-04 17:40:12 +08:00
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
}