merge: W6 — 文档进对话数据层(/documents + chat.documents,v0.59 形状)

This commit is contained in:
AskaEth committed 2026-10-04 18:08:20 +08:00
commit a80dac7a7e
13 files changed
+1498 -1

No files matched your search

@@ -0,0 +1,68 @@
package top.yeij.cyrene.data.api
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Streaming
import top.yeij.cyrene.data.api.dto.DocumentUploadResponse
/**
* 文档存储端点(契约 **v0.59 待发布**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S1)。
* 权限 `owner|admin`。与图片链路([UploadApi] + `/api/v1/files/{name}`)**刻意分离**:
*
* | | 图片 | 文档 |
* |---|---|---|
* | 上传字段名 | `file` | `file`(同) |
* | 上限 | 5 MiB | 20 MiB(`CHAT_DOCUMENT_MAX_BYTES` 默认,**与图片上限解耦**) |
* | 读侧鉴权 | **不需要**(名字即凭据,供 `<img>`/provider 直接取) | **要 Bearer `owner|admin`** |
* | 读侧响应 | 可内联渲染 | `Content-Disposition: attachment` + `nosniff`(**永不渲染**) |
* | 成功载荷的 url | **绝对**同源地址,可直接当 `chat.image` | **相对**路径,客户端要的是 `name` |
*
* 为什么文档读侧要鉴权而图片不要:文档的摄取在**服务端**完成(正文进上下文),
* 客户端不需要把一个「名字即凭据」的读侧打开;图片则必须被 `<img>` 与 provider 直接消费。
*
* ## 鉴权拦截器这一侧没问题的依据
* Bearer 不是按接口挂的,而是**按 OkHttpClient** 挂的:[ApiClientFactory.create] 给每个
* 客户端都装了 `AuthInterceptor`(`Authorization: Bearer <token>` + 401 单飞续期),
* 因此只要 [DocumentRepository] 拿到的 `DocumentsApi` 是从 `ApiClientFactory.create` 建的
* (`AppContainer` 里照 [UploadApi] 的写法),**两个端点都自带 Bearer**,不需要额外加头。
*/
interface DocumentsApi {
/**
* 上传一份文档。
*
* @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`);**文件名必须带扩展名**,
* 且该扩展名要落在服务端的 deny-by-default 白名单里——与图片不同,这里的名字
* **真的参与裁决**(白名单扩展名 + 字节嗅探**合取**,两者都过才放行)。
* 服务端随后丢弃该名字,按内容寻址重命名为 `<sha256[:32]><ext>`,所以名字里
* 除了扩展名以外的内容不会被采用(也不会进路径)。
*
* 返回原始 [Response]:**`200` 不是错误**——同一份字节重复上传回 `200` + 同一个 `name` +
* `deduped:true`,客户端必须当成功。状态码本身不承载额外语义(`413`/`415` 的区分在
* `error.code` 里),所以仓库层可以直接走 [apiCall]。
*/
@Multipart
@POST("api/v1/documents")
suspend fun upload(@Part file: MultipartBody.Part): Response<ApiEnvelope<DocumentUploadResponse>>
/**
* 取回一份文档的原始字节。
*
* 它**不是信封**(响应体就是文件本身),所以返回 [ResponseBody]:解码器只处理
* `application/json`,这里必须自己读字节并在仓库层负责关闭。
* [Streaming] 让 Retrofit 不把响应体一次性读进内存(单份最大 20 MiB,边界情况不小)。
*
* @param name 服务端存储名(`<32hex>.<ext>`)。形态非法 / 不存在服务端都回
* `404 document_not_found`(不区分「非法」与「没有」),因此这里不做本地形态校验、
* 也不拿它拼任何本地路径。
*/
@Streaming
@GET("api/v1/documents/{name}")
suspend fun download(@Path("name") name: String): Response<ResponseBody>
}
@@ -20,6 +20,10 @@ import kotlinx.serialization.Serializable
* ⚠️ 本项目 `ApiClientFactory.json` 用 kotlinx 默认的 `encodeDefaults = false`:
* 取值等于默认值的字段**整键省略**。因此**纯图片消息**(`text == ""`)线上不会带 `text` 键——
* 服务端 Go 侧零值同为空串,语义完全等价(已按此实现,见 [ChatDtoRealPayloadTest])。
*
* [documents] 同理:默认空列表 ⇒ **不带附件的请求线上与加入该字段之前逐字节相同**
* (服务端 `Documents []chatDocumentRef \`json:"documents,omitempty"\`` 零值也省略,
* 见 `DocumentDtoTest` 里拿字面量 JSON 对比的那条钉子)。
*/
@Serializable
data class ChatRequest(
@@ -30,6 +34,18 @@ data class ChatRequest(
val mode: String? = null,
val stream: Boolean? = null,
val clientMsgId: String? = null,
/**
* 本回合附带的文档引用(契约 v0.59 待发布,形状见 [DocumentRefDto]):**≤5 条**,
* 每条**只给服务端存储名**(`{"name":"<32hex>.<ext>"}`),**不收 URL**。
*
* 上行只发 `name`:其它字段服务端一律不采信并覆盖(字节嗅探是唯一的类型裁决)。
* 空列表 → 整键省略(`encodeDefaults = false`),旧请求逐字节不变。
*
* ⚠️ **带附件一律走 REST**:文档本体经 `POST /api/v1/documents` 上传,引用随
* `POST /api/v1/chat` 发出([top.yeij.cyrene.domain.chat.DocumentDelivery] 的判定);
* WS `chat` 帧的 4096 字节读上限仍在,且上行帧里没有承载附件的余地。
*/
val documents: List<DocumentRefDto> = emptyList(),
)
/**
@@ -0,0 +1,83 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.Serializable
/**
* 文档 DTO —— 「文件进对话」的**唯一形状落点**(契约 **v0.59 尚未发布**)。
*
* ## 形状从哪来
* 写这份代码时 `Cyrene_v2/Docs/api-client.md` 的最新契约版本仍是 **v0.58**(里面**没有**
* `documents` 任何形状)。因此形状依据是
* `Cyrene-For-Android/devdocs/02-前后端协同事项.md` 的 **回执五**(2026-10-04 深夜,
* 标题即「一条契约形状修正」),并用后端仓库已落库的三个切片逐字段核对过:
* `407c934`(S1 存储端点)/ `a4789ec`(S3-B 网关入口)/ `5b2b385`(S3.5 文档多值化)。
*
* **形状要变只改本文件。** 上行(`documents:[{name}]`)与下行/历史(`documents:[{…}]`)
* 是**同一个元素类型**([DocumentRefDto]):服务端两条入口共用一处解析,这里也共用一个类。
*
* ## 回执五 §二「请以此为准」的三条
* 1. **单对象 `document` 已作废**,一律**数组** `documents`(≤5)——修的是「一回合 5 个附件,
* 历史与后续上下文里只留第一个」这个实测确认的缺陷;
* 2. 上行元素**只给服务端存储名 `name`,绝不收 URL**(不收 URL 是为了不重开 `F3` 那种
* 「服务端代取 URL」的 SSRF 面);谎报的 `mime`/`url`/`size` 会被服务端覆盖,
* **类型裁决只有字节嗅探一条**(客户端声明的 `Content-Type` 与文件名只作参考、可被否决,
* 所以一个 `data.json` 可能被回成 `text/plain`);
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
*
* ## 客户端必须两种都吃的一件事
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
* 因此本包的解析一律按**缺失键 → 空列表**、**空数组 → 空列表**两条都收:
* 详见 [HistoryMessageDto.documents] 与 [ChatRequest.documents] 的默认值。
*/
@Serializable
data class DocumentUploadResponse(
/** 服务端存储名:32 位小写十六进制 + 白名单扩展名。**上行引用时唯一能用的字段**。 */
val name: String = "",
/**
* **相对**同源路径(`/api/v1/documents/<name>`),与图片上传的**绝对** URL 不同:
* 读侧要 Bearer `owner|admin`,所以它不是「贴进 `<img>` 就能取」的能力 URL,绝对化没有意义。
* 客户端真正要用的是 [name]。
*/
val url: String = "",
/** 服务端**字节嗅探**所得的基类型(不是客户端声明的),如 `text/plain`。 */
val mime: String = "",
val size: Long = 0,
/** 内容寻址的哈希(64 位小写十六进制):落盘名就是它的前 32 位 + 扩展名。 */
val sha256: String = "",
/**
* `true` = 同一份字节(同扩展名)之前已经上传过:服务端回 `200` + **同一个** `name`,
* **必须当成功处理,不是错误**。`false` = 首次上传(`201`)。
*/
val deduped: Boolean = false,
)
/**
* 一条文档引用。**上行只发 [name]**(其余字段服务端不采信、会覆盖),
* 下行/历史则带上全部统计字段。
*
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` 为 Go 的 `omitempty`
* —— 零值(0 / false / "")时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
*/
@Serializable
data class DocumentRefDto(
/** 服务端存储名(`<32hex>.<ext>`)。客户端**本地**的原始文件名不在这里,也不该在这里。 */
val name: String = "",
val mime: String = "",
val size: Long = 0,
/** 本次**注入上下文**的字符数(被预算截断者填截断后的值);`null` = 服务端未报告。 */
val chars: Int? = null,
/**
* 注入正文是否被预算截断:`true` 只说明进上下文的那段被截短了,**不是**「读不到」——
* 读不到看 [status](`unreadable`)。两个信号刻意分开,别互相顶替。
*/
val truncated: Boolean = false,
/**
* 抽取状态:`extracted` / `listed` / `unreadable`(见
* [top.yeij.cyrene.domain.chat.DocumentStatus])。**开放集合,不用 enum**:
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
*/
val status: String = "",
)
@@ -5,6 +5,9 @@ import kotlinx.serialization.Serializable
/**
* `GET /api/v1/sessions/{id}/messages` 单条消息(api-reference §3.4)。
* 服务端按 `createdAt` 倒序返回,渲染前需 reverse / merge。
*
* 它同时是 WS 握手 `history` 回放与 `message_persisted` 的载荷消息类型
* (服务端三处共用同一个 `historyMessage`),所以 [documents] 的解析一处覆盖三条路。
*/
@Serializable
data class HistoryMessageDto(
@@ -27,5 +30,17 @@ data class HistoryMessageDto(
* 空 = 这条消息没有键(老客户端发的、IM 镜像进来的、v0.56 之前的历史行)。
*/
val clientMsgId: String? = null,
/**
* 这条消息携带的文档引用与抽取统计(契约 v0.59 待发布;形状见 [DocumentRefDto])。
*
* 默认 [emptyList] 同时吃掉后端**尚未定死**的两种「0 个附件」形状:
* **键缺失 → 空列表**(后端 `historyMessage.Documents` 带 `omitempty`,空/缺失即整键省略——
* 这一点已对着 `5b2b385` 的实现与 `history_document_test.go` 的断言核对过)、
* **空数组 → 空列表**(S3.5 之前写下的行、以及将来若改回恒下发 `[]`)。
*
* 只回引用与统计、**绝不回正文**;未知 `status` **原样保留**(开放集合,见 [DocumentRefDto])。
* WS `message_persisted` 与握手 `history` 回放用的是同一个 DTO,因此这一处解析两条路都覆盖。
*/
val documents: List<DocumentRefDto> = emptyList(),
val createdAt: Long,
)
@@ -0,0 +1,142 @@
package top.yeij.cyrene.data.repository
import kotlinx.coroutines.CancellationException
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.ApiErrorMapper
import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.DocumentsApi
import top.yeij.cyrene.data.api.apiCall
/**
* 一次成功上传的文档。
*
* **首次上传(`201`)与去重命中(`200`)返回的是同一个结果**——区别只在 [deduped]:
* 服务端对「同一份字节 + 同一扩展名」回同一个 [name],调用方**必须当成功处理**,
* 唯一的差别是可以据此少显示一次「已上传」的进度。
*
* [url] 是**相对**同源路径(`/api/v1/documents/<name>`),与图片上传的绝对 URL 不同:
* 读侧要 Bearer,客户端真正要用的是 [name]。它留着只为排查与展示,**不要**当 `chat.image`
* 那样的可直接取用地址。
*/
data class UploadedDocument(
val name: String,
val url: String,
val mime: String,
val size: Long,
val sha256: String,
val deduped: Boolean,
)
/**
* 文档取数层(`POST /api/v1/documents` 上传、`GET /api/v1/documents/{name}` 取字节)。
*
* ## 与图片上传([UploadRepository])的三处刻意的不同
* 1. **文件名参与裁决**:服务端把**白名单扩展名**与**字节嗅探**合取,两者都过才放行
* (图片只看嗅探)。所以 [fileName] 必须原样带上真实扩展名,不能像图片那样自己编一个;
* 2. **`200` 是成功**:同一份字节重复上传回 `200` + 同一个 name + `deduped:true`,
* **不是错误**。这条由 [apiCall] 天然成立(它只看「2xx 且信封里有 data」);
* 3. **`url` 不做绝对化校验**:文档的 url 本来就是相对路径(读侧要 Bearer),
* 照搬图片那条「不是 http(s) 就判失败」的规则会把每一次成功上传都判成解析失败。
*
* ## 错误语义
* 用 [apiCall]:状态码**不承载额外语义**(`413` / `415` / `400` / `404` 的机器可读区分
* 全在 `error.code` 里),仓库只把它折进 `Result.failure`;调用方用
* [top.yeij.cyrene.domain.chat.toDocumentFailure] 收敛成可分支的失败类型。
*
* ## 客户端不做的事(服务端才是裁决者)
* **不做本地扩展名白名单、不做本地 20 MiB 上限**:白名单与上限都是服务端配置(上限是
* `CHAT_DOCUMENT_MAX_BYTES`,可部署改),客户端复制一份必然漂移——把「本地觉得不行」当成
* 拒绝,就会出现「服务端其实收得下、客户端却拦了」的静默更严。这里宁可让服务端回一个
* 带真实上限的 `413` / `415`,让用户看到准确的理由。
*/
class DocumentRepository(
private val api: DocumentsApi,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
/** 上传一份文档([bytes] 与 [fileName] 必须来自同一个文件)。 */
suspend fun upload(bytes: ByteArray, fileName: String): Result<UploadedDocument> =
withContext(dispatcher) {
apiCall { api.upload(partOf(bytes, fileName)) }.fold(
onSuccess = { data ->
if (data.name.isBlank()) {
// 201/200 却没有可用存储名 = 上传了但引用不上,判解析失败。
// **绝不**退回「用本地文件名」——服务端已经把它丢掉了,引用一个不存在的名字
// 只会让这一轮在服务端回 404。
Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
} else {
Result.success(
UploadedDocument(
name = data.name,
url = data.url,
mime = data.mime,
size = data.size,
sha256 = data.sha256,
deduped = data.deduped,
),
)
}
},
onFailure = { Result.failure(it) },
)
}
/**
* 取回一份文档的原始字节。
*
* 响应体**不是信封**(就是文件本身),所以这里不用 [apiCall]:成功即读字节,
* 失败才走 [ApiErrorMapper](`404 document_not_found` / `401`)。
* `@Streaming` 的响应体必须由调用方关闭,[okhttp3.ResponseBody.use] 在这里收口。
*/
suspend fun download(name: String): Result<ByteArray> = withContext(dispatcher) {
try {
val response = api.download(name)
val body = response.body()
when {
response.isSuccessful && body != null -> Result.success(body.use { it.bytes() })
// 2xx 却没有响应体:与 apiCall 同一口径,判解析失败而不是「拿到了空文件」。
response.isSuccessful -> Result.failure(CyreneException(CyreneError.Local(CyreneError.LocalKind.Parse)))
else -> Result.failure(
CyreneException(
ApiErrorMapper.fromHttp(response.code(), response.errorBody()?.string()),
),
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(CyreneException(ApiErrorMapper.fromThrowable(e)))
}
}
/**
* multipart 部件。
*
* **部件名固定 `file`**(服务端 `r.FormFile("file")`),**文件名必须给且要带扩展名**:
* Go 只把带非空 `filename` 参数的部件当**文件**(不带的落进普通表单值,`FormFile` 就找不到它),
* 而扩展名要参与服务端的白名单判定。[fileName] 为空时回落到
* [FALLBACK_FILE_NAME]——只是为了不让一个「没有名字的部件」变成 `400 需要 multipart 字段 file`;
* 真正的原因(用户到底送的是什么)只有调用方知道,**不要**靠这个兜底掩盖调用方的空名字。
*
* `Content-Type` 固定 `text/plain`:服务端**不采信**客户端声明(字节嗅探是唯一裁决),
* 这里给一个诚实的默认值即可——文档白名单里放行的都是文本类。
*/
private fun partOf(bytes: ByteArray, fileName: String): MultipartBody.Part =
MultipartBody.Part.createFormData(
name = UPLOAD_FIELD_NAME,
filename = fileName.takeIf { it.isNotBlank() } ?: FALLBACK_FILE_NAME,
body = bytes.toRequestBody(MIME_TEXT.toMediaTypeOrNull()),
)
}
private const val UPLOAD_FIELD_NAME = "file"
private const val FALLBACK_FILE_NAME = "document.txt"
private const val MIME_TEXT = "text/plain"
@@ -19,6 +19,7 @@ enum class SendChannel {
* 且 `stream:true` 的回合同样在**已连接的 WS** 上流式回来 ⇒ 「REST 发、WS 收」不牺牲流式体验。
*
* 图片一律走 REST:真实图片的 base64 远超 4096 字节,在 WS 上**必然**断连。
* 文档附件同理一律走 REST(上传换引用这件事整个压在同一条通道上,见 [DocumentDelivery])。
*
* ## 阈值
* 判据是**文本的 UTF-8 字节数**(不是字符数——中文一个字 3 字节,按字符数算会在
@@ -46,9 +47,17 @@ object ChatSendRouting {
/**
* 这一条该走哪条通道。
* @param hasImage 本次是否带图片(带图**永远**走 REST)
* @param hasDocuments 本次是否带**文档附件**(带附件**永远**走 REST):文档本体要先经
* `POST /api/v1/documents` 上传,引用只随 `POST /api/v1/chat` 发出——
* 「文档一律走 REST」见 [DocumentDelivery] 第 2 条。默认 false ⇒ 既有调用与旧行为逐字不变。
*/
fun channelFor(text: String, hasImage: Boolean): SendChannel = when {
fun channelFor(
text: String,
hasImage: Boolean,
hasDocuments: Boolean = false,
): SendChannel = when {
hasImage -> SendChannel.Rest
hasDocuments -> SendChannel.Rest
utf8Length(text) <= WS_TEXT_LIMIT_BYTES -> SendChannel.Ws
else -> SendChannel.Rest
}
@@ -0,0 +1,161 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.data.api.dto.DocumentRefDto
/**
* 一份待上传的本地附件。
*
* 只带「服务端裁决需要的两样」:[fileName](**扩展名真的参与裁决**——服务端把白名单扩展名与
* 字节嗅探**合取**,两者都过才放行;其余部分会被丢进内容寻址的存储名里)与 [bytes]。
*
* 不用 `data class`:`ByteArray` 的 `equals` 是引用比较,写成 `data class` 会给出一个
* 「看着像值语义、其实永远不等」的陷阱(测试里第一眼就会撞上)。
*/
class DocumentFile(
val fileName: String,
val bytes: ByteArray,
) {
val sizeBytes: Long get() = bytes.size.toLong()
}
/** 正文该内联发出,还是该先落成文档。 */
sealed interface DocumentPlan {
/** 照既有路由([ChatSendRouting])内联发出:没有附件,正文也没到「文件级」。 */
data object Inline : DocumentPlan
/**
* 先 `POST /api/v1/documents` 换成存储名,再用 `chat.documents` 引用发出。
* 调用方应把这一路整个交给 REST(见类型 KDoc 第 2 条的策略说明),不要在 WS 上重开一条。
*
* @param files 本地附件(≤ [DocumentDelivery.MAX_ATTACHMENTS_PER_TURN] 份)。
* @param textAsDocument 正文是否也要落成一份 `.txt` 一起当附件发。
*/
data class AsDocuments(
val files: List<DocumentFile> = emptyList(),
val textAsDocument: Boolean = false,
) : DocumentPlan
/**
* 明确拒绝:调用方**必须**给用户一个说法,**绝不静默丢**。
* 「静默丢」正是本模块要消灭的东西——一方以为附件发上去了、另一方一无所知。
*/
data class Rejected(val reason: DocumentRejection) : DocumentPlan
}
/** 本地就能判定的拒绝理由(服务端的同类拒绝见 [DocumentFailure])。 */
enum class DocumentRejection {
/** 一个回合的附件数超过上限:不做「只发前 5 个」这种裁剪,多出来的必须让用户看见。 */
TooManyAttachments,
}
/**
* 「该不该走文件」的判定与 `chat.documents` 载荷构造(纯函数,VM 与测试共用)。
*
* ## 为什么这条规则值得单独存在
* 三条互不相同的约束在客户端汇成一个决定,任何一条被忘掉都会出线上事故:
*
* 1. **WS 帧 4096 字节硬上限,且超限是直接断连**(不是回错误)⇒ 正文超过它必须走 REST
* —— 这一步**已经在** [ChatSendRouting] 里,本对象**不另立阈值**,只用 [textNeedsRest]
* 把它显式暴露出来,免得接线时把「超帧限」误当成「该转文件」;
* 2. **文档一律走 REST**(回执五 §一 S3-B 的明文规则):文档本体经
* `POST /api/v1/documents` 上传、引用随 `POST /api/v1/chat` 发出。实测后端 WS `chat` 帧
* **确实**能收 `documents`(`ws_hub_turn.go` 的入站结构与 REST 同形),所以这一条是
* **客户端策略**而非后端限制——理由是把「上传 → 引用 → 起回合」压在**一条**通道上,
* 不让两条入口在错误面(413 / 415 / 404 / too_many)上分叉。所以
* [DocumentPlan.AsDocuments] 的调用方不该再把这条消息交给 WS;
* 3. **长正文按文件送,比内联更划算**:内联的正文会进**每一页历史**、每一次 `history` 回放、
* 以及这一轮的完整上下文;落成文档后历史里只留一条引用(名 / MIME / 大小 / 字符数 /
* 状态),正文按预算注入、还能被 `document-read` 按游标**继续读**。
*
* ## 阈值与它们各自的分量(**别把三个数混成一个**)
* | 界线 | 值 | 谁定的 | 越过的后果 |
* |---|---|---|---|
* | WS 帧上限 | 4096 字节 | 服务端 `wsReadLimit` | **直接断连**(不是报错)⇒ 走 REST |
* | 走 REST 的正文上限 | 3584 字节 | 本端预留 512 字节 JSON 外壳 | 就是「换条通道」,无惩罚 |
* | **转文件的正文界线** | 4000 **rune** | **本端自定**(见下) | 换形态:正文变附件 |
*
* ⚠️ **第三行是客户端的产品判断,不是服务端拒绝线。** 契约里唯一的 4000 rune 上限是
* `PATCH /api/v1/messages/{id}` 的**编辑正文**上限(服务端 `maxMessageContentRunes`),
* **`POST /api/v1/chat` 根本没有正文长度上限**(已核 `api-client.md` §1 行 3 与后端
* `rest.go` / `ws_hub_turn.go` 的请求结构)。这里借它当「正文量级已经不像一条消息」的
* 参照——按 Unicode 码点计,与服务端 Go 的 rune 口径一致(代理对算一个)。
* 于是 [textNeedsFile] 只是**建议**:[plan] 会据此给出 [DocumentPlan.AsDocuments],
* 而**接线时要不要真的自动转换,是产品取舍**(建议先问用户:「这段内容较长,
* 作为文件发送?」),纯函数不替产品做决定、也不静默改变既有行为。
*
* 反过来,**正文只是超过 4096 字节(走 REST 就够)不该被当成文件**:那会平白改变一条
* 已经验证过的路径(长文本 → REST 内联)的语义,而 REST 本来装得下。
*/
object DocumentDelivery {
/** 一个回合的附件数上限:服务端 `maxChatDocuments = 5`(回执五 §一 S3-B),含等于。 */
const val MAX_ATTACHMENTS_PER_TURN: Int = 5
/**
* 正文「已经不像一条消息」的**客户端**界线(rune,含等于 → 仍算一条消息)。
*
* 取值参照契约里唯一的 rune 计数上限——`PATCH /api/v1/messages/{id}` 的 4000 rune
* (见类型 KDoc 的诚实说明:`/chat` 没有这条上限)。
*/
const val TEXT_AS_DOCUMENT_RUNE_LIMIT: Int = 4000
/** 长正文落成附件时的扩展名:服务端白名单内的 `.txt`(名字其余部分会被内容寻址丢弃)。 */
const val TEXT_DOCUMENT_FILE_NAME: String = "message.txt"
/** 按 Unicode 码点计数,与服务端 Go 的 rune 口径一致(代理对算一个)。 */
fun runeCount(text: String): Int = text.codePointCount(0, text.length)
/** 正文是否已经超过「一条消息的正文量级」⇒ 建议按文件发送。见类型 KDoc 的界线说明。 */
fun textNeedsFile(text: String): Boolean = runeCount(text) > TEXT_AS_DOCUMENT_RUNE_LIMIT
/**
* 正文是否连既有的 WS 通道都放不下 ⇒ 必须走 REST。
*
* 直接复用 [ChatSendRouting] 的判据(**不另立阈值**):这里只是把
* 「超帧限 = 换通道」与「超 rune 界线 = 换形态」两件事在名字上分开。
*/
fun textNeedsRest(text: String): Boolean =
ChatSendRouting.channelFor(text, hasImage = false) == SendChannel.Rest
/**
* 这一轮该怎么发。
*
* 判定顺序(先拒绝、再看附件、最后看正文):
* 1. 附件 > [MAX_ATTACHMENTS_PER_TURN] → [DocumentPlan.Rejected](**不裁剪**);
* 2. 有附件 → [DocumentPlan.AsDocuments](附件一律先上传换存储名);
* 3. 没附件、正文超 [TEXT_AS_DOCUMENT_RUNE_LIMIT] → [DocumentPlan.AsDocuments](正文落成 `.txt`);
* 4. 其余 → [DocumentPlan.Inline]。
*
* @param text 本轮的正文(调用方已 trim;空串 = 纯附件回合,服务端**支持**纯附件回合)。
* @param files 本轮要挂的本地附件。
*/
fun plan(text: String, files: List<DocumentFile>): DocumentPlan = when {
files.size > MAX_ATTACHMENTS_PER_TURN -> DocumentPlan.Rejected(DocumentRejection.TooManyAttachments)
files.isNotEmpty() -> DocumentPlan.AsDocuments(files = files, textAsDocument = textNeedsFile(text))
textNeedsFile(text) -> DocumentPlan.AsDocuments(textAsDocument = true)
else -> DocumentPlan.Inline
}
/**
* 把长正文落成一份待上传的 `.txt`(服务端只看扩展名,名字其余部分会被丢弃)。
* 字节就是 UTF-8 编码——服务端嗅探得到的基类型因此是 `text/plain`。
*/
fun textAsFile(text: String, fileName: String = TEXT_DOCUMENT_FILE_NAME): DocumentFile =
DocumentFile(fileName = fileName, bytes = text.toByteArray(Charsets.UTF_8))
/**
* 由**已上传**的服务端存储名构造 `chat.documents` 载荷。
*
* 只带 `name`(见 [DocumentRefDto]:服务端不采信其它字段并会覆盖),且**≤5 条**。
*
* @return `null` = 这份载荷**不合法**(超过 5 条,或有空名字),调用方**必须**给用户提示,
* **不得**截断到 5 条、也**不得**丢掉空名字那条——服务端对>5 回 `400 too_many_documents`、
* 对非法/不存在的名字回 `404 document_not_found`,两边都不做静默裁剪。
*/
fun payload(storageNames: List<String>): List<DocumentRefDto>? = when {
storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null
storageNames.any { it.isBlank() } -> null
else -> storageNames.map { DocumentRefDto(name = it) }
}
}
@@ -0,0 +1,72 @@
package top.yeij.cyrene.domain.chat
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 文档操作失败(上传 / 取值)。契约 **v0.59 待发布**,错误码依据见
* `02-前后端协同事项.md` 回执五 §一 S1 与 §五「v0.59 会登记什么」。
*
* ## 为什么要把错误码收成类型
* 四个码在**用户能做的事**上各不相同,折进一个「操作失败」会让 UI 只能靠字符串猜:
*
* | 码 | HTTP | 用户能做什么 |
* |---|---|---|
* | [UnsupportedType] `unsupported_media_type` | 415 | **换文件**:这个格式不在白名单里(deny-by-default 的文本/代码扩展名,含 `.js` / `.py` / `.sh` 一律拒),改名 / 改声明都没有用——**字节嗅探是唯一裁决** |
* | [TooLarge] `document_too_large` | 413 | **换文件**:超过单文件上限(默认 20 MiB,可部署配置) |
* | [TooMany] `too_many_documents` | 400 | **少选几个**:一个回合最多 5 个附件 |
* | [NotFound] `document_not_found` | 404 | 重传:存储名形态非法或文件已不在(非法与不存在**刻意同码**,不给探测面) |
* | [Unreadable] `document_unreadable` | 400 | **只有显式索取正文时才会出现**(如 `document-read`),不是「上传失败」 |
*
* 每个分支都带 [message]:四种拒绝的文案由**服务端产出**(含真实上限),客户端原样展示、
* 不自己编数字(与 `image_too_large` 同一口径)。未知码与本地网络错误一律落 [Other],
* **原样带上服务端消息**,绝不因为「不认识这个码」就把失败吞掉。
*/
sealed interface DocumentFailure {
/** 可直接展示给用户的文案(服务端有话说时就是它说的那句)。 */
val message: String
/** `415 unsupported_media_type`:扩展名白名单外,或字节嗅探判出非文本。 */
data class UnsupportedType(override val message: String) : DocumentFailure
/** `413 document_too_large`:超过单文件字节上限(`CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)。 */
data class TooLarge(override val message: String) : DocumentFailure
/** `400 too_many_documents`:一个回合挂的附件数超过 5。 */
data class TooMany(override val message: String) : DocumentFailure
/** `404 document_not_found`:存储名非法或文件不存在(两者同码)。 */
data class NotFound(override val message: String) : DocumentFailure
/** `400 document_unreadable`:正文取不回来(**仅显式索取正文时**)。 */
data class Unreadable(override val message: String) : DocumentFailure
/** 401 / 403:会话失效或角色不够(文档端点要求 `owner|admin`)。 */
data object Forbidden : DocumentFailure {
override val message: String get() = "登录状态已失效,请重新登录"
}
/** 其它失败:网络 / 超时 / 未知码 / 服务端 5xx——消息照原样带出来。 */
data class Other(override val message: String) : DocumentFailure
}
/** 把仓库层的 `Result` 失败收敛成 [DocumentFailure]。 */
fun Throwable.toDocumentFailure(): DocumentFailure {
val error = (this as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> DocumentFailure.Forbidden
error is CyreneError.Server && error.httpStatus == 403 -> DocumentFailure.Forbidden
error is CyreneError.Server -> when (error.code) {
"unsupported_media_type" -> DocumentFailure.UnsupportedType(error.userMessage)
"document_too_large" -> DocumentFailure.TooLarge(error.userMessage)
"too_many_documents" -> DocumentFailure.TooMany(error.userMessage)
"document_not_found" -> DocumentFailure.NotFound(error.userMessage)
"document_unreadable" -> DocumentFailure.Unreadable(error.userMessage)
else -> DocumentFailure.Other(error.userMessage)
}
error != null -> DocumentFailure.Other(error.userMessage)
else -> DocumentFailure.Other("文档操作失败,请稍后重试")
}
}
@@ -0,0 +1,31 @@
package top.yeij.cyrene.domain.chat
/**
* 文档抽取状态(契约 **v0.59 待发布**,形状依据见 `02-前后端协同事项.md` 回执五 §一 S3-A/S3-B)。
*
* **开放集合,不用 enum**:后端的 `status` 是自由字符串,客户端映射只认这三个已知值,
* **未知值原样保留**(不丢弃、不报错、不占位符)——与 `mode` / `platform` / `mood` 同一口径。
*
* 三个值的语义(**别互相顶替**):
* - [EXTRACTED]:正文已抽取并进了这一轮的上下文;
* - [LISTED]:只给了模型一份附件清单(名 / MIME / 大小 / 结论),**正文没进上下文**;
* 它不是错误态——大文件、超预算、或本期不支持的格式都可能停在这里;
* - [UNREADABLE]:**读不到这个文件**。这是唯一一个「昔涟看不到内容」的可观测信号,
* 与「内容被截断」(`truncated`)是两件事:截断者正文照样进了上下文,只是尾巴被预算切掉。
*/
object DocumentStatus {
const val EXTRACTED: String = "extracted"
const val LISTED: String = "listed"
const val UNREADABLE: String = "unreadable"
/**
* 这条引用是否报告「读不到」。
*
* 只比 [UNREADABLE],**不猜**:服务端是本信号的唯一生产者(回合结束后回写该用户消息的
* document 列——用户消息是回合前落的、结论是回合中才知道的),客户端不做二次推断。
*/
fun isUnreadable(status: String): Boolean = status == UNREADABLE
}
@@ -0,0 +1,230 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.domain.chat.DocumentStatus
/**
* 文档 DTO 的形状钉子。
*
* **形状来源:`Cyrene-For-Android/devdocs/02-前后端协同事项.md` 回执五,契约 v0.59 尚未发布**
* (写这些用例时 `Cyrene_v2/Docs/api-client.md` 最新仍是 v0.58,没有 documents 条目)。
* 字段集与取值另对齐后端已落库实现与它的测试:
* `407c934`(S1 存储端点 `documents.go` 的 `documentResponse`)、
* `5b2b385`(S3.5 文档多值化:`history.go` 的 `historyMessage.Documents` 带 `omitempty`、
* `history.DocumentRef` 的 `omitempty` 口径、`history_document_test.go` 钉住的元素字段集
* `chars,mime,name,size,status,truncated` 与它用的真实取值)。
* **没有 v0.59 抓包**:这里的 JSON 是按上述实现形状逐字节手写的(唯一确认过的真实取值
* 来自后端自己的测试夹具)。
*
* 解码器用 [ApiClientFactory.json](与生产同一份配置),所以这里过不了的形状线上也过不了。
*/
class DocumentDtoTest {
private val json = ApiClientFactory.json
private val name = "0123456789abcdef0123456789abcdef.txt"
private val sha256 = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f60" +
"1a2b3c4d5e6f708192a3b4c5d6e7f809"
// —— 上传响应(201 首次 / 200 去重)——
private val uploadCreated = """{"data":{"name":"$name",""" +
""""url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":12345,"sha256":"$sha256","deduped":false}}"""
private val uploadDeduped = """{"data":{"name":"$name",""" +
""""url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":12345,"sha256":"$sha256","deduped":true}}"""
private fun upload(body: String) =
json.decodeFromString(ApiEnvelope.serializer(DocumentUploadResponse.serializer()), body)
@Test
fun `a first upload decodes name url mime size sha256 and deduped false`() {
val data = upload(uploadCreated).data!!
assertEquals(name, data.name)
// url 是**相对**路径:读侧要 Bearer,绝对化没有意义(与图片上传的绝对 URL 不同)。
assertEquals("/api/v1/documents/$name", data.url)
assertFalse("相对路径不能被当成绝对地址校验", data.url.startsWith("http"))
assertEquals("text/plain", data.mime)
assertEquals(12345L, data.size)
assertEquals(sha256, data.sha256)
assertFalse(data.deduped)
}
@Test
fun `a deduped upload is a successful envelope with the same name, not an error`() {
val envelope = upload(uploadDeduped)
// 200 + deduped:true 走的是**成功信封**(data 在、error 不在)——客户端必须当成功。
assertNull(envelope.error)
val data = envelope.data!!
assertEquals(name, data.name)
assertTrue(data.deduped)
}
@Test
fun `an unknown extra key in the upload payload does not break decoding`() {
val body = """{"data":{"name":"$name","url":"/api/v1/documents/$name",""" +
""""mime":"text/plain","size":1,"sha256":"$sha256","deduped":false,"gcAt":123}}"""
assertEquals(name, upload(body).data!!.name)
}
// —— 历史 / 响应消息里的 documents 数组 ——
private fun message(body: String) =
json.decodeFromString(ApiEnvelope.serializer(HistoryMessageDto.serializer()), body).data!!
/** 后端 `history_document_test.go` 用的那一组取值:`{name,mime,size,chars,truncated,status}`。 */
private fun withDocuments(documents: String) = """{"data":{"id":"m1","sessionId":"web",""" +
""""role":"user","content":"总结这份报告","documents":$documents,"createdAt":1791088571785}}"""
@Test
fun `a message with one document decodes the reference and its statistics`() {
val decoded = message(
withDocuments(
"""[{"name":"$name","mime":"text/plain","size":12345,"chars":8000,""" +
""""truncated":true,"status":"extracted"}]""",
),
)
val doc = decoded.documents.single()
assertEquals(name, doc.name)
assertEquals("text/plain", doc.mime)
assertEquals(12345L, doc.size)
assertEquals(8000, doc.chars)
assertTrue(doc.truncated)
assertEquals(DocumentStatus.EXTRACTED, doc.status)
assertEquals("总结这份报告", decoded.content)
}
@Test
fun `several documents keep the server order`() {
val second = "fedcba9876543210fedcba9876543210.md"
val docs = message(
withDocuments(
"""[{"name":"$name","mime":"text/plain","size":12345,"chars":8000,"status":"extracted"},""" +
"""{"name":"$second","mime":"text/plain","size":42,"status":"unreadable"}]""",
),
).documents
// 一回合多个附件**原样回传**(S3.5 修的就是「只留第一个」)。
assertEquals(listOf(name, second), docs.map { it.name })
assertEquals(DocumentStatus.UNREADABLE, docs[1].status)
assertTrue(DocumentStatus.isUnreadable(docs[1].status))
assertFalse("截断与读不到是两件事", DocumentStatus.isUnreadable(docs[0].status))
assertNull("`chars` 缺键 → null(服务端 omitempty),不是 0", docs[1].chars)
assertFalse("`truncated` 缺键 → false", docs[1].truncated)
}
@Test
fun `a missing documents key is an empty list`() {
// 后端 `json:"documents,omitempty"`:没有附件的消息**省略该字段**(空列与带附件必须可区分)。
val decoded = message(
"""{"data":{"id":"m1","sessionId":"web","role":"user",""" +
""""content":"没有附件","createdAt":1791088571785}}""",
)
assertTrue(decoded.documents.isEmpty())
}
@Test
fun `an empty documents array is an empty list too`() {
// 回执五原文:「0 个时期望**不出现或空数组**,S3.5 回报里会定死一种」⇒ 两种都要吃。
val decoded = message(withDocuments("[]"))
assertTrue(decoded.documents.isEmpty())
}
@Test
fun `an unknown status is preserved verbatim`() {
// status 是开放集合:不丢弃、不报错、不占位符(与 mode / platform / mood 同一口径)。
val status = "quarantined-v2"
val doc = message(withDocuments("""[{"name":"$name","status":"$status"}]""")).documents.single()
assertEquals(status, doc.status)
assertFalse(DocumentStatus.isUnreadable(status))
}
@Test
fun `a reference without the optional statistics decodes with the wire defaults`() {
val doc = message(withDocuments("""[{"name":"$name","mime":"text/plain","size":7}]"""))
.documents.single()
assertEquals(name, doc.name)
assertEquals(7L, doc.size)
assertNull(doc.chars)
assertFalse(doc.truncated)
assertEquals("", doc.status)
}
// —— chat 请求:带附件与不带附件 ——
@Test
fun `a request without documents is byte identical to the old request`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(
sessionId = "web",
text = "嗨",
deviceId = "voiceassist:cyrene:v1",
stream = true,
clientMsgId = "local-1",
),
)
// 字面量对比:默认空列表 ⇒ `documents` **整键省略**(`encodeDefaults = false`),
// 键序与加入该字段之前逐字节相同。服务端 `documents,omitempty` 也是零值省略。
assertEquals(
"""{"sessionId":"web","text":"嗨","deviceId":"voiceassist:cyrene:v1",""" +
""""stream":true,"clientMsgId":"local-1"}""",
encoded,
)
}
@Test
fun `a request with documents carries only the server side storage name`() {
val encoded = json.encodeToString(
ChatRequest.serializer(),
ChatRequest(
sessionId = "web",
text = "看看这个",
stream = true,
documents = listOf(DocumentRefDto(name = name)),
),
)
val documents = json.parseToJsonElement(encoded).jsonObject["documents"]!!.jsonArray
// 上行元素**只有 name**:不收 URL、不收 mime(服务端不采信并覆盖,见回执五 §一 S3-B)。
assertEquals(1, documents.size)
val element = documents[0].jsonObject
assertEquals(setOf("name"), element.keys)
assertEquals(name, element["name"]!!.jsonPrimitive.content)
}
@Test
fun `the read leg element keeps the statistics the request leg never sends`() {
// 上行/下行共用同一个元素类型:写出去时由 ChatRequest 决定只发 name(上一用例),
// 读回来时统计字段照常解出(前面的用例)。这里把「读侧元素该长什么样」也钉一下。
val encoded = json.encodeToString(
DocumentRefDto.serializer(),
DocumentRefDto(name = name, mime = "text/plain", size = 1, status = DocumentStatus.LISTED),
)
assertEquals("""{"name":"$name","mime":"text/plain","size":1,"status":"listed"}""", encoded)
}
}
@@ -0,0 +1,324 @@
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.assertFalse
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.DocumentsApi
import top.yeij.cyrene.domain.chat.DocumentFailure
import top.yeij.cyrene.domain.chat.toDocumentFailure
import java.net.InetSocketAddress
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors
/**
* 文档端点的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端(与
* [UploadRepositoryTest] / [ChatRepositoryTest] 同一手法,不引新依赖)。
*
* **形状来源:`02-前后端协同事项.md` 回执五,契约 v0.59 尚未发布**
* (`api-client.md` 最新仍是 v0.58,没有 documents 条目)。夹具逐字段对齐后端实现:
* `documents.go` 的 `documentResponse`(`{name,url,mime,size,sha256,deduped}`,`url` 是
* **相对**路径、`201` 首次 / `200` 去重)与它的错误码常量、
* `chat_documents.go` 的 `too_many_documents`、`maxChatDocuments = 5`。
* **没有 v0.59 抓包**:响应体是按实现形状手写的。
*
* 要证明的四件事:
* 1. 请求**真的是 multipart**、部件名真的是 `file`、**文件名(含扩展名)原样发出**、
* 字节一个不改(服务端把白名单扩展名与字节嗅探合取,名字是裁决的一半);
* 2. **`200` + `deduped:true` 是成功**,不是错误;
* 3. 四个错误码按 `code` 分成可辨的四类(映射到 [DocumentFailure]);
* 4. **两个端点都自带 Bearer**(文档读侧要 `owner|admin`,而 token 是挂在 OkHttpClient 上的,
* 不是挂在某个接口上的——这里把「下载也带上了 Authorization」实测钉住)。
*/
class DocumentRepositoryTest {
private val backend = FakeDocumentBackend()
@After
fun tearDown() {
backend.close()
}
private fun repository() = DocumentRepository(
ApiClientFactory.create(
baseUrl = backend.baseUrl,
service = DocumentsApi::class.java,
tokenProvider = { TOKEN },
),
)
/** 这次调用**必须**失败,并把它收敛成可分支的失败类型。 */
private fun Result<*>.why(): DocumentFailure =
(exceptionOrNull() ?: error("这一次调用本该失败")).toDocumentFailure()
/** 一段带扩展名的文本:足够让「字节原样发出」与「文件名原样发出」两件事都可断言。 */
private val markdown = "# 报告\n昔涟的周报\n".toByteArray(Charsets.UTF_8)
@Test
fun `a first upload returns the server side storage name and the statistics`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
val uploaded = repository().upload(markdown, "报告.md").getOrThrow()
assertEquals(backend.name, uploaded.name)
assertEquals("/api/v1/documents/${backend.name}", uploaded.url)
assertEquals("text/plain", uploaded.mime)
assertEquals(markdown.size.toLong(), uploaded.size)
assertEquals(backend.sha256, uploaded.sha256)
assertFalse(uploaded.deduped)
}
@Test
fun `a deduped upload is a success with the same name, not an error`() = runBlocking {
// 同一份字节重复上传:服务端回 200 + 同一个 name + deduped:true。**必须当成功处理**。
backend.replyUpload(200, backend.uploadBody(deduped = true))
val uploaded = repository().upload(markdown, "报告.md").getOrThrow()
assertEquals(backend.name, uploaded.name)
assertTrue(uploaded.deduped)
}
@Test
fun `the request really is multipart with the file part name and the real extension`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "report.md").getOrThrow()
val request = backend.requests.single()
assertEquals("POST", request.method)
assertEquals("/api/v1/documents", request.path)
assertTrue(request.contentType, request.contentType.startsWith("multipart/form-data"))
// 部件名 / 文件名都要在:Go 只把带非空 filename 的部件当文件,而扩展名参与白名单判定。
assertTrue(request.body, request.body.contains("""name="file""""))
assertTrue(request.body, request.body.contains("""filename="report.md""""))
assertTrue(request.body, request.body.contains("Content-Type: text/plain"))
}
@Test
fun `a name without an extension still goes out as a file part`() = runBlocking {
// 空名字会让 Go 的 FormFile 直接找不到文件(返回 400「需要 multipart 字段 file」),
// 所以仓库必须兜一个带扩展名的名字——但扩展名的裁决权仍在服务端。
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "").getOrThrow()
assertTrue(backend.requests.single().body.contains("""filename="document.txt""""))
}
@Test
fun `the bytes leave untouched, utf8 included`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "report.md").getOrThrow()
// 记录侧按 ISO-8859-1 解码(字节 ↔ 字符一一对应),所以期望值也要同一解码,
// 这样断言的才是**线上真实字节**,而不是「解码后看起来像」。
val expected = String(markdown, Charsets.ISO_8859_1)
assertTrue(backend.requests.single().body, backend.requests.single().body.contains(expected))
}
@Test
fun `415 unsupported_media_type becomes the type branch`() = runBlocking {
backend.replyUpload(415, """{"error":{"code":"unsupported_media_type","message":"仅接受文本类文档"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.UnsupportedType("仅接受文本类文档"), failure)
}
@Test
fun `413 document_too_large becomes the size branch`() = runBlocking {
backend.replyUpload(
413,
"""{"error":{"code":"document_too_large","message":"文档过大:上限 20.0 MiB,本次 30.0 MiB"}}""",
)
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(
DocumentFailure.TooLarge("文档过大:上限 20.0 MiB,本次 30.0 MiB"),
failure,
)
}
@Test
fun `400 too_many_documents becomes the count branch`() = runBlocking {
backend.replyUpload(400, """{"error":{"code":"too_many_documents","message":"单回合附件最多 5 条,本次 6 条"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.TooMany("单回合附件最多 5 条,本次 6 条"), failure)
}
@Test
fun `404 document_not_found becomes the missing branch`() = runBlocking {
backend.replyUpload(404, """{"error":{"code":"document_not_found","message":"文档不存在"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `401 stays a forbidden failure because the session itself is no longer valid`() = runBlocking {
backend.replyUpload(401, """{"error":{"code":"unauthorized","message":"auth: unauthorized"}}""")
val failure = repository().upload(markdown, "报告.md").why()
assertEquals(DocumentFailure.Forbidden, failure)
}
@Test
fun `a successful upload without a usable name is a parse failure`() = runBlocking {
// 201 却没有存储名 = 上传了但引用不上:不能退回本地文件名(服务端已经丢掉了它)。
backend.replyUpload(201, """{"data":{"name":"","url":"","mime":"text/plain","size":1,"sha256":"","deduped":false}}""")
val error = repository().upload(markdown, "报告.md").exceptionOrNull() as CyreneException
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
}
@Test
fun `a 201 without data is a parse failure`() = runBlocking {
backend.replyUpload(201, "{}")
val error = repository().upload(markdown, "报告.md").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().upload(markdown, "报告.md").exceptionOrNull() as CyreneException
assertTrue("应当是本地连接类错误,实际 ${error.error}", error.error is CyreneError.Local)
}
// —— 取字节 ——
@Test
fun `download returns the raw bytes and carries the bearer token`() = runBlocking {
backend.replyDownload(200, markdown)
val bytes = repository().download(backend.name).getOrThrow()
assertEquals(markdown.toList(), bytes.toList())
val request = backend.requests.single()
assertEquals("GET", request.method)
assertEquals("/api/v1/documents/${backend.name}", request.path)
// 读侧要 Bearer owner|admin(与图片读侧刻意不同)——拦截器是按 OkHttpClient 挂的,
// 所以这条请求自带令牌,不需要在接口上单独加头。
assertEquals("Bearer $TOKEN", request.authorization)
}
@Test
fun `an upload also carries the bearer token`() = runBlocking {
backend.replyUpload(201, backend.uploadBody(deduped = false))
repository().upload(markdown, "报告.md").getOrThrow()
assertEquals("Bearer $TOKEN", backend.requests.single().authorization)
}
@Test
fun `a missing document on download becomes the missing branch`() = runBlocking {
backend.replyDownload(404, """{"error":{"code":"document_not_found","message":"文档不存在"}}""")
val failure = repository().download(backend.name).why()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `an empty download body is a failure, not an empty document`() = runBlocking {
backend.replyDownload(500, "")
val failure = repository().download(backend.name).why()
assertTrue("实际 $failure", failure is DocumentFailure.Other)
}
/** 记录每次请求的原始字节与头,按 `replyUpload` / `replyDownload` 回话的假后端。 */
private inner class FakeDocumentBackend : AutoCloseable {
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
executor = Executors.newFixedThreadPool(2)
// 前缀匹配:上传(POST /api/v1/documents)与取值(GET /api/v1/documents/<name>)共用。
createContext("/api/v1/documents", ::handle)
start()
}
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
/** 服务端生成的存储名:32 位小写 hex + 白名单扩展名。 */
val name = "0123456789abcdef0123456789abcdef.md"
val sha256 = "9f2c1e0a7b4d5c6e8f0a1b2c3d4e5f601a2b3c4d5e6f708192a3b4c5d6e7f809"
val requests = CopyOnWriteArrayList<RecordedRequest>()
@Volatile
private var status: Int = 201
@Volatile
private var body: ByteArray = ByteArray(0)
fun replyUpload(status: Int, body: String) = reply(status, body.toByteArray(Charsets.UTF_8))
fun replyDownload(status: Int, bytes: ByteArray) = reply(status, bytes)
/** 错误回执走的是 `{error}` 信封(JSON 文本)。 */
fun replyDownload(status: Int, body: String) = reply(status, body.toByteArray(Charsets.UTF_8))
/** 线上的成功形状(`documentResponse`):`url` 是**相对**路径,`size` 是服务端统计值。 */
fun uploadBody(deduped: Boolean): String =
"""{"data":{"name":"$name","url":"/api/v1/documents/$name","mime":"text/plain",""" +
""""size":${markdown.size},"sha256":"$sha256","deduped":$deduped}}"""
private fun reply(status: Int, bytes: ByteArray) {
this.status = status
this.body = bytes
}
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(),
authorization = exchange.requestHeaders.getFirst("Authorization").orEmpty(),
body = raw.toString(Charsets.ISO_8859_1),
)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, body.size.toLong())
exchange.responseBody.use { it.write(body) }
}
override fun close() {
runCatching { server.stop(0) }
}
}
private data class RecordedRequest(
val method: String,
val path: String,
val contentType: String,
val authorization: String,
val body: String,
)
private companion object {
const val TOKEN = "test-token"
}
}
@@ -0,0 +1,239 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.DocumentRefDto
/**
* 「该不该走文件」的判定矩阵与 `chat.documents` 载荷构造(纯函数)。
*
* **形状来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)——
* 「≤5 条」「只收服务端存储名」「文档一律走 REST」「WS 上行帧 4096 字节硬上限仍在」;
* 数值另对齐后端实现与测试:`chat_documents.go` 的 `maxChatDocuments = 5`、
* `wsReadLimit = 4096`(契约 v0.56 明确记载)。
*
* **本端自定的界线在测试里标明了**:[DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT] 是客户端
* 「正文量级不像一条消息」的参照(借 `PATCH /api/v1/messages/{id}` 的 4000 rune 上限;
* **`/chat` 本身没有正文长度上限**),所以这里同时钉住「超 WS 帧限 ⇒ 只是走 REST,**不是**
* 转文件」——那条界线属 [ChatSendRouting],本对象不另立阈值。
*/
class DocumentDeliveryTest {
private fun file(name: String = "note.txt", size: Int = 16) =
DocumentFile(fileName = name, bytes = ByteArray(size))
private fun files(count: Int) = List(count) { file(name = "note-$it.txt") }
// —— 正文界线 ——
@Test
fun `empty content with no attachments is an inline send`() {
// 纯空消息:调用方在更外层已经拦掉(text+image 至少一项),这里不该把它变成附件或报错。
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("", emptyList()))
}
@Test
fun `a short message stays inline`() {
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan("你好", emptyList()))
}
@Test
fun `the rune threshold is inclusive - exactly the limit still goes inline`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT, DocumentDelivery.runeCount(atLimit))
assertFalse(DocumentDelivery.textNeedsFile(atLimit))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(atLimit, emptyList()))
}
@Test
fun `one rune over the limit turns the text into a document`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
assertEquals(
DocumentPlan.AsDocuments(files = emptyList(), textAsDocument = true),
DocumentDelivery.plan(overLimit, emptyList()),
)
}
@Test
fun `rune counting follows code points so an emoji counts as one`() {
// UTF-16 里 emoji 是 2 个 char,Go 的 rune 与 Unicode 码点都算 1 —— 用 `length` 会提前一倍转文件。
val emoji = "🎐"
assertEquals(2, emoji.length)
assertEquals(1, DocumentDelivery.runeCount(emoji))
val overLimit = emoji.repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1, DocumentDelivery.runeCount(overLimit))
assertTrue(DocumentDelivery.textNeedsFile(overLimit))
}
// —— 「超 WS 帧限 = 换通道」与「超 rune 界线 = 换形态」是两件事 ——
@Test
fun `a text over the frame limit but under the rune threshold goes REST, not to a file`() {
// 4000 个汉字 = 12000 字节,远超 WS 文字的 3584 字节:必须走 REST(否则服务端**直接断连**),
// 但按 rune 它仍是一条消息 —— 转文件会平白改掉一条已验证过的路径。
val text = "昔".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
assertTrue(DocumentDelivery.textNeedsRest(text))
assertFalse(DocumentDelivery.textNeedsFile(text))
assertEquals(DocumentPlan.Inline, DocumentDelivery.plan(text, emptyList()))
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor(text, hasImage = false, hasDocuments = false),
)
}
@Test
fun `the REST boundary is the frame limit, inclusive on the websocket side`() {
val atLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES)
val overLimit = "a".repeat(ChatSendRouting.WS_TEXT_LIMIT_BYTES + 1)
assertFalse(DocumentDelivery.textNeedsRest(atLimit))
assertTrue(DocumentDelivery.textNeedsRest(overLimit))
}
@Test
fun `attachments always go over REST even when the text would fit the websocket`() {
// 文档一律走 REST:上传换引用这条链路整个压在同一条通道上。
assertEquals(
SendChannel.Rest,
ChatSendRouting.channelFor("嗨", hasImage = false, hasDocuments = true),
)
// 默认参数让既有调用方(无附件)行为逐字不变。
assertEquals(
SendChannel.Ws,
ChatSendRouting.channelFor("嗨", hasImage = false),
)
assertEquals(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN, 5)
}
// —— 附件数 ——
@Test
fun `one attachment is enough to switch to the document channel`() {
val one = files(1)
assertEquals(
DocumentPlan.AsDocuments(files = one, textAsDocument = false),
DocumentDelivery.plan("看看这个", one),
)
}
@Test
fun `exactly the maximum number of attachments is accepted`() {
val five = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN)
assertEquals(5, five.size)
assertEquals(
DocumentPlan.AsDocuments(files = five, textAsDocument = false),
DocumentDelivery.plan("", five),
)
}
@Test
fun `one attachment over the maximum is explicitly rejected, never trimmed`() {
// 静默只发前 5 个 = 用户以为 6 份都传上去了。必须让调用方给出提示。
val six = files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan("看看这些", six),
)
}
@Test
fun `the attachment limit is checked before anything else`() {
// 超限 + 长正文:回复必须是「附件太多」,不能落进「把正文转文件」那条分支。
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
assertEquals(
DocumentPlan.Rejected(DocumentRejection.TooManyAttachments),
DocumentDelivery.plan(overLimit, files(DocumentDelivery.MAX_ATTACHMENTS_PER_TURN + 1)),
)
}
@Test
fun `a long text plus attachments asks for both`() {
val overLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT + 1)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = true),
DocumentDelivery.plan(overLimit, two),
)
}
@Test
fun `a text at the limit plus attachments keeps the text inline in the request`() {
val atLimit = "a".repeat(DocumentDelivery.TEXT_AS_DOCUMENT_RUNE_LIMIT)
val two = files(2)
assertEquals(
DocumentPlan.AsDocuments(files = two, textAsDocument = false),
DocumentDelivery.plan(atLimit, two),
)
}
// —— 载荷构造 ——
@Test
fun `the payload carries only the storage name`() {
val names = listOf("0123456789abcdef0123456789abcdef.txt")
val payload = DocumentDelivery.payload(names)!!
assertEquals(1, payload.size)
assertEquals("0123456789abcdef0123456789abcdef.txt", payload.single().name)
// 上行只发 name:其它字段留默认值(`encodeDefaults = false` 于是整键省略)。
assertEquals("", payload.single().mime)
assertEquals(0L, payload.single().size)
}
@Test
fun `the payload accepts exactly five names`() {
val names = List(5) { "0123456789abcdef0123456789abcdef$it.txt" }
assertEquals(5, DocumentDelivery.payload(names)!!.size)
}
@Test
fun `the payload refuses six names instead of trimming`() {
val names = List(6) { "0123456789abcdef0123456789abcdef$it.txt" }
assertNull(DocumentDelivery.payload(names))
}
@Test
fun `the payload refuses a blank name instead of dropping it`() {
// 丢掉空名字 = 少发一份附件且双方都不知道;服务端对非法/不存在的名字回 404,客户端也不能静默裁。
assertNull(DocumentDelivery.payload(listOf("0123456789abcdef0123456789abcdef.txt", " ")))
}
@Test
fun `an empty payload list stays empty`() {
assertEquals(emptyList<DocumentRefDto>(), DocumentDelivery.payload(emptyList()))
}
// —— 长正文落成 .txt ——
@Test
fun `a text becomes a utf8 txt file the server whitelist accepts`() {
val file = DocumentDelivery.textAsFile("昔涟")
assertEquals("message.txt", file.fileName)
assertTrue("服务端按扩展名判白名单,必须带 .txt", file.fileName.endsWith(".txt"))
assertEquals("昔涟".toByteArray(Charsets.UTF_8).size.toLong(), file.sizeBytes)
assertEquals("昔涟", String(file.bytes, Charsets.UTF_8))
}
@Test
fun `the txt file name can be overridden for the share intake`() {
assertEquals("报告.md", DocumentDelivery.textAsFile("正文", fileName = "报告.md").fileName)
}
}
@@ -0,0 +1,107 @@
package top.yeij.cyrene.domain.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
/**
* 文档错误码 → 可分支失败类型的映射(纯函数)。
*
* **来源**:`02-前后端协同事项.md` **回执五**(契约 v0.59 尚未发布)§一 S1 的四个错误码表,
* 另核过后端实现里的常量(`documents.go` 的 `documentUnsupportedCode` /
* `documentTooLargeCode` / `documentNotFoundCode`、`chat_documents.go` 的
* `tooManyDocumentsCode` / `documentsUnavailableCode`)。文案一律用**服务端产出的原文**,
* 客户端不自己编数字。
*/
class DocumentFailureTest {
private fun server(code: String, httpStatus: Int, message: String = "服务端的话") =
CyreneException(CyreneError.Server(code = code, message = message, httpStatus = httpStatus))
@Test
fun `unsupported_media_type maps to a type problem the user can only fix by changing the file`() {
val failure = server("unsupported_media_type", 415, "扩展名不在白名单内").toDocumentFailure()
assertEquals(DocumentFailure.UnsupportedType("扩展名不在白名单内"), failure)
}
@Test
fun `document_too_large maps to the size branch and keeps the server numbers`() {
val failure = server("document_too_large", 413, "文档过大:上限 20.0 MiB").toDocumentFailure()
assertEquals(DocumentFailure.TooLarge("文档过大:上限 20.0 MiB"), failure)
}
@Test
fun `too_many_documents maps to the count branch`() {
val failure = server("too_many_documents", 400, "单回合附件最多 5 条,本次 6 条").toDocumentFailure()
assertEquals(DocumentFailure.TooMany("单回合附件最多 5 条,本次 6 条"), failure)
}
@Test
fun `document_not_found maps to the missing branch`() {
val failure = server("document_not_found", 404, "文档不存在").toDocumentFailure()
assertEquals(DocumentFailure.NotFound("文档不存在"), failure)
}
@Test
fun `document_unreadable is its own branch because it is not an upload failure`() {
// 只在显式索取正文时出现(如 document-read):与「上传被拒」是不同的用户动作。
val failure = server("document_unreadable", 400, "正文读不出来").toDocumentFailure()
assertEquals(DocumentFailure.Unreadable("正文读不出来"), failure)
}
@Test
fun `401 and 403 both mean the session cannot do this`() {
assertEquals(
DocumentFailure.Forbidden,
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")).toDocumentFailure(),
)
assertEquals(
DocumentFailure.Forbidden,
server("forbidden", 403).toDocumentFailure(),
)
}
@Test
fun `an unmapped server code keeps its message instead of being swallowed`() {
// 后端确有这个码:“文档解析器未接线”(500)。不认识也要原样带出来,绝不静默当成成功。
val failure = server("chat_documents_unavailable", 500, "文档解析器未接线").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档解析器未接线"), failure)
}
@Test
fun `a local network failure falls back to the client message`() {
val failure = CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout)).toDocumentFailure()
assertTrue("实际 $failure", failure is DocumentFailure.Other)
assertEquals("连接超时,服务器没有响应", failure.message)
}
@Test
fun `a throwable that is not a cyrene error still gets a message`() {
val failure = IllegalStateException("boom").toDocumentFailure()
assertEquals(DocumentFailure.Other("文档操作失败,请稍后重试"), failure)
}
@Test
fun `the five document codes stay five distinct branches`() {
// 这条是给「日后有人图省事把它们合成一个」留的反向钉子。
val branches = listOf(
server("unsupported_media_type", 415).toDocumentFailure(),
server("document_too_large", 413).toDocumentFailure(),
server("too_many_documents", 400).toDocumentFailure(),
server("document_not_found", 404).toDocumentFailure(),
server("document_unreadable", 400).toDocumentFailure(),
)
assertEquals(5, branches.toSet().size)
}
}