refactor+feat(documents,channelbridge): 共享存储核心 + IM 入站附件摄取(主人 2.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 抽共享存储核心: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:
AskaEth committed 2026-10-04 19:25:00 +08:00
1 parent 51c1672355
commit 2a43035f5d
12 files changed
+1743 -158

No files matched your search

+17
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
@@ -11,6 +12,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
@@ -77,6 +79,9 @@ func (a *app) wireChannels() error {
}
}
// 入站附件的目录口径与文档摄取层(wire_runtime.go 的 docResolver)同源,
// 两处必须指向同一棵目录树,否则附件会落在一个地方、抽取去另一个地方找。
docDataDir := resolveDataDir(a.cfg)
bridge, berr := channelbridge.New(channelbridge.Options{
Log: logger.WithSource(a.log, "services/channelbridge"),
Loop: a.loop,
@@ -85,6 +90,18 @@ func (a *app) wireChannels() error {
History: a.history,
QuietStartHour: a.cfg.Background.QuietStartHour,
QuietEndHour: a.cfg.Background.QuietEndHour,
// 入站附件摄取(design/24 §3;切片 M1):IM 收到的受支持格式文件落进与
// Web 上传**同一个**存储核心(目录口径与 wire_runtime.go 的文档摄取层
// 同源:`<DataDir>/uploads/documents`),元数据也写进同一张 `documents`
// 表——`documents` 表的写入路径因此全仓只有一条(S7 的引用计数与 GC 有
// 单一事实来源)。未配置数据目录时写入面为 nil,桥接器走「未接线」的
// 诚实路径(模型知道有附件读不到),绝不假装读到了。
DocumentStore: documents.StoreConfig{
DataDir: docDataDir,
UploadsDir: filepath.Join(docDataDir, "uploads", "documents"),
Meta: documents.MetadataWriterFor(a.dbPath, a.log),
Log: a.log,
},
// 可选的复审模型在接近单适配器上限时剔除低价值排队消息
// (design/12 §6 策略四)。适配器为 nil 时,闸门回落到 FIFO 驱逐。
Review: reviewFor(a.modelAdapter, logger.WithSource(a.log, "services/channelbridge/review")),
@@ -0,0 +1,47 @@
package main
import (
"os"
"strings"
"testing"
)
// TestWireChannels_WiresInboundDocumentStore 是一道「接线存在性」防回归检查
// (切片 M1;与 `TestWireRuntime_UsesTheCompositionRootSeams` 同一手法)。
//
// 组合根本身无法在单元测试里实例化(开库、迁移、绑端口,而后端还在运行),
// 因此「IM 入站附件确实被接上了共享文档存储」只能在源码层钉住:列出的字符串
// 少任何一个,桥接器都会静默退回「未接线」的诚实路径 —— 附件不再落库、S7 的 GC
// 也看不到 IM 来源的文件,而这一切**不会**让任何既有用例变红。所以必须显式钉住。
//
// 最后一对断言是**成对契约**:入站附件落盘的目录与文档摄取层读原文件的目录
// 必须逐字同源(`<DataDir>/uploads/documents`),否则附件会落在一个地方、
// 装配期去另一个地方找 —— 症状是「附件全都读不到」,且两边各自都自洽。
func TestWireChannels_WiresInboundDocumentStore(t *testing.T) {
t.Parallel()
const dirSeam = `UploadsDir: filepath.Join(docDataDir, "uploads", "documents")`
src, err := os.ReadFile("wire_channels.go")
if err != nil {
t.Fatalf("read wire_channels.go: %v", err)
}
for _, want := range []string{
"DocumentStore: documents.StoreConfig{",
dirSeam,
"Meta: documents.MetadataWriterFor(a.dbPath, a.log)",
"docDataDir := resolveDataDir(a.cfg)",
} {
if !strings.Contains(string(src), want) {
t.Fatalf("wire_channels.go 里找不到 %q —— IM 入站附件没有被接上共享文档存储", want)
}
}
reader, err := os.ReadFile("wire_runtime.go")
if err != nil {
t.Fatalf("read wire_runtime.go: %v", err)
}
if !strings.Contains(string(reader), dirSeam) {
t.Fatalf("文档摄取层的目录与 IM 入站附件的目录不同源(wire_runtime.go 里找不到 %q)—— "+
"附件会落在一个地方、装配期去另一个地方找", dirSeam)
}
}