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,89 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
|
||||
/**
|
||||
* 上传失败的文案(纯函数)。
|
||||
*
|
||||
* 这里锚的是**行为承诺**,不是措辞本身:上传失败时这条消息**没有发出去**(文本与图一起
|
||||
* 回到输入框),而且**绝不**退回内联 base64——退回就等于把「省下历史重量」当场作废,
|
||||
* 还是在用户看不见的情况下换了一条语义不同的路。
|
||||
*
|
||||
* 两个服务端拒绝码对应的是「换一张图」而不是「重试」,必须与网络失败分开说。
|
||||
*/
|
||||
class ImageUploadFailureTest {
|
||||
|
||||
private fun server(code: String, message: String, status: Int = 400) =
|
||||
CyreneException(CyreneError.Server(code, message, status))
|
||||
|
||||
@Test
|
||||
fun `payload_too_large asks for a smaller image, not a retry`() {
|
||||
val message = uploadFailureMessage(server("payload_too_large", "file exceeds the 5MB limit", 413))
|
||||
|
||||
assertEquals("图片超过服务端 5 MB 上限,请换一张更小的", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported_media_type says the format is refused`() {
|
||||
val message = uploadFailureMessage(
|
||||
server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415),
|
||||
)
|
||||
|
||||
assertEquals("服务端不接受这张图片的格式,请换一张", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network failure names the step that failed and keeps the reason`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)))
|
||||
|
||||
assertEquals("图片上传失败,网络不可用,请检查连接", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timeout keeps its own wording`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)))
|
||||
|
||||
assertEquals("图片上传失败,连接超时,服务器没有响应", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server error keeps the server message instead of inventing one`() {
|
||||
val message = uploadFailureMessage(server("upload_failed", "failed to store upload", 500))
|
||||
|
||||
assertEquals("图片上传失败,failed to store upload", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session is still an upload failure, never a silent success`() {
|
||||
val message = uploadFailureMessage(CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")))
|
||||
|
||||
assertTrue(message, message.startsWith("图片上传失败"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non Cyrene throwable still produces an actionable message`() {
|
||||
assertEquals("图片上传失败,请重试", uploadFailureMessage(IllegalStateException("boom")))
|
||||
assertEquals("图片上传失败,请重试", uploadFailureMessage(RuntimeException()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failure path ever falls back to inlining the image`() {
|
||||
// 文案里绝不能出现「改用内联 / 直接发送」这类暗示:回退内联正是要消灭的成本。
|
||||
val messages = listOf(
|
||||
uploadFailureMessage(server("payload_too_large", "x", 413)),
|
||||
uploadFailureMessage(server("unsupported_media_type", "x", 415)),
|
||||
uploadFailureMessage(server("upload_failed", "x", 500)),
|
||||
uploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.ConnectFailed))),
|
||||
uploadFailureMessage(IllegalStateException()),
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
messages.toString(),
|
||||
messages.all { it.contains("换一张") || it.contains("重试") || it.contains("上传失败") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user