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:
AskaEth committed 2026-10-04 18:44:23 +08:00
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("已上传") })
}
}