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.ChatApi
import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.MetaApi
import top.yeij.cyrene.data.api.SessionsApi 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.AdminRepository
import top.yeij.cyrene.data.repository.ChatRepository import top.yeij.cyrene.data.repository.ChatRepository
import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.MetaRepository
import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.repository.SessionRepository
import top.yeij.cyrene.data.repository.UploadRepository
import top.yeij.cyrene.data.ws.ChatSocket import top.yeij.cyrene.data.ws.ChatSocket
/** 手写 DI 容器(D:不引 Hilt)。 */ /** 手写 DI 容器(D:不引 Hilt)。 */
@@ -76,6 +78,31 @@ class AppContainer(context: Context) {
val chatRepository: ChatRepository get() = ChatRepository(chatApi) 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 cachedAdminApi: AdminApi? = null
private var cachedAdminBaseUrl: String? = 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 top.yeij.cyrene.domain.chat.ChatImageBudget
import java.io.ByteArrayOutputStream 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(**发送前的必经一步**)。 * 把用户选的本地图片压成可以内联发送的 `data:` URI(**发送前的必经一步**)。
* *
@@ -23,6 +36,10 @@ import java.io.ByteArrayOutputStream
* 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错, * 任何一步失败(读不到 / 解不出 / 编不出 / 压不到预算内)都返回 `null`——由调用方回滚并报错,
* **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。 * **绝不**退回「原图 base64」:那既会撞服务端 5 MiB 硬顶,也会把历史背成几 MB。
* *
* 产物同时给出 `data:` URI 与**同一份**字节:**内联还是上传由调用方按体积定**
* (见 `ChatImageDelivery`)——本对象只管「编到预算内」,通道选择是另一件事,而且它需要
* 编完之后的真实字节数才谈得上。
*
* 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。 * 预算数学在 [ChatImageBudget](纯函数,可单测);本对象只剩「必须真机才有」的解码与编码。
*/ */
object ChatImagePreparer { object ChatImagePreparer {
@@ -33,10 +50,10 @@ object ChatImagePreparer {
private const val MAX_ATTEMPTS = 4 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 raw = readBytes(context, uri) ?: return null
val decoded = decodeScaled(raw) ?: return null val decoded = decodeScaled(raw) ?: return null
return try { return try {
@@ -80,7 +97,7 @@ object ChatImagePreparer {
} }
/** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */ /** 逐档降尺寸,直到编码后字节数落进预算;都落不进返回 `null`。 */
private fun encodeWithinBudget(source: Bitmap): String? { private fun encodeWithinBudget(source: Bitmap): PreparedImage? {
var width = source.width var width = source.width
var height = source.height var height = source.height
repeat(MAX_ATTEMPTS) { attempt -> repeat(MAX_ATTEMPTS) { attempt ->
@@ -92,7 +109,8 @@ object ChatImagePreparer {
if (ChatImageBudget.withinTarget(bytes.size)) { if (ChatImageBudget.withinTarget(bytes.size)) {
val dataUri = ChatImageBudget.dataUri(mime, Base64.encodeToString(bytes, Base64.NO_WRAP)) 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) width = (width / 2).coerceAtLeast(1)
height = (height / 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yeij.cyrene.app.AppContainer 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.CyreneException
import top.yeij.cyrene.data.api.dto.ChatRequest import top.yeij.cyrene.data.api.dto.ChatRequest
import top.yeij.cyrene.data.api.dto.HistoryMessageDto import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.image.ChatImagePreparer 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.ChatAck
import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MessageRepository
import top.yeij.cyrene.data.repository.historyForSession 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.WsEvent
import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.data.ws.isForSession 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.ChatSendRouting
import top.yeij.cyrene.domain.chat.ImageDelivery
import top.yeij.cyrene.domain.chat.SendChannel import top.yeij.cyrene.domain.chat.SendChannel
import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.ParsedBubble
import top.yeij.cyrene.domain.parse.StreamingContentParser import top.yeij.cyrene.domain.parse.StreamingContentParser
@@ -219,6 +223,9 @@ data class ChatUiState(
* *
* 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走 * 上行两条通道(路由见 [ChatSendRouting]):短文本走 WS `chat` 帧,长文本 / 任何图片走
* REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。 * REST `POST /api/v1/chat`(`stream:true`)——**收都在 WS 上**,所以两种发的体验一致。
* 图片在 REST 之前还多一层选择:小图内联成 `data:` URI,大图先 `POST /api/v1/upload`
* 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页
* 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。
* *
* 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅, * 本 VM 只收**属于 [sessionId] 的帧**(见 [onEvent] 的会话闸)。服务端广播不按会话订阅,
* 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。 * 且每个会话各有一个 VM(各自的 socket 都还活着),没有这道闸就会串台。
@@ -287,7 +294,9 @@ class ChatViewModel(
* 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。 * 回来**(同一个编排器 + 广播路径),所以「REST 发、WS 收」不牺牲流式体验。
* *
* 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上; * 图片先在本端解码 / 缩放 / 重编码([ChatImagePreparer]),这一步是重活,放在 IO 线程上;
* 气泡同步上屏,所以等待期间用户已经能看到它。 * 气泡同步上屏,所以等待期间用户已经能看到它。重编码之后再按体积决定**内联还是上传**
* (见 [deliverImage] 与 [ChatImageDelivery])——大图宁可多走一次 `POST /api/v1/upload`,
* 也不把几 MB base64 永久留在每一页历史里。
*/ */
fun send() { fun send() {
val snapshot = _state.value val snapshot = _state.value
@@ -324,7 +333,30 @@ class ChatViewModel(
if (!sent) rollback(localId, "发送失败,请重试") 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 = private fun Throwable.userMessage(): String =
(this as? CyreneException)?.error?.userMessage ?: "加载失败,请稍后重试" (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 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("上传失败") },
)
}
}