- 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 时新钉子红、旧用例仍绿。
158 lines
7.0 KiB
Go
158 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|