feat(chat): 文档附件接到 UI 与发送链路(选文件 / 待发芯片 / chat.documents / 分享分流)
把 W6 做好的「文档进对话」数据层接上 UI 与发送路径,产品决定按 05 的定案执行: **不做长正文自动转文件**、**附件与正文各自独立存在**、上行**只给服务端存储名 `name`、≤5 条**、 带附件一律走 REST。 改了什么(按清单): - AppContainer:照 cachedUploadApi 的写法加 documentsApi + documentRepository,**与 chatApi 同 baseUrl / 同 token·401 策略**(上传拿到的 name 要交给同一个后端的 /chat,不同源必然 404)。 - domain/chat/DocumentAttachment.kt(新):DraftDocument(待发附件,只带本地引用,**不带字节**)、 admit()(去重 → 20 MiB 单份预检 → 5 份上限,被挡下的**都计数**、绝不静默裁)、 readBounded()(超限回 null、IO 异常原样抛——「太大」与「读不到」必须分得开)。 - domain/chat/DocumentChip.kt(新):附件芯片的展示换算(类型 / 体积 / 注入字数 / 状态), unreadable(读不到)与 truncated(注入被预算截断)**两句文案分开**,未知 status 原样显示。 - domain/chat/DocumentDelivery.kt:加 planForCount(text, 份数)——同一套判定矩阵,但只看份数, 因为 VM 手上还没有字节(最多 5×20 MiB 一次性读进内存不可接受)。plan() 委托给它,行为不变。 - ChatViewModel:ChatUiState 加 draftDocuments / attachmentNotice;send() → deliver() 走 DocumentDelivery 判定 → 逐份上传(保留扩展名、200+deduped 当成功)→ payload(names) 为 null 时**提示而不截断** → sendViaRest 带 documents;失败与正文、图片一起回滚(附件还给输入栏)。 - ChatScreen:SAF OpenMultipleDocuments 入口(MiuixIcons.File + contentDescription「添加文件附件」), MIME 列表贴近后端文本白名单的语义(含 octet-stream 以免「后端收得下、用户选不到」), 输入栏上方一排可移除芯片;发送按钮在只有附件时也可用(纯附件回合服务端支持)。 - MessageBubble:documents 渲染成芯片(名字中略保住扩展名 / 类型·体积·字数·状态), unreadable 整枚转灰并明写「读不到」;空列表不占位。PendingBubble 显示本地待发件。 - ShareIntake + ShareRules:EXTRA_STREAM 按 ContentResolver.getType 分流——image/ 前缀走既有 图片路,其余(含 null)走**文档路**(拷进 cache、带显示名与真实字节数)。此前一律当图片拷, 一份 .md 只能得到「图片处理失败」。**Manifest 一行未改**:后端明令拒绝 html/SVG/zip/脚本, 声明全通配会造出「点进去必然失败」的入口,text/plain 已能接住 .txt/.md/.log。 测试:485 → 536(+51,0 失败 0 错误)。
This commit is contained in:
1 parent
a6b4bae2a0
commit
30180ab1e5
14 files changed
+1668
-65
No files matched your search
@@ -0,0 +1,103 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
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.CyreneException
|
||||
|
||||
/**
|
||||
* 附件上传失败的文案(纯函数)。
|
||||
*
|
||||
* 这里锚的是**每个拒绝码对应的正确动作**:这四个码在用户能做的事上各不相同
|
||||
* (415/413 = 换文件、400 = 少选几个、404 = 重传、401/403 = 重新登录),折成一句
|
||||
* 「附件失败」等于让用户去猜。文案由**服务端**给(含真实上限),客户端**不自己编数字**。
|
||||
*
|
||||
* 另一个锚点是「这是上传这一步失败」:网络类失败必须点明,否则用户看到「无法连接到服务器」
|
||||
* 会以为整条消息没发出去,而实际上文字与附件一起回到了输入框。
|
||||
*/
|
||||
class DocumentUploadFailureTest {
|
||||
|
||||
private fun server(code: String, message: String, status: Int = 400) =
|
||||
CyreneException(CyreneError.Server(code, message, status))
|
||||
|
||||
@Test
|
||||
fun `unsupported media type keeps the server wording instead of inventing one`() {
|
||||
val message = documentUploadFailureMessage(
|
||||
server("unsupported_media_type", "extension not allowed or content is not plain text", 415),
|
||||
)
|
||||
|
||||
assertEquals("extension not allowed or content is not plain text", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too large keeps the server limit so the number is the real one`() {
|
||||
val message = documentUploadFailureMessage(
|
||||
server("document_too_large", "file exceeds the 20971520 byte limit", 413),
|
||||
)
|
||||
|
||||
assertEquals("file exceeds the 20971520 byte limit", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `too many documents keeps the server wording as well`() {
|
||||
val message = documentUploadFailureMessage(server("too_many_documents", "at most 5 documents", 400))
|
||||
|
||||
assertEquals("at most 5 documents", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing document says to send it again`() {
|
||||
val message = documentUploadFailureMessage(server("document_not_found", "document not found", 404))
|
||||
|
||||
assertEquals("document not found", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expired session asks for a re-login, not for another file`() {
|
||||
val message = documentUploadFailureMessage(
|
||||
CyreneException(CyreneError.Unauthorized("unauthorized", "auth: unauthorized")),
|
||||
)
|
||||
|
||||
assertTrue(message, message.contains("重新登录"))
|
||||
assertFalse("鉴权失败不该劝用户换文件", message.contains("换"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a network failure names the step that failed and keeps the reason`() {
|
||||
val message = documentUploadFailureMessage(
|
||||
CyreneException(CyreneError.Local(CyreneError.LocalKind.NoNetwork)),
|
||||
)
|
||||
|
||||
assertEquals("附件上传失败,网络不可用,请检查连接", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown server code still carries the server message`() {
|
||||
val message = documentUploadFailureMessage(server("weird_new_code", "something went wrong", 500))
|
||||
|
||||
assertEquals("附件上传失败,something went wrong", message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non Cyrene throwable still produces an actionable message`() {
|
||||
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(IllegalStateException("boom")))
|
||||
assertEquals("附件上传失败,请重试", documentUploadFailureMessage(RuntimeException()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no failure path ever claims the attachment was sent`() {
|
||||
val messages = listOf(
|
||||
documentUploadFailureMessage(server("unsupported_media_type", "x", 415)),
|
||||
documentUploadFailureMessage(server("document_too_large", "x", 413)),
|
||||
documentUploadFailureMessage(server("too_many_documents", "x", 400)),
|
||||
documentUploadFailureMessage(server("document_not_found", "x", 404)),
|
||||
documentUploadFailureMessage(CyreneException(CyreneError.Local(CyreneError.LocalKind.Timeout))),
|
||||
documentUploadFailureMessage(IllegalStateException()),
|
||||
)
|
||||
|
||||
// 文案里绝不能出现「已发送 / 已上传」这类暗示:失败即整轮回滚,附件与正文一起回到输入框。
|
||||
assertTrue(messages.toString(), messages.none { it.contains("已发送") || it.contains("已上传") })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user