fix(channelbridge): IM 入站附件引用写进落库的用户消息(安卓报的 P0)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因:摄取结果只挂到「内存里的模型可见消息」(inbound.go 的 attachInboundAttachments),
落库时只传了 text、history.Message 没有 Document 字段 ⇒ 该列恒空 ⇒ 读侧 omitempty
导致 documents 键整个缺席 ⇒ 任何客户端只看到一条空消息。Web 侧不这样,是因为
gateway/session_persist.go 有 messageTextImageDocument 把 ContentTypeDocument 部件编码进该列
—— IM 路径缺的就是同一步。
修法:inboundAttachments.documentColumn() 复用 history.EncodeDocuments 同一口径写出该列;
persistToMain/persistIsolated 增 document 参数并返回刚落库的消息;新增两段式第二段
persistTurnDocumentStatus 按 id 回写抽取结论(只补结论、按存储名匹配、匹配不到原样留),
与 gateway/session_document_status.go 逐字对齐。
契约零扩张:无新端点/帧/字段;0 附件 ⇒ 列空 ⇒ 键省略;形状即 web 的同一结构体。
测试 6 条(含元素 JSON 逐字断言、unreadable 带 reason、被拒不编造芯片、群聊隔离分支、
真循环端到端);变异两条(还原挂载 / 关闭结论回写)⇒ 各 4 条红。
This commit is contained in:
AskaEth committed 2026-10-05 08:10:21 +08:00
1 parent f8f2e9c54c
commit 679cee04f7
7 files changed
+606 -19

No files matched your search

@@ -5,6 +5,11 @@ package channelbridge
// 上传同一份实现),成功者挂成该消息的文档部件,让装配期 `resolveDocuments`
// 照既有链路完成抽取/注入/清单/结论回写,**不需要新链路**。
//
// 同样的产物还必须是**用户消息落库时的 `document` 列**(documentColumn):只把部件
// 挂在内存里的模型可见消息上,客户端读到的那条消息就是一片空白 —— 附件芯片只认
// 库里的那一列。结论(status/chars/truncated/reason)随后由
// persistTurnDocumentStatus 补进同一列(inbound_documents_persist.go)。
//
// 读不到的附件**绝不静默**(design/24 §4.4):模型会收到一行诚实提示,日志留一条
// 带来源的记录(只记 name/mime/size,**绝不记正文**)。
//
@@ -26,6 +31,7 @@ import (
"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/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
@@ -81,6 +87,44 @@ func (a inboundAttachments) noticeText() string {
return strings.Join(a.notices, "\n")
}
// documentColumn 把本次摄取产出的文档部件编码成落库用的 `document` 列(design/24
// §2.4 的引用数组)。它与 Web 上传走**同一个字段、同一个结构体**:history.DocumentRef
// 同时就是历史 DTO 里 `documents` 数组的元素形状,因此两边客户端读到的附件芯片
// 逐字一致(名字/MIME/大小取自服务端,客户端给的原始文件名与声明类型都不在其中)。
//
// 与网关侧 documentRefOf(session_persist.go)是同一份映射:那里未导出、且本包
// 不许反向依赖网关,因此这里按同一口径重写一份 —— 两侧任一改动都必须同时改。
//
// 这里只落**引用**,不落结论:chars/truncated/status 是**本回合注入侧**的事实,
// 要等回合跑起来(装配期解析)才存在,由 persistTurnDocumentStatus 在回合结束后
// 补进同一列。没有附件时返回空串(与 EncodeDocuments 对空列表的口径一致:空串
// ⇒ DTO 里该键省略)。
func (a inboundAttachments) documentColumn() (string, error) {
refs := make([]history.DocumentRef, 0, len(a.parts))
for _, c := range a.parts {
refs = append(refs, history.DocumentRef{Name: c.DocumentName, MIME: c.DocumentMIME, Size: c.DocumentSize})
}
return history.EncodeDocuments(refs)
}
// inboundDocumentColumn 取本回合要写进用户消息 `document` 列的引用串。编码失败
// (只可能是坏数据或超限)绝不静默:打一条带来源的 ERROR,并让这条消息按「没有
// 附件」落库 —— 丢一条消息远比丢一个附件引用严重,而那条写不进去的引用也绝不
// 假装存在(与 Web 侧 persistUserMessages 的同一取舍)。
func (b *Bridge) inboundDocumentColumn(msg adapter.UnifiedMessage, attach inboundAttachments) string {
if len(attach.parts) == 0 {
return ""
}
column, err := attach.documentColumn()
if err != nil {
b.log.Error("inbound attachment refs not persisted",
"platform", msg.Platform, "channel", msg.ChannelID,
"documents", len(attach.parts), "error", err)
return ""
}
return column
}
// hasFileMedia 报告消息是否携带文件类媒体(决定「无文本消息」是否仍要跑流水线)。
func hasFileMedia(msg adapter.UnifiedMessage) bool {
for _, m := range msg.Media {