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"