- 抽共享存储核心: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,与本次无关)。
63 lines
2.7 KiB
Go
63 lines
2.7 KiB
Go
package documents
|
||
|
||
// 本文件归属 `documents` 元数据表(迁移 0014)的**唯一写入实现**(design/24
|
||
// §3.2;切片 M1)。S1 把这条 SQL 写在网关里;抽到共享存储核心之后,Web 上传与
|
||
// IM 入站附件必须共用同一份写入逻辑,否则「哪条来源写了元数据、哪条没写」会
|
||
// 分叉,S7 的引用计数与 GC 也就失去单一事实来源。
|
||
//
|
||
// 沿用 S1 的两条既有事实(刻意不改,改动属另一片):
|
||
// - 每次写入 `db.Open` 开一条新连接(design/24 §9.2-5 已登记的技术债);
|
||
// - 同一存储名重复上传 `ON CONFLICT(name) DO NOTHING` —— 首次的
|
||
// uploader/created_at 是权威,去重不刷新它们。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||
)
|
||
|
||
// ErrMetadataNoPath 报告元数据写入器没有数据库路径(未接库的构建/测试)。
|
||
// 调用方据此不构造写入器(Store.Meta 留 nil),而不是拿到一个写不进去的替身。
|
||
var ErrMetadataNoPath = errors.New("documents: 元数据写入器缺少数据库路径")
|
||
|
||
// SQLMetadataWriter 把元数据行写进 SQLite 的 `documents` 表。它实现
|
||
// MetadataWriter,供网关(Web 上传)与组合根(IM 入站附件)共用。
|
||
type SQLMetadataWriter struct {
|
||
path string
|
||
log *slog.Logger
|
||
}
|
||
|
||
// MetadataWriterFor 按数据库路径给出元数据写入面;路径为空(或全空白)时返回
|
||
// **真正的 nil 接口**,调用方可以直接把它放进 `StoreConfig.Meta` 而不会踩
|
||
// 「接口里装着一个 nil 具体值」的陷阱(那会让「未注入」判定失效,把一次成功的
|
||
// 落盘变成 500)。未接库的构建/测试因此照旧可用:Store 按「未注入」跳过并留痕。
|
||
func MetadataWriterFor(path string, log *slog.Logger) MetadataWriter {
|
||
if strings.TrimSpace(path) == "" {
|
||
return nil
|
||
}
|
||
return &SQLMetadataWriter{path: path, log: log}
|
||
}
|
||
|
||
// WriteDocument 写入一行文档元数据。参数化 SQL、无拼接,错误按项目规范 wrap。
|
||
func (w *SQLMetadataWriter) WriteDocument(ctx context.Context, rec Record) error {
|
||
if w == nil || w.path == "" {
|
||
return fmt.Errorf("documents: 写元数据: %w", ErrMetadataNoPath)
|
||
}
|
||
conn, err := db.Open(ctx, w.path, w.log)
|
||
if err != nil {
|
||
return fmt.Errorf("documents: 打开元数据库: %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("documents: 插入元数据行: %w", err)
|
||
}
|
||
return nil
|
||
}
|