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,33 @@
package top.yeij.cyrene.data.api
import top.yeij.cyrene.data.api.dto.UploadResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
/**
* 图片上传端点(`POST /api/v1/upload`,api-client §1 行 13)。权限 `owner|admin`。
*
* **上传本身不是目的**:服务端把 `chat.image` 字符串**原样落库**,于是每一页历史与每一次
* WS `history` 回放都要把整段 base64 再传一遍(协同 G3)。上传一次、消息里只留 URL,
* 是这条链路上唯一能把「历史重量」永久压下去的手段。
*
* 与 [ChatApi] 分开:它的请求体是 multipart(不是 JSON)、错误面也完全不同
* (`413 payload_too_large` / `415 unsupported_media_type` 是它的日常回执)。
*
* 返回原始 [Response] 而不是直接折成 `Result`:与其它端点一样由仓库层统一走
* [ApiErrorMapper],状态码在这里**不承载额外语义**(`413`/`415` 的机器可读区分在
* `error.code` 里,所以仓库能用 [apiCall])。
*/
interface UploadApi {
/**
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`):字段名错了会回
* `400 {error:{code:"bad_request",message:"file is required"}}`(已对着在线后端实测)。
*/
@Multipart
@POST("api/v1/upload")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<UploadResponse>>
}
@@ -0,0 +1,31 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* `POST /api/v1/upload` 的成功载荷(api-client §1 行 13,v0.24 起的 B1 端点)。
*
* 线上形状(`internal/services/gateway/upload.go` 的 `uploadResponse`,字段名逐字如此):
* ```
* 201 {"data":{"url":"http://192.168.1.10:10942/api/v1/files/<32hex>.png","size":1234,"mime":"image/png","name":"<32hex>.png"}}
* ```
*
* 语义要点:
* - [url] 是**绝对同源地址**(`scheme://host/api/v1/files/<name>`),**可直接作 `chat.image`**:
* 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 —— 所以这个字段必须按 URL 校验,
* 不能原样塞回去(见 `UploadRepository`);
* - [name] 是服务端生成的 **32 位小写 hex + 白名单扩展名**,客户端原始文件名**被丢弃**(不可信输入);
* - [size] 字节数与 [mime] 都是**服务端嗅探/统计的结果**,不是客户端声明的——客户端传的
* `Content-Type` 一律不被采信(`sniffImageMIME`)。
*
* 全部字段带默认值:解码器是生产同一份 `Json`(`ignoreUnknownKeys` / `coerceInputValues`),
* 将来服务端加键不会让整次上传变成解析失败。**缺 `url`(或 `url` 不是绝对 http(s))仍要判失败**——
* 那是「上传成功但拿不到可用地址」,由仓库层拦,绝不把空串/路径当 URL 发出去。
*/
@Serializable
data class UploadResponse(
val url: String = "",
val size: Long = 0,
val mime: String = "",
val name: String = "",
)
@@ -8,6 +8,19 @@ import android.util.Base64
import top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream
/**
* 一次重编码的产物。
*
* [dataUri] 与 [bytes] 是**同一张图**的两种形态:内联(base64 `data:` URI)用前者,
* 上传(`POST /api/v1/upload`)用后者。上传时**不能**从 [dataUri] 反解一遍——那既多一次
* 解码与分配,也让「上传的字节」和「内联的字节」有漂移的余地。
*/
class PreparedImage(
val dataUri: String,
val bytes: ByteArray,
val mime: String,
)
/**
* 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
*
@@ -23,6 +36,10 @@ import java.io.ByteArrayOutputStream
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
*
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
* 编完之后的真实字节数才谈得上。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/
object ChatImagePreparer {
@@ -33,10 +50,10 @@ object ChatImagePreparer {
private const val MAX_ATTEMPTS = 4
/**
* @return 可直接作为 `image` 字段发送的 `data:` URI;失败返回 `null`。
* @return 可内联发送的 `data:` URI 与可上传的同一份字节;失败返回 `null`。
* 必须在后台线程调用(解码 + 缩放 + 编码都是重活)。
*/
fun prepare(context: Context, uri: Uri): String? {
fun prepare(context: Context, uri: Uri): PreparedImage? {
val raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null
return try {
@@ -80,7 +97,7 @@ object ChatImagePreparer {
}
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): String? {
private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
var width = source.width
var height = source.height
repeat(MAX_ATTEMPTS) { attempt ->
@@ -92,7 +109,8 @@ object ChatImagePreparer {
if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP))
return dataUri.takeIf { ChatImageBudget.withinServerLimit(it.length) }
if (!ChatImageBudget.withinServerLimit(dataUri.length)) return null
return PreparedImage(dataUri = dataUri, bytes = bytes, mime = mime)
}
width = (width / 2).coerceAtLeast(1)
height = (height / 2).coerceAtLeast(1)
@@ -0,0 +1,112 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.UploadApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的可用形态。
*
* 调用方(`ChatViewModel`)只需要 [url],其余三个字段由服务端生成、留着排查用:
* [size] 是服务端统计的字节数、[mime] 是**服务端嗅探**出的类型(不是客户端声明的)、
* [name] 是服务端存储名(客户端原始文件名已被丢弃)。
*/
data class UploadedImage(
val url: String,
val size: Long,
val mime: String,
val name: String,
)
/**
* 图片上传取数层(`POST /api/v1/upload`)。
*
* ## 为什么这台机器存在
* 服务端把 `chat.image` 字符串**原样落库**(api-client §1 行 3 + 协同 G3):内联的 base64
* 会在**每一页历史**与**每一次 WS `history` 回放**里重传一遍。上传一次、消息里只留一个
* URL,是唯一能让历史不再随图片长大的路。
*
* ## 错误语义(这里是本仓库与只读仓库的差别)
* 用 [apiCall]:它的状态码**不承载额外语义**——`413 payload_too_large` 与
* `415 unsupported_media_type` 的机器可读区分都在 `error.code` 里(已对着在线后端实测,
* 见 `UploadDtoRealPayloadTest`),不像 `POST /api/v1/chat` 那样必须靠状态码分 `202` / `409`。
* 于是网络 / 超时 / `401` / 服务端拒绝一律折进 `Result.failure`,调用方按 `code` 或
* [CyreneError] 的类型分支。
*
* ## 两处**客户端**必须自己把关的地方(服务端帮不了)
* 1. **`url` 必须是绝对 http(s) 地址**:`chat.image` 的视觉层只把 `http(s)://` 当 URL
* (`vision.ParseImageRef` 的判据就是 `strings.HasPrefix(src, "http://")`),裸路径
* (如 `/api/v1/files/x.png`)会被当成 **base64** 载荷。拿到这种 url 还照发,就是把这一轮
* 丢进「图片解析失败」的黑洞——所以这里直接判解析失败,宁可当场重试。
* 2. **`url` 不能为空**:`201` 却没有可用地址 = 上传了但拿不到东西,同样判失败,**绝不**
* 退回内联 base64(那正是本任务要消灭的成本)。
*/
class UploadRepository(
private val api: UploadApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/**
* 上传一张**已重编码**的图片,返回可作 `chat.image` 的绝对 URL。
*
* [bytes] 与 [mime] 必须来自同一次编码(`ChatImagePreparer` 的产物):
* 服务端按**字节嗅探**判类型,声明的 `Content-Type` 只作参考,两者不一致时以字节为准。
*/
suspend fun uploadImage(bytes: ByteArray, mime: String): Result<UploadedImage> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, mime)) }.fold(
onSuccess = { data ->
val url = absoluteHttpUrl(data.url)
if (url == null) {
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedImage(url = url, size = data.size, mime = data.mime, name = data.name),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `FormFile("file")`)。文件名也**必须给**:Go 的
* `mime/multipart` 只把带 `filename` 参数的部件当**文件**,不带的会落进普通表单值、
* 于是 `FormFile` 找不到它。扩展名与服务端白名单一致(服务端丢弃原始名,这里只是让
* 线上的部件看起来像一张真图,中间件不会因为怪名字做多余判断)。
*/
private fun partOf(bytes: ByteArray, mime: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileNameFor(mime),
body = bytes.toRequestBody(mime.toMediaTypeOrNull()),
)
private fun fileNameFor(mime: String): String = "upload." + when (mime) {
MIME_PNG -> "png"
MIME_GIF -> "gif"
MIME_WEBP -> "webp"
MIME_BMP -> "bmp"
else -> "jpg"
}
/** 只认绝对 http(s) 地址,且**大小写敏感**(与服务端 `ParseImageRef` 的判据同一口径:放宽只会放行服务端不认的东西)。 */
private fun absoluteHttpUrl(raw: String): String? = raw.trim()
.takeIf { it.startsWith("http://") || it.startsWith("https://") }
}
private const val UPLOAD_FIELD_NAME = "file"
private const val MIME_PNG = "image/png"
private const val MIME_GIF = "image/gif"
private const val MIME_WEBP = "image/webp"
private const val MIME_BMP = "image/bmp"