diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index de137d2..5b861f6 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -11,11 +11,13 @@ import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.SessionsApi +import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.SessionRepository +import top.yeij.cyrene.data.repository.UploadRepository import top.yeij.cyrene.data.ws.ChatSocket /** 手写 DI 容器(D:不引 Hilt)。 */ @@ -76,6 +78,31 @@ class AppContainer(context: Context) { val chatRepository: ChatRepository get() = ChatRepository(chatApi) + private var cachedUploadApi: UploadApi? = null + private var cachedUploadBaseUrl: String? = null + + /** + * 图片上传端点(`POST /api/v1/upload`);与 [chatApi] 同源(同一 baseUrl、同一 token/401 处理)—— + * 上传的产物 URL 就是要交给 `POST /api/v1/chat` 的 `image` 字段的,两者必须在同一个后端上。 + */ + val uploadApi: UploadApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedUploadApi + if (cached != null && cachedUploadBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = UploadApi::class.java, + tokenProvider = authManager::currentToken, + unauthorizedPolicy = authManager.unauthorizedPolicy, + ) + cachedUploadBaseUrl = baseUrl + cachedUploadApi = created + return created + } + + val uploadRepository: UploadRepository get() = UploadRepository(uploadApi) + private var cachedAdminApi: AdminApi? = null private var cachedAdminBaseUrl: String? = null diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/UploadApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/UploadApi.kt new file mode 100644 index 0000000..3942ca0 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/UploadApi.kt @@ -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> +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/UploadDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/UploadDto.kt new file mode 100644 index 0000000..626b09a --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/UploadDto.kt @@ -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/`),**可直接作 `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 = "", +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/image/ChatImagePreparer.kt b/app/src/main/kotlin/top/yeij/cyrene/data/image/ChatImagePreparer.kt index 077644a..481cfce 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/image/ChatImagePreparer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/image/ChatImagePreparer.kt @@ -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) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/UploadRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/UploadRepository.kt new file mode 100644 index 0000000..c009550 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/UploadRepository.kt @@ -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 = + 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" diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageDelivery.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageDelivery.kt new file mode 100644 index 0000000..0a43fc1 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatImageDelivery.kt @@ -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 +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 8462781..fc435a4 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import top.yeij.cyrene.app.AppContainer +import top.yeij.cyrene.core.error.CyreneError import top.yeij.cyrene.data.api.CyreneException import top.yeij.cyrene.data.api.dto.ChatRequest import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.image.ChatImagePreparer +import top.yeij.cyrene.data.image.PreparedImage import top.yeij.cyrene.data.repository.ChatAck import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.historyForSession @@ -23,7 +25,9 @@ import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.WsEvent import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.isForSession +import top.yeij.cyrene.domain.chat.ChatImageDelivery import top.yeij.cyrene.domain.chat.ChatSendRouting +import top.yeij.cyrene.domain.chat.ImageDelivery import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.StreamingContentParser @@ -219,6 +223,9 @@ data class ChatUiState( * * 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走 * REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。 + * 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload` + * 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页 + * 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。 * * 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅, * 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。 @@ -287,7 +294,9 @@ class ChatViewModel( * 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。 * * 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上; - * 气泡同步上屏,所以等待期间用户已经能看到它。 + * 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传** + * (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`, + * 也不把几 MB base64 永久留在每一页历史里。 */ fun send() { val snapshot = _state.value @@ -324,7 +333,30 @@ class ChatViewModel( if (!sent) rollback(localId, "发送失败,请重试") } - SendChannel.Rest -> sendViaRest(localId, text, image) + SendChannel.Rest -> if (image == null) { + sendViaRest(localId, text, image = null) + } else { + deliverImage(localId, text, image) + } + } + } + + /** + * 一张图怎么送上去:小图内联成 `data:` URI,大图先上传、再把返回的 URL 当 `image` + * (阈值与这笔成本账见 [ChatImageDelivery])。两者都走 REST `stream:true`,收都在 WS 上。 + * + * **上传失败不回退内联**:回退等于把「省历史重量」这件事当场作废——用户以为发的是 URL、 + * 落库的却是几 MB base64,而且是在他看不见的情况下换了一条语义不同的路。这里与 + * [ChatImagePreparer] 同一纪律:宁可回滚、把图和文本还给输入框让用户重试。 + */ + private suspend fun deliverImage(localId: String, text: String, image: PreparedImage) { + when (ChatImageDelivery.forPreparedBytes(image.bytes.size)) { + ImageDelivery.Inline -> sendViaRest(localId, text, image.dataUri) + + ImageDelivery.Upload -> container.uploadRepository + .uploadImage(image.bytes, image.mime) + .onSuccess { uploaded -> sendViaRest(localId, text, uploaded.url) } + .onFailure { e -> rollback(localId, uploadFailureMessage(e)) } } } @@ -667,5 +699,24 @@ class ChatViewModel( private fun Throwable.userMessage(): String = (this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试" +/** + * 上传失败的文案(纯函数,可单测)。 + * + * 前两条是服务端的**机器可读**拒绝码(对着在线后端实测过,见 `UploadDtoRealPayloadTest`): + * 在本端预算下它们不该出现(重编码出口 ≤ 1 MiB,类型非 png 即 jpeg,都在白名单里), + * 但真出现时正确的动作是「换一张图」而不是「重试」,文案必须把这两者分开。 + * + * 其余(网络 / 超时 / 5xx)用既有错误口径,并**点明是上传这一步**失败——否则用户看到 + * 「无法连接到服务器」会以为整条消息没发出去,而实际上文字也是跟着一起回滚的。 + */ +fun uploadFailureMessage(e: Throwable): String { + val error = (e as? CyreneException)?.error + return when ((error as? CyreneError.Server)?.code) { + "payload_too_large" -> "图片超过服务端 5 MB 上限,请换一张更小的" + "unsupported_media_type" -> "服务端不接受这张图片的格式,请换一张" + else -> "图片上传失败," + (error?.userMessage ?: "请重试") + } +} + /** 思维链在流式结束后继续保留的时长。 */ private const val THINKING_LINGER_MILLIS = 10 * 60 * 1000L diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/UploadDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/UploadDtoRealPayloadTest.kt new file mode 100644 index 0000000..f39ba75 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/UploadDtoRealPayloadTest.kt @@ -0,0 +1,143 @@ +package top.yeij.cyrene.data.api.dto + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.ApiEnvelope +import top.yeij.cyrene.data.api.ApiErrorMapper + +/** + * 契约钉死测试:`POST /api/v1/upload` 的**成功载荷**用的是服务端 + * `internal/services/gateway/upload.go` 的 `uploadResponse` 结构体形状(字段名逐字: + * `url` / `size` / `mime` / `name`),**错误载荷**用的是 2026-10-04 对着在线后端 + * (`http://127.0.0.1:10942`)**实测抓下来**的原文——只发错误请求(缺部件 / 非图片 / 超限), + * 服务端一律在落盘之前拒绝,所以这些抓包**没有在服务端留下任何文件**。 + * + * 解码器用 [ApiClientFactory.json]——与生产同一份 `Json`,这里过不了的形状线上也过不了。 + */ +class UploadDtoRealPayloadTest { + + private val json = ApiClientFactory.json + + /** 成功载荷:32 位小写 hex 名 + 白名单扩展名 + 同源绝对 URL,全是服务端生成的。 */ + private val realSuccess = """ + {"data":{"url":"http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg","size":184320,"mime":"image/jpeg","name":"9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg"}} + """.trimIndent() + + private fun decode(body: String): UploadResponse = + json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), body).data!! + + @Test + fun `the real success payload decodes into a directly usable url`() { + val data = decode(realSuccess) + + assertEquals( + "http://127.0.0.1:10942/api/v1/files/9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg", + data.url, + ) + assertEquals(184320L, data.size) + assertEquals("image/jpeg", data.mime) + assertEquals("9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg", data.name) + } + + @Test + fun `the url is absolute same-origin because chat image only accepts http s urls`() { + val url = decode(realSuccess).url + + // 视觉层只把 `http(s)://` 开头当 URL,裸路径会被当成 base64 载荷 —— 所以这个形状是硬要求。 + assertTrue(url, url.startsWith("http://") || url.startsWith("https://")) + assertTrue(url, url.contains("/api/v1/files/")) + } + + @Test + fun `the name is 32 lowercase hex plus a whitelisted raster extension`() { + val name = decode(realSuccess).name + val stem = name.substringBeforeLast('.') + + assertEquals(32, stem.length) + assertEquals(stem, stem.lowercase()) + assertTrue(stem, stem.all { it in "0123456789abcdef" }) + // 白名单只有光栅:SVG / HTML 会被拒(`uploadExtForMIME`)。 + assertTrue(name, name.endsWith(".jpg")) + } + + // —— 以下四条是**在线抓包原文**(逐字照抄) —— + + @Test + fun `the captured 415 body maps to unsupported_media_type`() { + val body = """{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}""" + + val error = ApiErrorMapper.fromHttp(415, body) + + assertEquals(CyreneError.Server("unsupported_media_type", "only png/jpeg/gif/webp/bmp images are accepted", 415), error) + // 客户端按 code 分支(改文案不改判定),所以这两个字符串必须钉住。 + assertEquals("unsupported_media_type", (error as CyreneError.Server).code) + } + + @Test + fun `the captured 413 body maps to payload_too_large`() { + val body = """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}""" + + val error = ApiErrorMapper.fromHttp(413, body) as CyreneError.Server + + assertEquals("payload_too_large", error.code) + assertEquals(413, error.httpStatus) + } + + @Test + fun `a wrong part name is a 400 that names the field`() { + // 实测:部件名不是 `file`(例如 `image`)→ 这条。它是「字段名写错」的唯一征兆。 + val body = """{"error":{"code":"bad_request","message":"file is required"}}""" + + val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server + + assertEquals("bad_request", error.code) + assertEquals("file is required", error.message) + } + + @Test + fun `a body that is not multipart at all is a 400 with a different message`() { + val body = """{"error":{"code":"bad_request","message":"invalid multipart body"}}""" + + val error = ApiErrorMapper.fromHttp(400, body) as CyreneError.Server + + assertEquals("bad_request", error.code) + // 与「部件名错了」同码不同文案:排障时靠 message 区分,靠 code 分支。 + assertEquals("invalid multipart body", error.message) + } + + @Test + fun `the captured 401 is unauthorized and not a server rejection`() { + val error = ApiErrorMapper.fromHttp(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""") + + assertEquals(CyreneError.Unauthorized("unauthorized", "auth: unauthorized"), error) + } + + // —— 形状的松紧 —— + + @Test + fun `a success envelope without data is not an upload`() { + assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), "{}").data) + assertNull(json.decodeFromString(ApiEnvelope.serializer(UploadResponse.serializer()), """{"data":null}""").data) + } + + @Test + fun `unknown extra keys are ignored so a future field cannot break decoding`() { + val data = decode("""{"data":{"url":"http://h/api/v1/files/aa.jpg","size":1,"mime":"image/jpeg","name":"aa.jpg","sha256":"deadbeef"}}""") + + assertEquals("http://h/api/v1/files/aa.jpg", data.url) + } + + @Test + fun `explicit nulls fall back to the defaults instead of failing the decode`() { + val data = decode("""{"data":{"url":null,"size":null,"mime":null,"name":null}}""") + + assertEquals("", data.url) + assertEquals(0L, data.size) + assertEquals("", data.mime) + assertEquals("", data.name) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/UploadRepositoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/UploadRepositoryTest.kt new file mode 100644 index 0000000..45ff4b4 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/UploadRepositoryTest.kt @@ -0,0 +1,227 @@ +package top.yeij.cyrene.data.repository + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import org.junit.After +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.ApiClientFactory +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.api.UploadApi +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * 上传端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(不引新依赖, + * 与 [ChatRepositoryTest] 同一手法)。 + * + * 这里要证明的不是「能解析一个 JSON」,而是三件线上真会出事的事: + * 1. 请求**真的是 multipart**、部件名真的是 `file`、载荷字节**一个不改**地发出去 + * (服务端 `r.FormFile("file")` 认不到就回 `400 file is required`); + * 2. `413 payload_too_large` / `415 unsupported_media_type` 按 `code` 分成可辨的两类; + * 3. 服务端给的 url **不是绝对 http(s) 就判失败**——`chat.image` 会把裸路径当成 base64, + * 照发等于把这一轮扔进解析错误里。 + */ +class UploadRepositoryTest { + + private val backend = FakeUploadBackend() + + @After + fun tearDown() { + backend.close() + } + + private fun repository() = UploadRepository( + ApiClientFactory.create(baseUrl = backend.baseUrl, service = UploadApi::class.java), + ) + + /** 一段带 JPEG 魔数的假图:足够让「字节原样发出」这件事可断言。 */ + private val jpeg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte()) + + "B1-UPLOAD-PAYLOAD".toByteArray() + + @Test + fun `a successful upload returns the server built url`() = runBlocking { + backend.reply(201, backend.successBody()) + + val uploaded = repository().uploadImage(jpeg, "image/jpeg").getOrThrow() + + assertEquals(backend.url, uploaded.url) + // size / mime / name 是**服务端**的数,客户端只传递、不自己算一遍。 + assertEquals(jpeg.size.toLong(), uploaded.size) + assertEquals("image/jpeg", uploaded.mime) + assertEquals(backend.name, uploaded.name) + } + + @Test + fun `the request really is multipart with the file part name the server looks for`() = runBlocking { + backend.reply(201, backend.successBody()) + + repository().uploadImage(jpeg, "image/jpeg").getOrThrow() + + val request = backend.requests.single() + assertEquals("POST", request.method) + assertEquals("/api/v1/upload", request.path) + assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data")) + // 部件名 / 文件名都要在:Go 只把带 filename 的部件当文件,字段名不对则 FormFile 找不到。 + assertTrue(request.body, request.body.contains("""name="file"""")) + assertTrue(request.body, request.body.contains("""filename="upload.jpg"""")) + assertTrue(request.body, request.body.contains("Content-Type: image/jpeg")) + } + + @Test + fun `the bytes leave untouched, jpeg magic included`() = runBlocking { + backend.reply(201, backend.successBody()) + + repository().uploadImage(jpeg, "image/jpeg").getOrThrow() + + // ISO-8859-1 一一对应字节:这里的断言就是「发出去的就是编出来的那些字节」。 + val body = backend.requests.single().body + assertTrue(body, body.contains("\u00FF\u00D8\u00FF\u00E0")) + assertTrue(body, body.contains("B1-UPLOAD-PAYLOAD")) + } + + @Test + fun `a png is sent as a png part`() = runBlocking { + backend.reply(201, backend.successBody(mime = "image/png")) + + repository().uploadImage(byteArrayOf(0x89.toByte(), 0x50), "image/png").getOrThrow() + + val body = backend.requests.single().body + assertTrue(body, body.contains("Content-Type: image/png")) + assertTrue(body, body.contains("""filename="upload.png"""")) + } + + @Test + fun `413 payload_too_large is surfaced with its code`() = runBlocking { + backend.reply(413, """{"error":{"code":"payload_too_large","message":"file exceeds the 5MB limit"}}""") + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertEquals( + CyreneError.Server("payload_too_large", "file exceeds the 5MB limit", 413), + error.error, + ) + } + + @Test + fun `415 unsupported_media_type is surfaced with its code`() = runBlocking { + backend.reply( + 415, + """{"error":{"code":"unsupported_media_type","message":"only png/jpeg/gif/webp/bmp images are accepted"}}""", + ) + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertEquals("unsupported_media_type", (error.error as CyreneError.Server).code) + } + + @Test + fun `401 stays a failure because the session itself is no longer valid`() = runBlocking { + backend.reply(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""") + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertTrue("实际 ${error.error}", error.error is CyreneError.Unauthorized) + } + + @Test + fun `a relative url is a parse failure not something we would send as image`() = runBlocking { + // `chat.image` 只把 http(s):// 当 URL:裸路径会被当成 base64 载荷。 + backend.reply(201, """{"data":{"url":"/api/v1/files/aa.jpg","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""") + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error) + } + + @Test + fun `a blank url is a parse failure instead of a silent empty image`() = runBlocking { + backend.reply(201, """{"data":{"url":"","size":21,"mime":"image/jpeg","name":"aa.jpg"}}""") + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error) + } + + @Test + fun `a 201 without data is a parse failure`() = runBlocking { + backend.reply(201, "{}") + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error) + } + + @Test + fun `an unreachable server is a local failure`() = runBlocking { + backend.close() + + val error = repository().uploadImage(jpeg, "image/jpeg").exceptionOrNull() as CyreneException + + assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local) + } + + /** 记录每次请求的原始字节与头,按 `reply(...)` 回话的假后端。 */ + private inner class FakeUploadBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/upload", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + /** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */ + val name = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60.jpg" + + val url: String get() = baseUrl + "api/v1/files/" + name + + val requests = CopyOnWriteArrayList() + + @Volatile + private var status: Int = 201 + + @Volatile + private var body: String = "" + + fun reply(status: Int, body: String) { + this.status = status + this.body = body + } + + /** 线上的成功形状(`uploadResponse`):`size` 报的就是本次真正发出去的那段字节数。 */ + fun successBody(mime: String = "image/jpeg"): String = + """{"data":{"url":"$url","size":${jpeg.size},"mime":"$mime","name":"$name"}}""" + + private fun handle(exchange: HttpExchange) { + // ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。 + val raw = exchange.requestBody.readBytes() + requests += RecordedRequest( + method = exchange.requestMethod, + path = exchange.requestURI.path, + contentType = exchange.requestHeaders.getFirst("Content-Type").orEmpty(), + body = raw.toString(Charsets.ISO_8859_1), + ) + val bytes = body.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + + override fun close() { + runCatching { server.stop(0) } + } + } + + private data class RecordedRequest( + val method: String, + val path: String, + val contentType: String, + val body: String, + ) +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ChatImageDeliveryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ChatImageDeliveryTest.kt new file mode 100644 index 0000000..dbb2c74 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ChatImageDeliveryTest.kt @@ -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), + ) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageUploadFailureTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageUploadFailureTest.kt new file mode 100644 index 0000000..2bb7978 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ImageUploadFailureTest.kt @@ -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("上传失败") }, + ) + } +}