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,81 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 内联 vs 上传的阈值(纯函数)。
*
* 判据是**重编码后的字节数**:它同时决定「这次多走一次往返值不值」与「历史会背多重的 base64」。
* 阈值本身不是服务端限制——服务端的两个 5 MiB 上限离这里还有两个数量级,所以下面的不变量
* 才是重点:**上传分支必须够得着(不是死代码)、内联分支必须永远送得出去**。
*/
class ChatImageDeliveryTest {
@Test
fun `small images stay inline`() {
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(0))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(1))
assertEquals(ImageDelivery.Inline, ChatImageDelivery.forPreparedBytes(32 * 1024))
}
@Test
fun `the threshold is inclusive on the inline side`() {
assertEquals(
ImageDelivery.Inline,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES),
)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageDelivery.INLINE_MAX_BYTES + 1),
)
}
@Test
fun `the threshold is 64 KiB`() {
// 一个成本量级上的取整值:每次历史取数多 4/3 × B,64 KiB 时约 +85 KB/次(见下一条)。
assertEquals(64 * 1024, ChatImageDelivery.INLINE_MAX_BYTES)
}
@Test
fun `at the threshold one history fetch pays at least the number the docs claim`() {
// 类注释里「+85 KB」这个数的出处:base64 膨胀 4/3 后是 87384 字符。
assertEquals(87384, ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES))
assertTrue(ChatImageBudget.base64Chars(ChatImageDelivery.INLINE_MAX_BYTES) > 85 * 1000)
}
@Test
fun `the upload branch is reachable but not mandatory for everything`() {
// 阈值得低于重编码出口的上限,否则「上传」是死代码;但也不能低到把一切都推去上传。
assertTrue(
"阈值得小于本端预算 ${ChatImageBudget.MAX_DECODED_BYTES},否则上传分支永远走不到",
ChatImageDelivery.INLINE_MAX_BYTES < ChatImageBudget.MAX_DECODED_BYTES,
)
assertTrue(
"阈值也不能小到把缩略图也推去上传",
ChatImageDelivery.INLINE_MAX_BYTES * 4 <= ChatImageBudget.MAX_DECODED_BYTES,
)
}
@Test
fun `the inline branch always fits the server cap by construction`() {
// 内联的字节上限就是本端预算:它的 base64 + 前缀余量必须仍在服务端字符上限内。
val worstChars = ChatImageBudget.base64Chars(ChatImageBudget.MAX_DECODED_BYTES) +
ChatImageBudget.PREFIX_ALLOWANCE_CHARS
assertTrue(ChatImageBudget.withinServerLimit(worstChars))
assertTrue(ChatImageBudget.withinTarget(ChatImageBudget.MAX_DECODED_BYTES))
}
@Test
fun `the choice is about history weight, not about fitting under a cap`() {
// 重编码出口比服务端硬顶低 5 倍:所以「走上传」从来不是为了装得下,
// 而是为了让每一页历史 / 每一次 WS history 回放不再重传这段 base64。
assertTrue(ChatImageBudget.MAX_DECODED_BYTES * 5 <= ChatImageBudget.SERVER_MAX_DECODED_BYTES)
assertEquals(
ImageDelivery.Upload,
ChatImageDelivery.forPreparedBytes(ChatImageBudget.MAX_DECODED_BYTES),
)
}
}