feat(gateway): chat.documents 解析 + 修两处静默吞掉(design/24 S3-B)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- REST(同步 + stream:true)与 WS 两条入口同形解析 documents:只收服务端存储名(形态 + 真实存在 + 嗅探 MIME + 本地 URL + 大小),客户端谎报 mime/url/size 一律被服务端覆盖 ⇒ 不重开 SSRF 面(有「真实可命中 URL,命中次数 0」的用例钉住)。

- 拒绝口径对齐 S1:超过 5 条 → 400 too_many_documents(且先于任何 FS 访问);形态非法/穿越/不存在 → 404 document_not_found。

- 挂载:一回合一条 RoleUser 消息,文档部件追加其后(纯附件回合不塞空文本块,空文本会被 provider 拒)。

- 修 S2 移交的两处静默吞掉:session_transcript 用 history.DecodeDocument 把 h.Document 还原为部件(坏列带来源 WARN 不静默、也不卡会话);session_tail 的 messagesEmpty 计入 ContentTypeDocument(纯附件不再被判空回合)。

- 拆文件/拆函数(AGENTS:文件≤500 行、函数≤80 行):new chat_documents.go(rest.go 原 499/500 行塞不下)、handleChat 尾段 54 行原样搬成 submitChatTurn(handleChat 90→40 行),ws_hub 增 documents 依赖(与既有 envResolver 同接法)。

- 主会话独立验收(隔离 worktree = 当前 HEAD + 仅 B 的文件 ⇒ 正是 A+B 合流态):build/vet 绿、全树 59 包 0 FAIL、13 条新用例 PASS、linux/darwin 交叉编译绿;变异独立复现(摘掉条数上限判定 ⇒ 红)。

