merge: W3 — B1 /upload(大图走上传,历史只留 URL)

This commit is contained in:
AskaEth committed 2026-10-04 17:44:23 +08:00
commit 664fd8dcef
11 files changed
+869 -6

No files matched your search

@@ -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
@@ -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"
@@ -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
}
@@ -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
@@ -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)
}
}
@@ -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<RecordedRequest>()
@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,
)
}
@@ -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),
)
}
}
@@ -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("上传失败") },
)
}
}