From 095d6e781ecce3757148976f8ef1cc288685b4c1 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 18:05:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(data,domain):=20=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=BF=9B=E5=AF=B9=E8=AF=9D=E6=95=B0=E6=8D=AE=E5=B1=82=EF=BC=88?= =?UTF-8?q?v0.59=20=E5=BE=85=E5=8F=91=E5=B8=83=E5=BD=A2=E7=8A=B6=20+=20?= =?UTF-8?q?=E8=AF=A5=E4=B8=8D=E8=AF=A5=E8=B5=B0=E6=96=87=E4=BB=B6=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约 v0.59 尚未发布(api-client.md 最新仍是 v0.58),形状依据 devdocs/02 回执五「契约形状修正」,并用后端已落库切片逐字段核对: 407c934(S1 存储端点)、a4789ec(S3-B 网关入口)、5b2b385(S3.5 多值化)。 新增: - data/api/DocumentsApi.kt:POST /api/v1/documents(multipart 字段 file)、 GET /api/v1/documents/{name}(Bearer owner|admin;@Streaming 取字节) - data/api/dto/DocumentDto.kt:文档形状的唯一落点(v0.59 若不同只改这里) - data/repository/DocumentRepository.kt:200 + deduped:true 当成功;url 是相对 路径故不做图片那套绝对化校验;不做本地白名单/大小上限(服务端才是裁决者) - domain/chat/DocumentDelivery.kt:该不该走文件的判定 + chat.documents 载荷构造 - domain/chat/DocumentStatus.kt / DocumentFailure.kt:状态与四个错误码的可分支映射 改动: - ChatDto.ChatRequest 增 documents(默认空列表 ⇒ 旧请求逐字节不变,有字面量钉子) - MessageDto.HistoryMessageDto 增 documents(缺键与空数组都解成空列表,覆盖 后端「0 个」尚未定死的两种形状;WS message_persisted / history 回放共用该 DTO) - ChatSendRouting.channelFor 增 hasDocuments(默认 false):带附件强制 REST, 因为 WS 帧 4096 字节硬上限超限是直接断连,且文档一律走 REST 为什么不做 UI/VM:UI 与 ViewModel 文件由并行的分享入口模块占用,接线由主代理统一做。 测试:353 → 413(+60:DTO 12 / Repository 17 / Delivery 21 / Failure 10), assembleDebug 与 testDebugUnitTest 均通过。 --- .../top/yeij/cyrene/data/api/DocumentsApi.kt | 68 ++++ .../top/yeij/cyrene/data/api/dto/ChatDto.kt | 16 + .../yeij/cyrene/data/api/dto/DocumentDto.kt | 83 +++++ .../yeij/cyrene/data/api/dto/MessageDto.kt | 15 + .../data/repository/DocumentRepository.kt | 142 ++++++++ .../cyrene/domain/chat/ChatSendRouting.kt | 11 +- .../cyrene/domain/chat/DocumentDelivery.kt | 161 +++++++++ .../cyrene/domain/chat/DocumentFailure.kt | 72 ++++ .../yeij/cyrene/domain/chat/DocumentStatus.kt | 31 ++ .../cyrene/data/api/dto/DocumentDtoTest.kt | 230 +++++++++++++ .../data/repository/DocumentRepositoryTest.kt | 324 ++++++++++++++++++ .../domain/chat/DocumentDeliveryTest.kt | 239 +++++++++++++ .../cyrene/domain/chat/DocumentFailureTest.kt | 107 ++++++ 13 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/DocumentsApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentRepository.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentFailure.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentStatus.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentRepositoryTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentFailureTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/DocumentsApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/DocumentsApi.kt new file mode 100644 index 0000000..188c2a1 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/DocumentsApi.kt @@ -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` 默认,**与图片上限解耦**) | + * | 读侧鉴权 | **不需要**(名字即凭据,供 ``/provider 直接取) | **要 Bearer `owner|admin`** | + * | 读侧响应 | 可内联渲染 | `Content-Disposition: attachment` + `nosniff`(**永不渲染**) | + * | 成功载荷的 url | **绝对**同源地址,可直接当 `chat.image` | **相对**路径,客户端要的是 `name` | + * + * 为什么文档读侧要鉴权而图片不要:文档的摄取在**服务端**完成(正文进上下文), + * 客户端不需要把一个「名字即凭据」的读侧打开;图片则必须被 `` 与 provider 直接消费。 + * + * ## 鉴权拦截器这一侧没问题的依据 + * Bearer 不是按接口挂的,而是**按 OkHttpClient** 挂的:[ApiClientFactory.create] 给每个 + * 客户端都装了 `AuthInterceptor`(`Authorization: Bearer ` + 401 单飞续期), + * 因此只要 [DocumentRepository] 拿到的 `DocumentsApi` 是从 `ApiClientFactory.create` 建的 + * (`AppContainer` 里照 [UploadApi] 的写法),**两个端点都自带 Bearer**,不需要额外加头。 + */ +interface DocumentsApi { + + /** + * 上传一份文档。 + * + * @param file **部件名必须是 `file`**(服务端 `r.FormFile("file")`);**文件名必须带扩展名**, + * 且该扩展名要落在服务端的 deny-by-default 白名单里——与图片不同,这里的名字 + * **真的参与裁决**(白名单扩展名 + 字节嗅探**合取**,两者都过才放行)。 + * 服务端随后丢弃该名字,按内容寻址重命名为 ``,所以名字里 + * 除了扩展名以外的内容不会被采用(也不会进路径)。 + * + * 返回原始 [Response]:**`200` 不是错误**——同一份字节重复上传回 `200` + 同一个 `name` + + * `deduped:true`,客户端必须当成功。状态码本身不承载额外语义(`413`/`415` 的区分在 + * `error.code` 里),所以仓库层可以直接走 [apiCall]。 + */ + @Multipart + @POST("api/v1/documents") + suspend fun upload(@Part file: MultipartBody.Part): Response> + + /** + * 取回一份文档的原始字节。 + * + * 它**不是信封**(响应体就是文件本身),所以返回 [ResponseBody]:解码器只处理 + * `application/json`,这里必须自己读字节并在仓库层负责关闭。 + * [Streaming] 让 Retrofit 不把响应体一次性读进内存(单份最大 20 MiB,边界情况不小)。 + * + * @param name 服务端存储名(`<32hex>.`)。形态非法 / 不存在服务端都回 + * `404 document_not_found`(不区分「非法」与「没有」),因此这里不做本地形态校验、 + * 也不拿它拼任何本地路径。 + */ + @Streaming + @GET("api/v1/documents/{name}") + suspend fun download(@Path("name") name: String): Response +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt index 20ce504..815086d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/ChatDto.kt @@ -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>."}`),**不收 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 = emptyList(), ) /** diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt new file mode 100644 index 0000000..a9f6e54 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/DocumentDto.kt @@ -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/`),与图片上传的**绝对** URL 不同: + * 读侧要 Bearer `owner|admin`,所以它不是「贴进 `` 就能取」的能力 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>.`)。客户端**本地**的原始文件名不在这里,也不该在这里。 */ + 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 = "", +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt index b576607..9d3266e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MessageDto.kt @@ -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 = emptyList(), val createdAt: Long, ) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentRepository.kt new file mode 100644 index 0000000..dea375f --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/DocumentRepository.kt @@ -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/`),与图片上传的绝对 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 = + 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 = 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" diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatSendRouting.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatSendRouting.kt index 7dbf437..b6473e8 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatSendRouting.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ChatSendRouting.kt @@ -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 } diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt new file mode 100644 index 0000000..0fd5eb6 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentDelivery.kt @@ -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 = 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): 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): List? = when { + storageNames.size > MAX_ATTACHMENTS_PER_TURN -> null + storageNames.any { it.isBlank() } -> null + else -> storageNames.map { DocumentRefDto(name = it) } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentFailure.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentFailure.kt new file mode 100644 index 0000000..ff5f249 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentFailure.kt @@ -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("文档操作失败,请稍后重试") + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentStatus.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentStatus.kt new file mode 100644 index 0000000..81b1a54 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/DocumentStatus.kt @@ -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 +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt new file mode 100644 index 0000000..54eaddd --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/DocumentDtoTest.kt @@ -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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentRepositoryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentRepositoryTest.kt new file mode 100644 index 0000000..bf01d9c --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/repository/DocumentRepositoryTest.kt @@ -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/)共用。 + 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() + + @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" + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt new file mode 100644 index 0000000..ec8fbb1 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentDeliveryTest.kt @@ -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(), 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) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentFailureTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentFailureTest.kt new file mode 100644 index 0000000..ee4af44 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/DocumentFailureTest.kt @@ -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) + } +}