- 主会话补的测试钉子:TestMaxChatDocumentsMatchesDesign —— 原条数用例是用常量自身算输入条数的,改常量它照样绿(实测 5→1000 全包仍绿);补钉后改 10 时新钉子红、旧用例仍绿。
This commit is contained in:
AskaEth committed 2026-10-04 17:25:35 +08:00
1 parent a8851dc0a2
commit a4789ec13e
8 files changed
+859 -87

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
package gateway
// 本文件归属 `chat` 请求里的文档引用(design/24 §4.2 路线① / §6.1):请求形态、
// 服务端校验(条数上限、存储名形态、文件真实存在)与该回合用户消息的装配。
//
// 它刻意**不改** S1 已落库的 `documents.go`:解析只复用那边的
// `validDocumentName` / `documentReadMIME` / `documentFilePrefix` /
// `documentNotFoundError` / `documentsLog`,读侧行为零变动。
//
// 两条硬规则:
// - 只接受**服务端签发的存储名**(`POST /api/v1/documents` 响应里的 name),
// 绝不接受任意 http(s) URL —— 不重开 F3 那种「服务端代取 URL」的 SSRF 面
// (design/24 §5);
// - 文档部件只挂在 RoleUser 回合上(design/24 §2.3-③、design/11 §10.2)。
//
// 失败一律可见(design/24 §4.4):条数超限、名字形态非法、文件不存在都让整个
// 请求被拒;绝不做「丢掉这条附件但照样起回合」的静默处理。
import (
"os"
"path/filepath"
"strings"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// maxChatDocuments 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。
const maxChatDocuments = 5
// tooManyDocumentsCode 是附件数超限的机器可读错误码(design/24 §6.2 表格)。
// 它与 image 侧的 image_too_large 同一风格:客户端只看 code 就能分开处理。
const tooManyDocumentsCode = "too_many_documents"
// documentsUnavailableCode 报告本进程没有接文档解析器(未接线的构建 / 单元测试
// 构造的裸 hub)。它是**内部接线**问题,绝不是客户端输入问题:此时带了附件的
// 回合被明确拒绝,而不是把附件静默丢掉。
const documentsUnavailableCode = "chat_documents_unavailable"
// chatDocumentRef 是 `chat` 请求里的一条文档引用。它只有 `name` 一个字段:
// 客户端能给的就是 `POST /api/v1/documents` 响应里的存储名,MIME / URL / 大小
// 一律由服务端自己解析(design/24 §3.2:字节嗅探是唯一裁决,客户端声明可被
// 否决)。REST 与 WS 上行帧**同形**(design/24 §6.1)。
type chatDocumentRef struct {
Name string `json:"name"`
}
// documentResolver 把一组文档引用解析为服务端文档部件。REST 与 WS 两条入口
// 共用同一个实现(`Server.resolveChatDocuments`),因此两边不可能分叉。
type documentResolver func(refs []chatDocumentRef) ([]model.Document, error)
// resolveChatDocuments 校验并解析一个回合携带的全部文档引用。条数上限先于任何
// 文件系统访问被判定(用最小的代价拒最早),随后逐条解析;任一条非法或不存在
// 都让整个请求被拒 —— 静默丢掉一条附件正是 design/24 §4.4 禁止的
// 「传上去了但双方都不知道」。
func (s *Server) resolveChatDocuments(refs []chatDocumentRef) ([]model.Document, error) {
if len(refs) > maxChatDocuments {
return nil, tooManyDocumentsError(len(refs))
}
out := make([]model.Document, 0, len(refs))
for _, ref := range refs {
doc, err := s.resolveChatDocument(ref)
if err != nil {
return nil, err
}
out = append(out, doc)
}
return out, nil
}
// resolveChatDocument 把一条引用解析为文档部件。名字形态必须与 S1 的
// `validDocumentName` 同规则(32 位小写十六进制主名 + 白名单扩展名),且文件
// 必须**确实存在**于 `<DataDir>/uploads/documents/`。形态非法、路径穿越、
// 文件不存在一律收敛到同一个 404 document_not_found —— 与读端点同一口径,
// 既不泄露「是否存在」以外的信息,也不可能被当成取任意 URL 的入口。
//
// MIME 由读侧的字节嗅探给出(`documentReadMIME`)、大小取自文件本身:落盘内容
// 就是唯一真源,不问数据库、也不信客户端。
func (s *Server) resolveChatDocument(ref chatDocumentRef) (model.Document, error) {
if !validDocumentName(ref.Name) {
return model.Document{}, documentNotFoundError()
}
dir, err := s.documentsDir()
if err != nil {
return model.Document{}, documentNotFoundError()
}
path := filepath.Join(dir, ref.Name)
if filepath.Dir(path) != filepath.Clean(dir) {
return model.Document{}, documentNotFoundError()
}
f, err := os.Open(path)
if err != nil {
return model.Document{}, documentNotFoundError()
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
return model.Document{}, documentNotFoundError()
}
return model.Document{
Name: ref.Name,
MIME: documentReadMIME(f),
URL: documentFilePrefix + ref.Name,
Size: info.Size(),
}, nil
}
// tooManyDocumentsError 构造附件数超限错误:400 + too_many_documents,带上限与
// 本次条数,使客户端不必猜是哪条限额挡下的。
func tooManyDocumentsError(n int) error {
return apperr.Wrap(apperr.ErrBadRequest, tooManyDocumentsCode,
"单回合附件最多 %d 条,本次 %d 条", maxChatDocuments, n)
}
// documentsUnavailableError 构造「未接文档解析器」错误:内部接线问题,因此走
// 500 + 显式错误码,绝不假装这回合只有文本。
func documentsUnavailableError() error {
return apperr.Wrap(apperr.ErrInternal, documentsUnavailableCode, "文档解析器未接线")
}
// chatDocumentErrorCode 取出错误里可对客户端暴露的机器可读码(WS 错误帧用)。
// 取不到时回退 bad_request —— 内部细节绝不回给客户端。
func chatDocumentErrorCode(err error) string {
if appErr, ok := apperr.As(err); ok && appErr.Code != "" {
return appErr.Code
}
return wsBadRequestCode
}
// chatTurnMessages 为一个回合构建用户消息:文本、图片与文档部件挂在**同一条**
// RoleUser 消息上。文档部件由 model.DocumentMessage 构造(design/24 §2.2),
// 且只可能落在 RoleUser 上(§2.3-③)。
//
// 纯附件回合(无文本、无图片)同样得到一条消息,且文档部件让它不被判成空回合
// (见 session_tail.go 的 messagesEmpty)。这一分支**不塞空文本部件**:空文本块
// 会被 provider 拒,而文档部件本身就是这条消息的内容。
func chatTurnMessages(text, image string, docs []model.Document) []model.Message {
msg := baseUserMessage(text, image)
for _, doc := range docs {
msg.Content = append(msg.Content, model.DocumentMessage(model.RoleUser, "", doc).Content...)
}
return []model.Message{msg}
}
// baseUserMessage 返回回合的基底用户消息:有图片时是可带文本的图片消息,否则是
// 文本消息。它就是引入文档之前 `userChatMessages` 的行为,逐字不变(唯一的
// 差别是文本为空时给出不带文本部件的空基底,供纯附件回合使用)。
func baseUserMessage(text, image string) model.Message {
switch {
case strings.TrimSpace(image) != "":
return model.ImageMessage(model.RoleUser, text, image)
case text == "":
return model.Message{Role: model.RoleUser}
default:
return model.TextMessage(model.RoleUser, text)
}
}