refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
- 抽共享存储核心:internal/runtime/documents/store.go(22 项白名单 + 字节嗅探唯一裁决 + 内容寻址 sha256[:32]+ext + 去重 + 限额 + 路径双检)+ store_meta.go(documents 表唯一写入实现)。
- gateway 改为调用 store,**对外行为零变化**(机器证明:既有 documents_test/chat_documents_test/chat_documents_bounds_test 三个文件 0 行改动且全绿;状态码/响应字段/错误码/审计动作名/限额文档口径全部逐字保留;maxChatDocuments 改为引用 documents.MaxPerMessage)。
- IM 入站附件摄取(channelbridge):文件类媒体 → store.Save → 挂 model.DocumentMessage(RoleUser) 部件 ⇒ 后续 resolveDocuments 自动抽取/注入/清单;挂载点在「会话解析 + 黑白名单 + 最小打扰」三重闸门之后(被拦消息绝不在盘上留文件);**不支持的格式不静默**(诚实提示行 + 带来源日志,只记 name/mime/size,名字做展示净化防伪造行);图片/语音保持既有链路。
- 主会话独立验收:隔离 worktree(M1 的 12 文件)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、12 条 Inbound 用例 PASS;变异独立复现(关闭 IM 摄取 ⇒ 5 条红,含「落盘文件数 = [] want 1」)。
- ⚠️ 真机边界(需裁决):现网四个适配器上报的文件媒体**只带 URL**(telegram FileID / qq-obv11 file|url / weixin·kook CDN URL),Media.Data 恒空 ⇒ 本切片只摄**内联字节**,URL 一律「诚实提示,不代取远程」(避免第三份 SSRF 实现)。真机要让「她读到 IM 文件」,需各适配器入站时下载并内联(凭据在各自渠道里,推荐)或新增共享受守卫抓取器。
- 另登记:IM 附件暂未写 chat_messages.document 列(二手回合看不到引用,建议并入 S7);channelbridge 既有测试夹具在 -race 下有数据竞争(stubSessions.GetOrCreate,与本次无关)。
This commit is contained in:
1 parent
51c1672355
commit
2a43035f5d
12 files changed
+1743
-158
No files matched your search
@@ -23,6 +23,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
@@ -148,6 +149,12 @@ type Options struct {
|
||||
// 被移入某个角色的作用域(design/22 §7-4)。为 nil 时所有渠道都使用全局
|
||||
// 默认角色。
|
||||
ChannelPersona func(platform, accountID string) string
|
||||
// DocumentStore 是 IM **入站附件**的落盘配置(design/24 §3;切片 M1):
|
||||
// 目录、单文件上限与 `documents` 元数据写入面。它由组合根给出(桥接器不猜
|
||||
// 目录布局),并与 Web 上传共用同一个存储核心。UploadsDir 与 DataDir 都为
|
||||
// 空即视为**未接线**:带文件的消息照旧能跑(模型会收到一行「读不到」的诚实
|
||||
// 提示),只是不落库、不抽取 —— 绝不假装读到了。
|
||||
DocumentStore documents.StoreConfig
|
||||
}
|
||||
|
||||
// Bridge 掌管渠道适配器生命周期与入站消息流水线。它
|
||||
@@ -195,6 +202,9 @@ type Bridge struct {
|
||||
// 都由 mu 保护。
|
||||
channelPersona func(platform, accountID string) string
|
||||
adapterPersona map[string]string
|
||||
// documentsCfg 是入站附件落盘配置(design/24 §3;切片 M1)。零值 = 未接线:
|
||||
// 带文件的消息照旧能跑,模型会收到一行「读不到」的诚实提示(绝不静默)。
|
||||
documentsCfg documents.StoreConfig
|
||||
// proactiveCursor 是主动推送「无信号」兜底时的轮转游标
|
||||
// (channelbridge/proactive.go),使默认渠道不会总是
|
||||
// 白名单中的第一个主人渠道。由 mu 保护。
|
||||
@@ -267,6 +277,7 @@ func New(opts Options) (*Bridge, error) {
|
||||
factory: opts.Factory,
|
||||
channelPersona: opts.ChannelPersona,
|
||||
adapterPersona: make(map[string]string),
|
||||
documentsCfg: opts.DocumentStore,
|
||||
cancel: func() {},
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
}, nil
|
||||
|
||||
@@ -134,7 +134,10 @@ func (b *Bridge) process(ctx context.Context, msg adapter.UnifiedMessage) {
|
||||
// (项目红线)。
|
||||
func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter) {
|
||||
text := strings.TrimSpace(msg.Content)
|
||||
if text == "" {
|
||||
// 无文本且无文件类附件 ⇒ 没有可处理的内容(与既有行为一致)。带附件的
|
||||
// 「纯文件消息」必须继续往下走:附件就是它的内容(design/24 §4.4 的
|
||||
// 「不许静默丢」)。
|
||||
if text == "" && !hasFileMedia(msg) {
|
||||
b.log.Debug("inbound message has no chat text, skipped",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID)
|
||||
return
|
||||
@@ -220,13 +223,19 @@ func inboundTargetFor(sess session.Session, platform string) inboundTarget {
|
||||
}
|
||||
|
||||
// runInboundTurn 为被放行的消息驱动 agent 轮次:解析
|
||||
// 路由目标、构建模型可见的消息列表、在轮次之前持久化入站
|
||||
// 路由目标、把消息携带的文件类附件**落库并挂成文档部件**
|
||||
// (design/24 §3;切片 M1)、构建模型可见的消息列表、在轮次之前持久化入站
|
||||
// 用户消息(因此即使渠道回复失败它也已落地),运行
|
||||
// 循环并把结果交给 deliverReply。
|
||||
func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter,
|
||||
text string, actor adapter.Actor, sess session.Session) {
|
||||
target := inboundTargetFor(sess, msg.Platform)
|
||||
|
||||
// 入站附件摄取留在**闸门之后**(本函数只在消息被放行后调用):被黑白名单或
|
||||
// 最小打扰拦下的消息绝不在盘上留下文件。成功者挂成文档部件,装配期
|
||||
// resolveDocuments 照既有链路抽取/注入;读不到的走诚实提示。
|
||||
attachments := b.ingestInboundDocuments(ctx, msg)
|
||||
|
||||
// 第三方边界只在受限(群聊/陌生人)上下文中生效:
|
||||
// 记忆只读(MemWritable=false),并携带发送者名,
|
||||
// 使 agent 能看到是谁在说话(design/16 §4.6 §1-2)。
|
||||
@@ -241,6 +250,7 @@ func (b *Bridge) runInboundTurn(ctx context.Context, msg adapter.UnifiedMessage,
|
||||
runEnv.PersonaID = b.resolvePersona(ctx, sess, target, adapterName(a))
|
||||
senderName := senderDisplayName(msg, actor)
|
||||
loopMsgs := b.buildLoopMessages(ctx, text, runEnv, actor, senderName, msg)
|
||||
attachInboundAttachments(loopMsgs, attachments)
|
||||
|
||||
// 主人私聊消息落在 Web 主会话并广播给 Web 客户端;
|
||||
// 受限消息落在其隔离会话(适配器日志页),
|
||||
@@ -307,6 +317,30 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
return msg.Platform + ":" + msg.ChannelID
|
||||
}
|
||||
|
||||
// attachInboundAttachments 把入站附件摄取的结果挂到消息列表里的**用户回合**上
|
||||
// (design/24 §3/§4.4;切片 M1):文档部件与「读不到」的诚实提示行都只进
|
||||
// `RoleUser`,且挂在那条已构建好的用户消息上 —— 来源系统行、第三方边界、
|
||||
// SenderName 等既有装配因此逐字不变(本切片不重排消息,只追加部件)。
|
||||
//
|
||||
// 一个回合只有一条用户消息(buildLoopMessages 的既有形状),因此找到第一条即
|
||||
// 挂载并返回;没有附件产物时是空操作。文本部件为空且既有文本为空时**不塞空文本
|
||||
// 部件**(provider 会拒):附件部件本身就是这条消息的内容。
|
||||
func attachInboundAttachments(msgs []model.Message, attach inboundAttachments) {
|
||||
if len(attach.parts) == 0 && len(attach.notices) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range msgs {
|
||||
if msgs[i].Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
msgs[i].Content = append(msgs[i].Content, attach.parts...)
|
||||
if notice := attach.noticeText(); notice != "" {
|
||||
msgs[i].Content = append(msgs[i].Content, model.Content{Type: model.ContentTypeText, Text: notice})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// buildLoopMessages 为一次入站轮次构建模型可见的消息列表。它
|
||||
// 用 "[来源: <platform> · <conversation>]" 前缀装饰用户消息,使
|
||||
// 模型能看到消息来自哪个平台、哪个会话(design/16
|
||||
@@ -318,8 +352,12 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
|
||||
// SessionEnv/actor 结构化决定,绝不由 LLM 推断。原始消息文本
|
||||
// 保持不动(构建带前缀的副本),因此持久化内容与
|
||||
// 来源平台投递的内容一致。
|
||||
//
|
||||
// 入站附件摄取的产物(已落库的文档部件与「读不到」的诚实提示行)由
|
||||
// attachInboundAttachments 挂到**同一条 RoleUser 消息**上 —— 用户内容只进用户
|
||||
// 角色消息是项目红线。
|
||||
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
|
||||
msg := model.TextMessage(model.RoleUser, text)
|
||||
msg := userTurnMessage(text)
|
||||
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
|
||||
if thirdParty {
|
||||
// 说话者由结构化元数据承载,不织入用户文本
|
||||
@@ -349,6 +387,16 @@ func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.Sess
|
||||
return []model.Message{model.TextMessage(model.RoleSystem, sys), msg}
|
||||
}
|
||||
|
||||
// userTurnMessage 构建入站回合的用户消息基底:文本为空时**不塞空文本部件**
|
||||
// (provider 会拒空文本块),让附件部件或诚实提示行本身成为这条消息的内容 ——
|
||||
// 与网关 `baseUserMessage` 对纯附件回合的口径一致(design/24 §4.2)。
|
||||
func userTurnMessage(text string) model.Message {
|
||||
if text == "" {
|
||||
return model.Message{Role: model.RoleUser}
|
||||
}
|
||||
return model.TextMessage(model.RoleUser, text)
|
||||
}
|
||||
|
||||
// thirdPartyBoundaryPrompt 是加在受限(群聊/陌生人)上下文
|
||||
// 最前面的系统消息,让模型知道身份边界。只有当会话
|
||||
// 在结构上受限时才注入(design/16 §4.6 §1);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件归属 **IM 入站附件摄取**(design/24 §3 / §4.4;切片 M1):把入站消息携带的
|
||||
// 文件类媒体交给共享文档存储(`internal/runtime/documents` 的 Store —— 与 Web
|
||||
// 上传同一份实现),成功者挂成该消息的文档部件,让装配期 `resolveDocuments`
|
||||
// 照既有链路完成抽取/注入/清单/结论回写,**不需要新链路**。
|
||||
//
|
||||
// 读不到的附件**绝不静默**(design/24 §4.4):模型会收到一行诚实提示,日志留一条
|
||||
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
|
||||
//
|
||||
// 四条边界:
|
||||
// - **图片/语音不走这里**(design/24 §4.2 末:图片走既有视觉链路),本切片只补
|
||||
// 文件类;
|
||||
// - 入站附件是**用户内容** ⇒ 文档部件只挂在 `model.RoleUser` 消息上;
|
||||
// - 摄取发生在**闸门之后**(会话解析、黑白名单、最小打扰判定都通过之后),
|
||||
// 被拒的消息因此绝不会在盘上留下文件;
|
||||
// - 输出仍只走 chat 文本:IM 侧不新增任何文件出站能力。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 入站附件的具名常量与提示文案(design/24 §4.4)。提示行的形状与循环的清单行
|
||||
// 一致(`[附件 <名> · …]`),但**只进模型可见视图**,不写进持久化正文 ——
|
||||
// design/24 §4.3 要求人类可见的来源标记归客户端渲染层,绝不拼进正文。
|
||||
const (
|
||||
// inboundAttachmentNameMaxRunes 是提示行里原始文件名的展示上限。文件名来自
|
||||
// 渠道(发送方可任意构造),因此限长之外还要**剔控制字符**:换行会让一条
|
||||
// 附件名在模型上下文里伪造出额外的行。
|
||||
inboundAttachmentNameMaxRunes = 64
|
||||
|
||||
// inboundAttachmentMIMEMaxRunes 是提示行里媒体类型的展示上限。
|
||||
inboundAttachmentMIMEMaxRunes = 48
|
||||
|
||||
// inboundAttachmentUnknownName 是文件名缺失(或净化后为空)时的占位。
|
||||
inboundAttachmentUnknownName = "未命名文件"
|
||||
|
||||
// inboundAttachmentUnknownMIME 是渠道未给 MIME 时的展示值(与循环清单行的
|
||||
// 「未知类型」同一口径:宁可写出来,也不留一个空洞让模型误读)。
|
||||
inboundAttachmentUnknownMIME = "未知类型"
|
||||
|
||||
// inboundUploaderPrefix 是写进 `documents` 元数据的上传者身份前缀,用来与
|
||||
// Web 账号身份分开(渠道来源 vs 登录账号)。
|
||||
inboundUploaderPrefix = "im:"
|
||||
|
||||
// 诚实提示行(design/24 §4.4)。参考 `%s=展示名`、`%s=展示 MIME`、`%d=字节数`。
|
||||
// 文案按**拒绝原因**分类:把「超限」说成「格式不支持」本身就是一种不诚实。
|
||||
inboundNoticeExtDenied = "[附件 %s · %s · %d 字节 · 无法读取:格式不在可读白名单内(只支持纯文本类文档)]"
|
||||
inboundNoticeNotText = "[附件 %s · %s · %d 字节 · 无法读取:内容不是纯文本(类型由字节嗅探裁决)]"
|
||||
inboundNoticeTooLarge = "[附件 %s · %s · %d 字节 · 无法读取:文件超过服务端体积上限]"
|
||||
inboundNoticeEmpty = "[附件 %s · %s · %d 字节 · 无法读取:文件为空]"
|
||||
inboundNoticeStoreError = "[附件 %s · %s · %d 字节 · 无法读取:服务端保存该附件失败]"
|
||||
inboundNoticeNoBytes = "[附件 %s · %s · %d 字节 · 无法读取:渠道只给了远程引用,服务端未代取文件字节]"
|
||||
inboundNoticeUnwired = "[附件 %s · %s · %d 字节 · 无法读取:服务端未接线文档存储]"
|
||||
inboundNoticeTooMany = "[另有附件未处理:单条消息最多 %d 个文件]"
|
||||
)
|
||||
|
||||
// inboundAttachments 是一次入站附件摄取的产物。
|
||||
type inboundAttachments struct {
|
||||
// parts 是已落库的文档部件(挂在同一条 RoleUser 消息上)。
|
||||
parts []model.Content
|
||||
// notices 是「有附件读不到」的诚实提示行,顺序与消息里的文件媒体顺序一致。
|
||||
notices []string
|
||||
}
|
||||
|
||||
// noticeText 把提示行拼成一段文本;没有提示时返回空串(调用方据此不塞空文本部件)。
|
||||
func (a inboundAttachments) noticeText() string {
|
||||
return strings.Join(a.notices, "\n")
|
||||
}
|
||||
|
||||
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
|
||||
func hasFileMedia(msg adapter.UnifiedMessage) bool {
|
||||
for _, m := range msg.Media {
|
||||
if isFileMedia(msg, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isFileMedia 报告一条媒体是不是**文件类**。部分适配器只给内容类型而不给媒体
|
||||
// Kind(`ContentType == file`,附件就在 Media 里),那种消息也按文件处理,否则
|
||||
// 附件会被静默忽略 —— 静默忽略正是本切片要消灭的行为。
|
||||
func isFileMedia(msg adapter.UnifiedMessage, m adapter.Media) bool {
|
||||
if m.Kind == adapter.MediaFile {
|
||||
return true
|
||||
}
|
||||
return m.Kind == "" && msg.ContentType == adapter.ContentFile
|
||||
}
|
||||
|
||||
// fileMedia 返回消息里全部文件类媒体(保序)。
|
||||
func fileMedia(msg adapter.UnifiedMessage) []adapter.Media {
|
||||
out := make([]adapter.Media, 0, len(msg.Media))
|
||||
for _, m := range msg.Media {
|
||||
if isFileMedia(msg, m) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ingestInboundDocuments 把一条入站消息的文件类媒体落进共享文档存储,返回要挂到
|
||||
// 该消息上的文档部件与「读不到」的诚实提示。调用点必须在**放行之后**:被黑白名单
|
||||
// 或最小打扰拦下的消息不会在盘上留下任何文件。
|
||||
func (b *Bridge) ingestInboundDocuments(ctx context.Context, msg adapter.UnifiedMessage) inboundAttachments {
|
||||
files := fileMedia(msg)
|
||||
if len(files) == 0 {
|
||||
return inboundAttachments{}
|
||||
}
|
||||
store := b.inboundDocumentStore(msg)
|
||||
if store == nil {
|
||||
// 未接线:绝不假装读到了。每个附件都得到一行诚实提示 + 一条带来源的日志。
|
||||
b.log.Warn("inbound attachment ingest skipped: document store not wired",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID, "files", len(files))
|
||||
out := inboundAttachments{notices: make([]string, 0, len(files))}
|
||||
for _, m := range files {
|
||||
out.notices = append(out.notices, noticeLine(m, inboundNoticeUnwired))
|
||||
}
|
||||
return out
|
||||
}
|
||||
return b.ingestFiles(ctx, store, msg, files)
|
||||
}
|
||||
|
||||
// ingestFiles 逐个落盘并分类:成功挂文档部件,被拒/无字节则记一条带来源的日志 +
|
||||
// 一行诚实提示。条数上限与 Web 侧同源(documents.MaxPerMessage),超出的附件
|
||||
// **不静默**:整条提示一次,且日志记下实际条数。
|
||||
func (b *Bridge) ingestFiles(ctx context.Context, store *documents.Store, msg adapter.UnifiedMessage, files []adapter.Media) inboundAttachments {
|
||||
out := inboundAttachments{
|
||||
parts: make([]model.Content, 0, len(files)),
|
||||
notices: make([]string, 0, len(files)),
|
||||
}
|
||||
for i, media := range files {
|
||||
if i >= documents.MaxPerMessage {
|
||||
b.log.Warn("inbound attachments beyond per-message limit ignored",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"files", len(files), "limit", documents.MaxPerMessage)
|
||||
out.notices = append(out.notices, fmt.Sprintf(inboundNoticeTooMany, documents.MaxPerMessage))
|
||||
break
|
||||
}
|
||||
if len(media.Data) == 0 {
|
||||
// 渠道只给了远程引用(CDN URL / 平台文件 id):本切片**不代取** ——
|
||||
// 服务端代取任意 http(s) 正是 F3 的教训(design/24 §5),需要专门
|
||||
// 的裁决与地址守卫,不在这里顺手打开。绝不静默。
|
||||
b.log.Warn("inbound attachment has no inlined bytes",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", media.Name, "mime", media.MIMEType, "size", media.Size)
|
||||
out.notices = append(out.notices, noticeLine(media, inboundNoticeNoBytes))
|
||||
continue
|
||||
}
|
||||
ref, err := store.Save(ctx, media.Name, media.Data)
|
||||
if err != nil {
|
||||
// 白名单/嗅探/限额的裁决全在 store(与 Web 上传同源)。这里只如实
|
||||
// 记日志(name/mime/size,**绝不记正文**)并让模型看到「读不到」,
|
||||
// 且提示文案按**拒绝原因**分类(把「超限」说成「格式不支持」也是一种
|
||||
// 不诚实)。
|
||||
b.log.Warn("inbound attachment rejected",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", media.Name, "mime", media.MIMEType, "size", len(media.Data), "error", err)
|
||||
out.notices = append(out.notices, noticeLine(media, rejectedNoticeFor(err)))
|
||||
continue
|
||||
}
|
||||
b.log.Info("inbound attachment stored",
|
||||
"platform", msg.Platform, "channel", msg.ChannelID,
|
||||
"name", ref.Name, "mime", ref.MIME, "size", ref.Size,
|
||||
"sha256", ref.SHA256, "deduped", ref.Deduped)
|
||||
out.parts = append(out.parts, documentPart(ref))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inboundDocumentStore 按消息的渠道身份构造文档存储:目录/上限/元数据写入面来自
|
||||
// 组合根注入的配置,上传者身份按消息给出。未接线(UploadsDir 与 DataDir 都为空)
|
||||
// 时返回 nil —— 调用方据此走「读不到」的诚实路径,而不是假装读到了。
|
||||
func (b *Bridge) inboundDocumentStore(msg adapter.UnifiedMessage) *documents.Store {
|
||||
cfg := b.documentsCfg
|
||||
if cfg.UploadsDir == "" && cfg.DataDir == "" {
|
||||
return nil
|
||||
}
|
||||
cfg.Uploader = inboundUploader(msg)
|
||||
return documents.NewStore(cfg)
|
||||
}
|
||||
|
||||
// inboundUploader 给出写进 `documents` 元数据的上传者身份(平台 + 本地账号 +
|
||||
// 平台 uid)。身份**只来自渠道元数据**,绝不从文件名或内容里推断(项目红线)。
|
||||
func inboundUploader(msg adapter.UnifiedMessage) string {
|
||||
return inboundUploaderPrefix + deviceIDFor(msg)
|
||||
}
|
||||
|
||||
// documentPart 把一条已落库的附件折成模型部件。身份一律取自**服务端存储名**
|
||||
// (原始文件名永不进上下文);URL 刻意留空:IM 侧没有文件出站能力、装配期也
|
||||
// 用不到读侧 URL,而落盘路径(Ref.Path)更是绝不能进模型上下文。
|
||||
func documentPart(ref documents.Ref) model.Content {
|
||||
msg := model.DocumentMessage(model.RoleUser, "", model.Document{
|
||||
Name: ref.Name,
|
||||
MIME: ref.MIME,
|
||||
Size: ref.Size,
|
||||
})
|
||||
return msg.Content[0]
|
||||
}
|
||||
|
||||
// rejectedNoticeFor 按 store 的哨兵错误给出对应的诚实提示模板(design/24 §4.4):
|
||||
// 分类只依据哨兵链,**错误原文只进日志、绝不进模型上下文**。
|
||||
func rejectedNoticeFor(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, documents.ErrExtNotAllowed):
|
||||
return inboundNoticeExtDenied
|
||||
case errors.Is(err, documents.ErrContentNotText):
|
||||
return inboundNoticeNotText
|
||||
case errors.Is(err, documents.ErrTooLarge):
|
||||
return inboundNoticeTooLarge
|
||||
case errors.Is(err, documents.ErrEmpty):
|
||||
return inboundNoticeEmpty
|
||||
default:
|
||||
return inboundNoticeStoreError
|
||||
}
|
||||
}
|
||||
|
||||
// noticeLine 渲染一行诚实提示(design/24 §4.4)。名字与媒体类型都来自渠道,
|
||||
// 因此先做展示净化:剔控制字符、限长、缺失给占位。
|
||||
func noticeLine(media adapter.Media, template string) string {
|
||||
return fmt.Sprintf(template, displayName(media.Name), displayMIME(media.MIMEType), attachmentSize(media))
|
||||
}
|
||||
|
||||
// attachmentSize 取附件的展示字节数:优先用渠道上报的大小,缺失或为负时退回
|
||||
// 实际拿到的内联字节数 —— 提示行里的数字宁可是「已知的最小值」,也不能是负数。
|
||||
func attachmentSize(media adapter.Media) int64 {
|
||||
if media.Size > 0 {
|
||||
return media.Size
|
||||
}
|
||||
return int64(len(media.Data))
|
||||
}
|
||||
|
||||
// displayName 把渠道给的文件名整理成可安全放进模型上下文的展示名:去控制字符、
|
||||
// 折叠空白、限长;净化后为空时给占位名。
|
||||
func displayName(name string) string {
|
||||
cleaned := strings.Map(dropAttachmentControl, name)
|
||||
cleaned = strings.Join(strings.Fields(cleaned), " ")
|
||||
if cleaned == "" {
|
||||
return inboundAttachmentUnknownName
|
||||
}
|
||||
return truncateRunes(cleaned, inboundAttachmentNameMaxRunes)
|
||||
}
|
||||
|
||||
// displayMIME 只保留 MIME 里合法的字符(字母/数字与 `/+;=-.` 与空白),其余一律
|
||||
// 丢弃 —— 它同样是渠道给的字符串,绝不原样进上下文;净化后为空时给「未知类型」。
|
||||
func displayMIME(mime string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '/', r == '+', r == ';', r == '=', r == '-', r == '.', r == ' ':
|
||||
return r
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}, mime)
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned == "" {
|
||||
return inboundAttachmentUnknownMIME
|
||||
}
|
||||
return truncateRunes(cleaned, inboundAttachmentMIMEMaxRunes)
|
||||
}
|
||||
|
||||
// dropAttachmentControl 把换行/制表折叠为空格,其余控制字符直接丢弃。
|
||||
func dropAttachmentControl(r rune) rune {
|
||||
if r == '\n' || r == '\r' || r == '\t' {
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// truncateRunes 按 rune 截断并加省略号(绝不把多字节字符切成两半)。
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件承载 IM 入站附件摄取的**诚实性与边界**用例(design/24 §4.4;切片 M1),
|
||||
// 与 inbound_documents_test.go 是同一批夹具的搬家(夹具留在那边,同包可见):
|
||||
//
|
||||
// - 拒绝原因必须**分类正确**:超限说超限,绝不笼统说成「格式不支持」;
|
||||
// - 渠道只给远程引用(目前适配器的现实形态)时不代取、不静默;
|
||||
// - 图片类媒体保持现状(不走文档摄取);
|
||||
// - 组合根未接线时照旧跑回合,但模型知道附件读不到。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
// TestInboundOversizeFileIsHonestAboutSize 钉住诚实口径的**分类**:体积超限必须
|
||||
// 说「超过体积上限」,绝不能笼统说成「格式不支持」——把原因说错也是不诚实。
|
||||
func TestInboundOversizeFileIsHonestAboutSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{MaxBytes: 8}, true)
|
||||
msg := fileMessage("这个大吗", "big.txt", []byte("这是明显超过八个字节的正文\n"), "text/plain")
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
||||
t.Fatalf("超限附件被落库了:%v", names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, "体积上限") {
|
||||
t.Fatalf("超限附件的提示没有点明体积上限:%q", visible)
|
||||
}
|
||||
if strings.Contains(visible, "格式不在可读白名单内") {
|
||||
t.Fatalf("超限附件被说成了「格式不支持」(原因说错):%q", visible)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound attachment rejected") {
|
||||
t.Fatalf("缺少带来源的拒绝日志:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundRemoteOnlyMediaIsHonestWithoutFetch 钉住「渠道只给远程引用」这一现实形态
|
||||
// (适配器目前上报 URL/平台文件 id,而不是内联字节):本切片**不代取**,但绝不静默。
|
||||
func TestInboundRemoteOnlyMediaIsHonestWithoutFetch(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
msg := directMessage()
|
||||
msg.Content = "这个文件里是什么"
|
||||
msg.Media = []adapter.Media{{
|
||||
Kind: adapter.MediaFile, URL: "https://cdn.example.com/secret-token=abc/report.pdf",
|
||||
Name: "report.pdf", Size: 1024, MIMEType: "application/pdf",
|
||||
}}
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
||||
t.Fatalf("远程引用的附件被落库了:%v", names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, "report.pdf") || !strings.Contains(visible, "远程引用") {
|
||||
t.Fatalf("模型可见视图里没有诚实提示行:%q", visible)
|
||||
}
|
||||
log := env.logs.String()
|
||||
if !strings.Contains(log, "no inlined bytes") {
|
||||
t.Fatalf("缺少「无内联字节」的带来源日志:%q", log)
|
||||
}
|
||||
// 远程引用可能带凭据,绝不进日志。
|
||||
if strings.Contains(log, "secret-token=abc") {
|
||||
t.Fatalf("日志里出现了远程引用原串(可能含凭据):%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundImageMediaKeepsExistingPath 钉住边界:图片类媒体**不**走文档摄取
|
||||
// (design/24 §4.2 末:图片走既有视觉链路),因此不落库、不挂文档部件、不产生提示行。
|
||||
func TestInboundImageMediaKeepsExistingPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
msg := directMessage()
|
||||
msg.Content = "看这张图"
|
||||
msg.Media = []adapter.Media{{
|
||||
Kind: adapter.MediaImage, Name: "photo.png", MIMEType: "image/png",
|
||||
Data: []byte("\x89PNG\r\n\x1a\n"), Size: 8,
|
||||
}}
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
||||
t.Fatalf("图片类媒体被文档摄取落库了:%v", names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if strings.Contains(visible, "[附件 ") || strings.Contains(visible, "无法读取") {
|
||||
t.Fatalf("图片类媒体产生了文档清单/提示行:%q", visible)
|
||||
}
|
||||
if !strings.Contains(visible, "看这张图") {
|
||||
t.Fatalf("用户文本丢了:%q", visible)
|
||||
}
|
||||
if log := env.logs.String(); strings.Contains(log, "attachment") {
|
||||
t.Fatalf("图片类媒体产生了附件日志:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileMediaWithoutStoreIsHonest 钉住「未接线」路径:组合根没给落盘配置时,
|
||||
// 带文件的消息照旧能跑(模型收到诚实提示),只是不落库 —— 绝不假装读到了。
|
||||
func TestInboundFileMediaWithoutStoreIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, false)
|
||||
msg := fileMessage("这个文件呢", "notes.txt", []byte("正文\n"), "text/plain")
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatal("未接线时回合没有跑到模型")
|
||||
}
|
||||
if !strings.Contains(env.model.visibleText(), "未接线文档存储") {
|
||||
t.Fatalf("未接线时没有诚实提示:%q", env.model.visibleText())
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "document store not wired") {
|
||||
t.Fatalf("未接线时没有带来源的日志:%q", log)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package channelbridge
|
||||
|
||||
// 本文件锁定 **IM 入站附件摄取**(design/24 §3 / §4.4;切片 M1):
|
||||
//
|
||||
// - 文件类媒体 → 共享文档存储落库 → 装配期抽取 → **模型可见视图里出现清单行与
|
||||
// 正文**(真 store + 真抽取器 + 真循环 + 假模型,端到端);
|
||||
// - 不支持的格式**不落库**,但模型收到一行诚实提示、日志留一条带来源的记录,
|
||||
// 且日志**绝不记正文**;
|
||||
// - 渠道只给远程引用(无内联字节)时同样诚实,不静默;
|
||||
// - 摄取在**闸门之后**:被拉黑/被隔离的消息绝不在盘上留下文件,其附件也只进
|
||||
// 隔离会话,不镜像到主人主会话;
|
||||
// - 只有附件的消息(无文本)不再被判成「没有内容」而丢弃;
|
||||
// - 图片类媒体保持现状(不走摄取,也不落库)。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// stubModelAdapter 是最小的 model.Adapter:记下每次补全请求的**模型可见视图**
|
||||
// (即装配期 resolveDocuments 替换之后的形态),并回一条固定文本。
|
||||
type stubModelAdapter struct {
|
||||
mu sync.Mutex
|
||||
seen [][]model.Message
|
||||
}
|
||||
|
||||
func (a *stubModelAdapter) Name() string { return "stub" }
|
||||
|
||||
func (a *stubModelAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.mu.Lock()
|
||||
a.seen = append(a.seen, req.Messages)
|
||||
a.mu.Unlock()
|
||||
return model.Response{Model: "stub", Content: "读完了", StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
func (a *stubModelAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// visibleText 返回模型最后看到的全部消息文本(含系统与用户回合)。
|
||||
func (a *stubModelAdapter) visibleText() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if len(a.seen) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, m := range a.seen[len(a.seen)-1] {
|
||||
for _, c := range m.Content {
|
||||
b.WriteString(c.Text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// calls 返回模型被调用的次数(0 = 循环根本没跑)。
|
||||
func (a *stubModelAdapter) calls() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return len(a.seen)
|
||||
}
|
||||
|
||||
// safeBuffer 是并发安全的日志缓冲(桥接器的入站路径可能在 goroutine 里写日志)。
|
||||
type safeBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *safeBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *safeBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// ingestEnv 是入站附件端到端用例的装配:**真 store + 真抽取器 + 真循环 + 假模型**,
|
||||
// 因此模型看到的清单行与正文就是生产路径装配出来的那一份。
|
||||
type ingestEnv struct {
|
||||
bridge *Bridge
|
||||
model *stubModelAdapter
|
||||
adapter *fakeAdapter
|
||||
sessions *stubSessions
|
||||
uploads string
|
||||
logs *safeBuffer
|
||||
}
|
||||
|
||||
// newIngestEnv 装配上述端到端环境。cfg 给出入站附件的落盘配置(未给 UploadsDir 时
|
||||
// 落到临时目录);cfg 为零值时即「未接线」用例(跳过 SetDocumentStore)。
|
||||
func newIngestEnv(t *testing.T, cfg documents.StoreConfig, wireStore bool) *ingestEnv {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := cfg.UploadsDir
|
||||
if uploads == "" {
|
||||
uploads = filepath.Join(dataDir, "uploads", "documents")
|
||||
}
|
||||
cfg.UploadsDir = uploads
|
||||
logs := &safeBuffer{}
|
||||
log := slog.New(slog.NewTextHandler(logs, nil))
|
||||
|
||||
modelAdapter := &stubModelAdapter{}
|
||||
ag, err := agent.New("测试系统提示词", nil, nil, agent.WithModel(modelAdapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("构造 agent: %v", err)
|
||||
}
|
||||
resolver := documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads, Log: log})
|
||||
runner := loop.New(loop.Options{
|
||||
Agent: ag, DocumentResolver: resolver, MaxRounds: 4, Log: log,
|
||||
})
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
|
||||
opts := Options{
|
||||
Log: log, Loop: runner, Sessions: sess, Owner: testOwnerProfile("telegram", "100"),
|
||||
}
|
||||
if wireStore {
|
||||
opts.DocumentStore = cfg
|
||||
}
|
||||
b, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
platform := &fakeAdapter{name: "telegram"}
|
||||
b.Add(platform)
|
||||
return &ingestEnv{
|
||||
bridge: b, model: modelAdapter, adapter: platform,
|
||||
sessions: sess, uploads: uploads, logs: logs,
|
||||
}
|
||||
}
|
||||
|
||||
// fileMessage 构造一条主人私聊消息,附带一个文件类媒体。
|
||||
func fileMessage(text, name string, data []byte, mime string) adapter.UnifiedMessage {
|
||||
msg := directMessage()
|
||||
msg.Content = text
|
||||
msg.ContentType = adapter.ContentText
|
||||
if text == "" {
|
||||
msg.ContentType = adapter.ContentFile
|
||||
}
|
||||
msg.Media = []adapter.Media{{
|
||||
Kind: adapter.MediaFile, Name: name, Size: int64(len(data)), MIMEType: mime, Data: data,
|
||||
}}
|
||||
return msg
|
||||
}
|
||||
|
||||
// docDirNames 返回文档落盘目录里的条目名(目录不存在时返回 nil)。
|
||||
func docDirNames(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
t.Fatalf("读文档目录: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestInboundFileMediaIngestedAndVisibleToModel 覆盖验收第 3 条:一条带文件媒体的
|
||||
// 入站消息 → 落库 → 抽取 → **模型可见视图里出现该附件的清单行与正文**。
|
||||
func TestInboundFileMediaIngestedAndVisibleToModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
body := "第一行:昔涟的测试附件\n第二行:正文必须真的进上下文\n"
|
||||
msg := fileMessage("看看这个", "notes.txt", []byte(body), "text/plain")
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
names := docDirNames(t, env.uploads)
|
||||
if len(names) != 1 {
|
||||
t.Fatalf("落盘文件数 = %v, want 1(附件必须落库)", names)
|
||||
}
|
||||
stored, err := os.ReadFile(filepath.Join(env.uploads, names[0]))
|
||||
if err != nil {
|
||||
t.Fatalf("读落盘文件: %v", err)
|
||||
}
|
||||
if string(stored) != body {
|
||||
t.Fatalf("落盘内容 = %q, want %q", stored, body)
|
||||
}
|
||||
if !strings.HasSuffix(names[0], ".txt") || len(names[0]) != documents.HashHexLen+len(".txt") {
|
||||
t.Fatalf("落盘名 = %q, want <32 位十六进制>.txt(内容寻址)", names[0])
|
||||
}
|
||||
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, "[附件 "+names[0]+" · ") {
|
||||
t.Fatalf("模型可见视图里没有附件清单行:%q", visible)
|
||||
}
|
||||
if !strings.Contains(visible, "第二行:正文必须真的进上下文") {
|
||||
t.Fatalf("模型可见视图里没有附件正文:%q", visible)
|
||||
}
|
||||
if !strings.Contains(visible, "看看这个") {
|
||||
t.Fatalf("模型可见视图里丢了用户文本:%q", visible)
|
||||
}
|
||||
if log := env.logs.String(); !strings.Contains(log, "inbound attachment stored") ||
|
||||
!strings.Contains(log, "source=services/channelbridge") {
|
||||
t.Fatalf("落库日志缺少来源或内容:%q", log)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundUnsupportedFileIsHonestAndNotStored 覆盖验收第 4 条:不支持的格式
|
||||
// 不落库,但模型知道「有附件读不到」,且日志带来源、只记 name/mime/size(绝不记正文)。
|
||||
func TestInboundUnsupportedFileIsHonestAndNotStored(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
label string
|
||||
name string
|
||||
mime string
|
||||
data []byte
|
||||
}{
|
||||
{"扩展名不在白名单", "tool.py", "text/x-python", []byte("print('秘密内容')\n")},
|
||||
{"内容被嗅探否决", "lie.txt", "text/plain", append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 32)...)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
env.bridge.process(context.Background(), fileMessage("跑一下", tc.name, tc.data, tc.mime))
|
||||
|
||||
if names := docDirNames(t, env.uploads); len(names) != 0 {
|
||||
t.Fatalf("%s: 被拒的附件仍落库 %v", tc.label, names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, tc.name) || !strings.Contains(visible, "无法读取") {
|
||||
t.Fatalf("%s: 模型可见视图里没有诚实提示行:%q", tc.label, visible)
|
||||
}
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatalf("%s: 回合没有跑到模型", tc.label)
|
||||
}
|
||||
log := env.logs.String()
|
||||
if !strings.Contains(log, "inbound attachment rejected") || !strings.Contains(log, tc.name) {
|
||||
t.Fatalf("%s: 缺少带来源的拒绝日志:%q", tc.label, log)
|
||||
}
|
||||
if strings.Contains(log, "秘密内容") {
|
||||
t.Fatalf("%s: 日志里出现了附件正文(红线:只记 name/mime/size)", tc.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileMediaBlacklistedNotIngested 覆盖验收第 5 条的一半:被黑白名单拉黑的
|
||||
// 群/陌生人消息,附件**不落库**、也不驱动循环(摄取在闸门之后)。
|
||||
func TestInboundFileMediaBlacklistedNotIngested(t *testing.T) {
|
||||
t.Parallel()
|
||||
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
|
||||
chat := &stubChatter{res: &loop.Result{Text: "不该回复"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
||||
var rejected []string
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{},
|
||||
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
|
||||
OnRejected: func(_ adapter.UnifiedMessage, reason string) { rejected = append(rejected, reason) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ad := &fakeAdapter{name: "telegram", cfg: map[string]any{
|
||||
"bot_token": "configured", "denyGroups": []string{"-100"},
|
||||
}}
|
||||
b.Add(ad)
|
||||
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-100", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-100", SenderID: "9", SenderName: "陌生人",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Content: "给你个文件",
|
||||
ContentType: adapter.ContentText,
|
||||
Media: []adapter.Media{{
|
||||
Kind: adapter.MediaFile, Name: "stranger.txt", Size: 6, MIMEType: "text/plain", Data: []byte("你好\n"),
|
||||
}},
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
if names := docDirNames(t, uploads); len(names) != 0 {
|
||||
t.Fatalf("被拉黑的消息把附件落库了:%v", names)
|
||||
}
|
||||
if chat.sessionID != "" || chat.messages != nil {
|
||||
t.Fatalf("被拉黑的消息仍驱动了循环:session=%q", chat.sessionID)
|
||||
}
|
||||
if len(rejected) != 1 || rejected[0] != "group_denied" {
|
||||
t.Fatalf("拒绝原因 = %v, want [group_denied]", rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundGroupFileStaysInIsolatedSession 覆盖验收第 5 条的另一半:第三方(群聊)
|
||||
// 消息的附件只随该消息进入**隔离会话**,绝不镜像到主人主会话(沿用既有策略测试写法)。
|
||||
func TestInboundGroupFileStaysInIsolatedSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
uploads := filepath.Join(t.TempDir(), "uploads", "documents")
|
||||
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
|
||||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}}
|
||||
hist := &captureHistory{}
|
||||
b, err := New(Options{
|
||||
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
|
||||
DocumentStore: documents.StoreConfig{UploadsDir: uploads},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
// 被点名的群消息 → 回复(design/12 §6 策略二),因此会真的走完流水线。
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
|
||||
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
|
||||
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: "9"}},
|
||||
Content: "看下这个", ContentType: adapter.ContentText,
|
||||
Media: []adapter.Media{{
|
||||
Kind: adapter.MediaFile, Name: "third.txt", Size: 4, MIMEType: "text/plain", Data: []byte("hi\n"),
|
||||
}},
|
||||
}
|
||||
b.process(context.Background(), msg)
|
||||
|
||||
if names := docDirNames(t, uploads); len(names) != 1 {
|
||||
t.Fatalf("群聊附件落盘文件数 = %v, want 1(第三方附件同样走共享存储)", names)
|
||||
}
|
||||
if chat.sessionID != "sess-g" {
|
||||
t.Fatalf("循环会话 = %q, want 隔离会话 sess-g", chat.sessionID)
|
||||
}
|
||||
recs := hist.records()
|
||||
if len(recs) != 2 {
|
||||
t.Fatalf("隔离会话落库条数 = %d, want 2", len(recs))
|
||||
}
|
||||
for i, rec := range recs {
|
||||
if rec.SessionID != "sess-g" {
|
||||
t.Fatalf("record[%d] SessionID = %q, want 隔离会话(绝不镜像到主人主会话)", i, rec.SessionID)
|
||||
}
|
||||
}
|
||||
// 用户消息在循环里带着文档部件(图片/文档只进用户回合)。
|
||||
var docParts int
|
||||
for _, m := range chat.messages {
|
||||
if m.Role == model.RoleSystem {
|
||||
continue
|
||||
}
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeDocument {
|
||||
docParts++
|
||||
}
|
||||
}
|
||||
}
|
||||
if docParts != 1 {
|
||||
t.Fatalf("用户消息里的文档部件数 = %d, want 1", docParts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInboundFileOnlyMessageReachesLoop 钉住「只有附件、没有文本」的消息不再被判成
|
||||
// 没有内容而丢弃,且那条消息**不塞空文本部件**(provider 会拒空文本块)。
|
||||
func TestInboundFileOnlyMessageReachesLoop(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newIngestEnv(t, documents.StoreConfig{}, true)
|
||||
msg := fileMessage("", "only.md", []byte("# 只有附件\n正文\n"), "text/markdown")
|
||||
|
||||
env.bridge.process(context.Background(), msg)
|
||||
|
||||
if env.model.calls() == 0 {
|
||||
t.Fatal("纯附件消息没有驱动循环")
|
||||
}
|
||||
names := docDirNames(t, env.uploads)
|
||||
if len(names) != 1 {
|
||||
t.Fatalf("落盘文件数 = %v, want 1", names)
|
||||
}
|
||||
visible := env.model.visibleText()
|
||||
if !strings.Contains(visible, "[附件 "+names[0]+" · ") || !strings.Contains(visible, "# 只有附件") {
|
||||
t.Fatalf("模型可见视图里没有清单行或正文:%q", visible)
|
||||
}
|
||||
env.model.mu.Lock()
|
||||
defer env.model.mu.Unlock()
|
||||
if len(env.model.seen) == 0 {
|
||||
t.Fatal("没有记录到模型请求")
|
||||
}
|
||||
for _, m := range env.model.seen[0] {
|
||||
if m.Role != model.RoleUser {
|
||||
continue
|
||||
}
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText && strings.TrimSpace(c.Text) == "" {
|
||||
t.Fatalf("纯附件消息里塞了空文本部件:%+v", m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,14 @@ import (
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// maxChatDocuments 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。
|
||||
const maxChatDocuments = 5
|
||||
// 取值取自共享存储核心(切片 M1):Web 上传的单回合上限与 IM 入站附件的单条
|
||||
// 上限因此同源,不可能一处改宽、另一处照旧。
|
||||
const maxChatDocuments = documents.MaxPerMessage
|
||||
|
||||
// tooManyDocumentsCode 是附件数超限的机器可读错误码(design/24 §6.2 表格)。
|
||||
// 它与 image 侧的 image_too_large 同一风格:客户端只看 code 就能分开处理。
|
||||
|
||||
@@ -16,9 +16,6 @@ package gateway
|
||||
// 元数据表 `documents` 只写不回收(引用计数与 GC 属切片 S7)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -31,17 +28,19 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxDocumentBytes 是 `POST /api/v1/documents` 的**单文件字节**默认上限:20 MiB。
|
||||
// maxDocumentBytes 是 `POST /api/v1/documents` 的**单文件字节**默认上限:
|
||||
// 它就是共享存储核心的 `documents.DefaultMaxBytes`(20 MiB)—— 全仓只定义
|
||||
// 一次,网关与 IM 入站附件因此不可能各写一个数(design/24 §3.3)。
|
||||
// 它与图片上限(`maxUploadBytes` / `CHAT_IMAGE_MAX_BYTES`)**刻意解耦**:
|
||||
// 图片上限被 vision 解码层钳住,文档上限由本项目的解析与上下文预算决定;
|
||||
// 共用同一个数会让「改文档上限」静默改掉图片行为(design/24 §3.3)。
|
||||
maxDocumentBytes = 20 << 20
|
||||
maxDocumentBytes = documents.DefaultMaxBytes
|
||||
|
||||
// documentMultipartSlack 是 multipart 信封(边界、部件头)在文件上限之上
|
||||
// 预留的字节余量;文件本身的校验按确切上限做。
|
||||
@@ -53,7 +52,8 @@ const (
|
||||
|
||||
// documentHashHexLen 是内容寻址落盘名使用的 sha256 十六进制前缀长度
|
||||
// (32 位十六进制 = 128 位),与 `validUploadName` 的形态校验同宽。
|
||||
documentHashHexLen = 32
|
||||
// 它取自共享存储核心,与落盘/读侧的名字形态同源(切片 M1)。
|
||||
documentHashHexLen = documents.HashHexLen
|
||||
|
||||
// documentFormField 是上传 multipart 里的文件字段名(design/24 §6.1)。
|
||||
documentFormField = "file"
|
||||
@@ -75,8 +75,8 @@ const (
|
||||
// documentSniffOKMIME 是唯一放行的嗅探结果基类型。白名单里的每一种文档
|
||||
// (文本、代码、CSV、JSON、YAML…)经 `http.DetectContentType` 嗅探后都落在
|
||||
// `text/plain`;HTML/XML/SVG/压缩包/可执行文件/判不定者各有自己的签名,
|
||||
// 因此「不是 text/plain」即「拒绝」。
|
||||
documentSniffOKMIME = "text/plain"
|
||||
// 因此「不是 text/plain」即「拒绝」。取值取自共享存储核心(同一裁决)。
|
||||
documentSniffOKMIME = documents.SniffOKMIME
|
||||
|
||||
// documentFallbackMIME 是读侧嗅探不出放行类型时下发的媒体类型。配合
|
||||
// attachment + nosniff,它保证浏览器永远不会按类型渲染文档。
|
||||
@@ -91,10 +91,6 @@ const (
|
||||
documentUploadFailedCode = "document_upload_failed"
|
||||
)
|
||||
|
||||
// ErrDocumentTooLarge 报告文档体量超过生效上限。`storeDocument` 用它把
|
||||
// 「超限(413)」与「存储失败(500)」分开,调用方用 `errors.Is` 判定。
|
||||
var ErrDocumentTooLarge = errors.New("gateway: document too large")
|
||||
|
||||
// documentResponse 是上传成功的载荷。URL 是**相对**同源路径(design/24 §6.1):
|
||||
// 读侧要求 Bearer,因此该 URL 不是「贴进 `<img>` 就能取」的能力 URL,绝对化
|
||||
// 没有意义;客户端真正要用的是 `chat.documents` 里的 `name`。
|
||||
@@ -107,23 +103,6 @@ type documentResponse struct {
|
||||
Deduped bool `json:"deduped"`
|
||||
}
|
||||
|
||||
// documentRecord 是写入 `documents` 表的一行元数据(S1 只写)。
|
||||
type documentRecord struct {
|
||||
Name string
|
||||
SHA256 string
|
||||
MIME string
|
||||
Size int64
|
||||
Uploader string
|
||||
}
|
||||
|
||||
// documentStored 是一次成功落盘的结果。
|
||||
type documentStored struct {
|
||||
name string
|
||||
sha256 string
|
||||
size int64
|
||||
deduped bool
|
||||
}
|
||||
|
||||
// documentLimitBytes 解析本次生效的单文件字节上限:配置给了正数就用它,
|
||||
// 否则(未配置的零值、0、负数)回落到默认 20 MiB —— 语义是「≤0 = 用默认」
|
||||
// 而不是「禁用上限」,与图片侧 `chatImageLimitBytes` 的口径一致。
|
||||
@@ -138,23 +117,18 @@ func documentLimitBytes(configured int) int {
|
||||
|
||||
// documentExtOf 从客户端原始文件名取出小写扩展名(含点)。它只作参考:
|
||||
// 结果必须同时通过扩展名白名单与字节嗅探,客户端因此无法靠改扩展名换类型。
|
||||
// 取值逻辑与共享存储核心同源(切片 M1),读侧不再各写一套。
|
||||
func documentExtOf(filename string) string {
|
||||
return strings.ToLower(filepath.Ext(filename))
|
||||
return documents.ExtOf(filename)
|
||||
}
|
||||
|
||||
// documentExtAllowed 报告扩展名是否在放行集合内。deny-by-default:集合之外的
|
||||
// 一切(`.html`/`.svg`/`.xml`/`.zip`/`.docx`/`.xlsx`/`.exe`/`.dll`/`.bat`/
|
||||
// `.cmd`/`.ps1`/`.js`/`.mjs`/`.sh`/`.py`…)一律拒绝。注意 `.js`/`.py` 即便
|
||||
// 嗅探成 `text/plain` 也必须拒绝 —— 判定以扩展名与嗅探的**合取**为准。
|
||||
// 白名单本体只存在于 `documents.ExtAllowed`(22 项,design/24 §9.2-2 定稿)。
|
||||
func documentExtAllowed(ext string) bool {
|
||||
switch ext {
|
||||
case ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl",
|
||||
".yaml", ".yml", ".log", ".ini", ".cfg", ".toml",
|
||||
".go", ".ts", ".tsx", ".java", ".c", ".h", ".cpp", ".rs", ".sql":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return documents.ExtAllowed(ext)
|
||||
}
|
||||
|
||||
// documentBaseMIME 把嗅探结果归一为不含参数的基类型(`text/plain; charset=utf-8`
|
||||
@@ -169,14 +143,9 @@ func documentBaseMIME(sniffed string) string {
|
||||
|
||||
// documentSniffAllowed 报告嗅探所得类型是否落在放行的文本类内。它是「类型裁决
|
||||
// 的唯一裁决者」在代码里的落点:客户端声明的 `Content-Type` 从不参与决定。
|
||||
// 裁决本体只存在于 `documents.SniffAllowed`(写侧与读侧同一份)。
|
||||
func documentSniffAllowed(sniffed string) bool {
|
||||
return documentBaseMIME(sniffed) == documentSniffOKMIME
|
||||
}
|
||||
|
||||
// documentHashName 返回内容寻址的落盘名:sha256 十六进制的前 32 位 + ext。
|
||||
// 同一份字节配同一个扩展名必然得到同一个名字,去重因此是「同名」的自然结果。
|
||||
func documentHashName(sum []byte, ext string) string {
|
||||
return hex.EncodeToString(sum)[:documentHashHexLen] + ext
|
||||
return documents.SniffAllowed(sniffed)
|
||||
}
|
||||
|
||||
// validDocumentName 报告 name 是否恰好是 32 位小写十六进制主名加白名单文档
|
||||
@@ -238,10 +207,45 @@ func (s *Server) readDocumentUpload(w http.ResponseWriter, r *http.Request, limi
|
||||
return file, header, true
|
||||
}
|
||||
|
||||
// documentStore 按本次生效的目录与上限构造一个文档存储(切片 M1 抽出的共享
|
||||
// 核心,见 `internal/runtime/documents`):网关只是它的一个调用方 —— IM 入站
|
||||
// 附件走的是**同一个** Store,两条来源因此不可能漂移。上限来自
|
||||
// `CHAT_DOCUMENT_MAX_BYTES`(经 documentLimitBytes 归一),并且**每个请求**
|
||||
// 按当时的配置取值,面板改配置后下一个请求即生效。
|
||||
//
|
||||
// 元数据写入面由 `MetadataWriterFor` 给出:未接库(`s.dbPath` 为空)时它返回
|
||||
// 真正的 nil 接口,Store 因此按「未注入」跳过写表并留痕(S1 的行为)。
|
||||
func (s *Server) documentStore(dir string, limit int, uploader string) *documents.Store {
|
||||
return documents.NewStore(documents.StoreConfig{
|
||||
UploadsDir: dir,
|
||||
MaxBytes: limit,
|
||||
Uploader: uploader,
|
||||
Meta: documents.MetadataWriterFor(s.dbPath, s.log),
|
||||
Log: s.log,
|
||||
})
|
||||
}
|
||||
|
||||
// readDocumentBytes 读出本次上传的文件字节,最多 `limit+1` 字节:多读一个字节
|
||||
// 就足以判定超限,因此既不把超限内容放大进内存,也不把它留在盘上。S1 的流式
|
||||
// 落盘在抽成共享存储后改为「先读进内存再交给 store」——上限本身(默认 20 MiB)
|
||||
// 没有放宽,而文档抽取路径本来就整份读进内存(design/24 §9.6 已登记的同一代价)。
|
||||
func readDocumentBytes(file io.Reader, limit int) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(file, int64(limit)+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("documents: 读上传内容: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// handleDocumentUpload 接受带单个 `file` 部件的 multipart/form-data 请求,
|
||||
// 用字节嗅探裁决类型,按内容寻址存到 `<dataDir>/uploads/documents`,写一行
|
||||
// 元数据并审计 `document.upload`。首次上传回 201 + `deduped:false`;命中同一
|
||||
// 内容时回 200 + `deduped:true` 且 `name` 与首次逐字一致。
|
||||
//
|
||||
// 类型/体积的裁决**全部**由 `documents.Store.Save` 给出(切片 M1 起与 IM 入站
|
||||
// 附件同源);这里只把它的哨兵错误映射为 S1 既有的契约错误码,判定顺序
|
||||
// (扩展名 → 空 → 上限 → 嗅探)由 store 保持,因此线上答案与抽共享核心之前
|
||||
// 逐字一致。
|
||||
func (s *Server) handleDocumentUpload(w http.ResponseWriter, r *http.Request) {
|
||||
limit := documentLimitBytes(s.cfg.Server.ChatDocumentMaxBytes)
|
||||
file, header, ok := s.readDocumentUpload(w, r, limit)
|
||||
@@ -250,139 +254,66 @@ func (s *Server) handleDocumentUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
ext := documentExtOf(header.Filename)
|
||||
if !documentExtAllowed(ext) {
|
||||
// 扩展名先于读体判定:明显不该收的扩展名不必先把请求体读进内存(S1 的
|
||||
// 顺位也是先看扩展名)。判定用的是**共享存储核心的同一份白名单**,
|
||||
// 与 store 内部的裁决不可能分叉;store 仍会再判一次(防御性)。
|
||||
if !documents.ExtAllowed(documents.ExtOf(header.Filename)) {
|
||||
s.failDocumentUpload(w, r, documentUnsupportedError())
|
||||
return
|
||||
}
|
||||
if header.Size <= 0 {
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "文件为空"))
|
||||
return
|
||||
}
|
||||
if header.Size > int64(limit) {
|
||||
s.failDocumentUpload(w, r, documentTooLargeError(limit))
|
||||
return
|
||||
}
|
||||
sniffed, err := sniffImageMIME(file)
|
||||
data, err := readDocumentBytes(file, limit)
|
||||
if err != nil {
|
||||
s.documentsLog().Error("document sniff", "err", err)
|
||||
s.documentsLog().Error("document read", "err", err)
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "读取上传内容失败"))
|
||||
return
|
||||
}
|
||||
if !documentSniffAllowed(sniffed) {
|
||||
s.failDocumentUpload(w, r, documentUnsupportedError())
|
||||
return
|
||||
}
|
||||
dir, err := s.documentsDir()
|
||||
if err != nil {
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "文档存储目录未配置"))
|
||||
return
|
||||
}
|
||||
stored, err := s.storeDocument(dir, ext, file, int64(limit))
|
||||
if errors.Is(err, ErrDocumentTooLarge) {
|
||||
s.failDocumentUpload(w, r, documentTooLargeError(limit))
|
||||
return
|
||||
}
|
||||
ref, err := s.documentStore(dir, limit, documentUploader(r)).Save(r.Context(), header.Filename, data)
|
||||
if err != nil {
|
||||
s.documentsLog().Error("document store", "err", err)
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "保存文档失败"))
|
||||
return
|
||||
}
|
||||
rec := documentRecord{
|
||||
Name: stored.name,
|
||||
SHA256: stored.sha256,
|
||||
MIME: documentBaseMIME(sniffed),
|
||||
Size: stored.size,
|
||||
Uploader: documentUploader(r),
|
||||
}
|
||||
if err := s.insertDocumentMetadata(r.Context(), rec); err != nil {
|
||||
s.documentsLog().Error("document metadata", "err", err, "name", rec.Name)
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "记录文档元数据失败"))
|
||||
s.failDocumentSave(w, r, limit, ref, err)
|
||||
return
|
||||
}
|
||||
s.documentsLog().Info("document stored",
|
||||
"name", rec.Name, "size", rec.Size, "mime", rec.MIME,
|
||||
"sha256", rec.SHA256, "deduped", stored.deduped)
|
||||
"name", ref.Name, "size", ref.Size, "mime", ref.MIME,
|
||||
"sha256", ref.SHA256, "deduped", ref.Deduped)
|
||||
s.auditClaims(r, documentUploadAction, "ok")
|
||||
status := http.StatusCreated
|
||||
if stored.deduped {
|
||||
if ref.Deduped {
|
||||
status = http.StatusOK
|
||||
}
|
||||
writeJSON(w, status, documentResponse{
|
||||
Name: rec.Name,
|
||||
URL: documentFilePrefix + rec.Name,
|
||||
MIME: rec.MIME,
|
||||
Size: rec.Size,
|
||||
SHA256: rec.SHA256,
|
||||
Deduped: stored.deduped,
|
||||
Name: ref.Name,
|
||||
URL: documentFilePrefix + ref.Name,
|
||||
MIME: ref.MIME,
|
||||
Size: ref.Size,
|
||||
SHA256: ref.SHA256,
|
||||
Deduped: ref.Deduped,
|
||||
})
|
||||
}
|
||||
|
||||
// storeDocument 把 f 按内容寻址写入 dir:先流式写点前缀临时文件、同时计算
|
||||
// sha256,再把临时文件重命名到 `<sha256[:32]><ext>`(部分写入因此绝不会以可读的
|
||||
// 名字露面)。目标名已存在(同字节同扩展名)时丢弃临时文件并报告 deduped,
|
||||
// 盘上只留一份。目录 0700、文件继承临时的 0600。
|
||||
func (s *Server) storeDocument(dir, ext string, f io.Reader, limit int64) (documentStored, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return documentStored{}, fmt.Errorf("create document dir: %w", err)
|
||||
// failDocumentSave 把 store 的哨兵错误映射为 S1 既有的线上错误并审计。映射表
|
||||
// 逐条对应(415 类型拒绝 / 413 超限 / 400 空文件 / 500 存储或元数据失败),
|
||||
// 与抽共享核心之前的对外行为一字不差:拒绝是**可预期的输入问题**,因此不额外
|
||||
// 记错误日志(与 S1 相同);存储与元数据失败才留日志。
|
||||
func (s *Server) failDocumentSave(w http.ResponseWriter, r *http.Request, limit int, ref documents.Ref, err error) {
|
||||
switch {
|
||||
case errors.Is(err, documents.ErrTooLarge):
|
||||
s.failDocumentUpload(w, r, documentTooLargeError(limit))
|
||||
case errors.Is(err, documents.ErrExtNotAllowed), errors.Is(err, documents.ErrContentNotText):
|
||||
s.failDocumentUpload(w, r, documentUnsupportedError())
|
||||
case errors.Is(err, documents.ErrEmpty):
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "文件为空"))
|
||||
case errors.Is(err, documents.ErrMetadata):
|
||||
s.documentsLog().Error("document metadata", "err", err, "name", ref.Name)
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "记录文档元数据失败"))
|
||||
default:
|
||||
s.documentsLog().Error("document store", "err", err)
|
||||
s.failDocumentUpload(w, r, apperr.Wrap(apperr.ErrInternal, documentUploadFailedCode, "保存文档失败"))
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".document-*")
|
||||
if err != nil {
|
||||
return documentStored{}, fmt.Errorf("create document temp: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
hasher := sha256.New()
|
||||
written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(f, limit+1))
|
||||
if err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(tmpName)
|
||||
return documentStored{}, fmt.Errorf("write document: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return documentStored{}, fmt.Errorf("close document: %w", err)
|
||||
}
|
||||
// 文件级上限的第二次把关(第一道是 multipart 部件大小):多读一个字节即可
|
||||
// 判定超限,因此既不放大内存也不把超限文件留在盘上。
|
||||
if written > limit {
|
||||
_ = os.Remove(tmpName)
|
||||
return documentStored{}, ErrDocumentTooLarge
|
||||
}
|
||||
digest := hasher.Sum(nil)
|
||||
sum := hex.EncodeToString(digest)
|
||||
name := documentHashName(digest, ext)
|
||||
if info, serr := os.Stat(filepath.Join(dir, name)); serr == nil && !info.IsDir() {
|
||||
_ = os.Remove(tmpName)
|
||||
return documentStored{name: name, sha256: sum, size: written, deduped: true}, nil
|
||||
}
|
||||
if err := os.Rename(tmpName, filepath.Join(dir, name)); err != nil {
|
||||
_ = os.Remove(tmpName)
|
||||
return documentStored{}, fmt.Errorf("commit document: %w", err)
|
||||
}
|
||||
return documentStored{name: name, sha256: sum, size: written, deduped: false}, nil
|
||||
}
|
||||
|
||||
// insertDocumentMetadata 把一行文档元数据写进 `documents` 表(迁移 0014)。
|
||||
// 同一内容寻址名重复上传时 `DO NOTHING`:首次的 uploader/created_at 是权威,
|
||||
// 去重不刷新它们。`s.dbPath` 未注入(未接数据库的构建/测试)时跳过 —— 上传
|
||||
// 本身仍然成功,元数据留待 S7 的引用计数与 GC 统一接线。
|
||||
func (s *Server) insertDocumentMetadata(ctx context.Context, rec documentRecord) error {
|
||||
path := strings.TrimSpace(s.dbPath)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
conn, err := db.Open(ctx, path, s.log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db for document metadata: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
const query = `INSERT INTO documents (name, sha256, mime, size, uploader)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO NOTHING`
|
||||
if _, err := conn.ExecContext(ctx, query, rec.Name, rec.SHA256, rec.MIME, rec.Size, rec.Uploader); err != nil {
|
||||
return fmt.Errorf("insert document metadata: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// documentUploader 从请求上下文取上传者 id(由 auth.Authorize 填充),取不到时
|
||||
|
||||
Reference in new issue
Block a user