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,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"
|
||||
Reference in new issue
Block a user