Files
sundynix-agentix/sundynix-gateway/internal/store/model.go
T
Blizzard 6523323a27 feat(gateway): MinIO 孤儿对象 GC(重名覆盖后清理旧对象)
大文档正文存 MinIO,重名再入库若转内联或换键,旧对象会成孤儿泄漏。
SaveDoc 改为返回 (id, oldObjectKey);runIngest 在覆盖成功后,若旧键非空且
与新键不同,从 MinIO 删除旧对象。新建文档 oldObjectKey 为空,不触发。

注:当前无文档删除/改名端点,主要孤儿路径=大→内联覆盖,已覆盖;
真机 MinIO GC 验证待后续(逻辑直白,blob.Delete/SaveDoc 已分别验证)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:56:22 +08:00

295 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。
// 表名 sundynix_kb。(owner,name) 唯一 —— 同一用户下知识库名不重复。
// 向量/全文/图谱实际以 "owner/name" 作分区键,保证只有 owner 能查到自己的库。
type KB struct {
BaseModel
Owner string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Name string `gorm:"size:64;uniqueIndex:idx_kb_owner_name"`
Kind string `gorm:"size:16"` // folder / project / case / general
}
func (KB) TableName() string { return "sundynix_kb" }
// ListKB 列出某 owner 的全部知识库(按创建时间)。
func (p *Postgres) ListKB(ctx context.Context, owner string) ([]KB, error) {
if p.db == nil {
return nil, nil
}
var rows []KB
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("id").Find(&rows).Error
return rows, err
}
// EnsureKB 幂等登记一个知识库(已存在则保持,不覆盖 kind)。
func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error {
if p.db == nil {
return nil // 降级模式:不持久化注册表,不阻断入库
}
if kind == "" {
kind = "general"
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
DoNothing: true,
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
}
// Agent 是一份保存的 Agent 编排(React Flow 图 JSON,按 owner 隔离)。
// 表名 sundynix_agent。(owner,name) 唯一 —— 同一用户下编排名不重复。
type Agent struct {
BaseModel
Owner string `gorm:"size:64;uniqueIndex:idx_agent_on"`
Name string `gorm:"size:128;uniqueIndex:idx_agent_on"`
Graph string `gorm:"type:text"` // {nodes,edges} 的 JSON(含布局)
}
func (Agent) TableName() string { return "sundynix_agent" }
// ListAgents 返回某 owner 的全部编排(最近更新在前)。
func (p *Postgres) ListAgents(ctx context.Context, owner string) ([]Agent, error) {
if p.db == nil {
return nil, nil
}
var rows []Agent
err := p.db.WithContext(ctx).Where("owner = ?", owner).Order("updated_at desc").Find(&rows).Error
return rows, err
}
// SaveAgent 新建/更新一份编排(owner+name 唯一,重名覆盖图与更新时间)。
func (p *Postgres) SaveAgent(ctx context.Context, owner, name, graph string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "name"}},
DoUpdates: clause.Assignments(map[string]any{"graph": graph, "updated_at": time.Now()}),
}).Create(&Agent{Owner: owner, Name: name, Graph: graph}).Error
}
// DeleteAgent 删除某 owner 的一份编排。
func (p *Postgres) DeleteAgent(ctx context.Context, owner, name string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Where("owner = ? AND name = ?", owner, name).Delete(&Agent{}).Error
}
// Doc 是入库的一份文件/笔记 —— 文件主表(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。文档间关联一律用本表的雪花 ID 关联。
type Doc struct {
BaseModel
Owner string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
KB string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"` // 文件名称(不含扩展名 / 笔记名)
Ext string `gorm:"size:16"` // 文件后缀(.md/.pdf/.docx…;笔记/文本为空)
MD5 string `gorm:"size:32;index"` // 正文内容指纹(去重 / 校验)
Size int // 原文字数(rune
Preview string `gorm:"size:600"` // 前若干字预览(列表/反链用,不拉全文)
Content string `gorm:"type:text"` // 小文档内联正文;大文档转 MinIO 后置空
ObjectKey string `gorm:"size:160"` // 存放链接:大文档在 MinIO 的对象键(空=内联 Content
}
func (Doc) TableName() string { return "sundynix_doc" }
// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),以 Doc.ID 关联,供反链/笔记关系图按 SQL 查询,
// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链;目标尚未入库时 ToID 为空(悬空,记 ToName 待回填)。
type DocLink struct {
BaseModel
Owner string `gorm:"size:64;index:idx_link_of"`
KB string `gorm:"size:64;index:idx_link_of"`
FromID string `gorm:"size:24;index:idx_link_of"` // 源文档 Doc.ID
ToID string `gorm:"size:24;index"` // 目标文档 Doc.ID(空=悬空:目标尚未入库)
ToName string `gorm:"size:160"` // [[原始名]],供悬空链接展示 / 目标入库后回填 ToID
}
func (DocLink) TableName() string { return "sundynix_doc_link" }
// SaveDoc 写入/更新一份文件(owner+kb+name 唯一,重名覆盖),返回 (文件雪花 ID, 被覆盖的旧对象键)。
// oldObjectKey 是重名覆盖前该文档的 ObjectKey(新建则为空)—— 供调用方清理 MinIO 孤儿对象。
// content 为内联正文(大文档转 MinIO 时传空 + objectKey);ext/md5/preview/size 由调用方按全文给出。
func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, ext, md5, content, objectKey string, size int, preview string) (id, oldObjectKey string, err error) {
if p.db == nil {
return "", "", nil
}
var d Doc
qerr := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&d).Error
if errors.Is(qerr, gorm.ErrRecordNotFound) {
d = Doc{Owner: owner, KB: kb, Name: name, Ext: ext, MD5: md5, Content: content, ObjectKey: objectKey, Size: size, Preview: preview}
if err := p.db.WithContext(ctx).Create(&d).Error; err != nil {
return "", "", err
}
return d.ID, "", nil
}
if qerr != nil {
return "", "", qerr
}
oldObjectKey = d.ObjectKey // 覆盖前的旧对象键
d.Ext, d.MD5, d.Content, d.ObjectKey, d.Size, d.Preview = ext, md5, content, objectKey, size, preview
if err := p.db.WithContext(ctx).Save(&d).Error; err != nil {
return "", "", err
}
return d.ID, oldObjectKey, nil
}
// ListVault 返回文库列表(仅元数据 + 预览,不含全文),避免一次拉回整库正文。
func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, error) {
if p.db == nil {
return nil, nil
}
var rows []Doc
err := p.db.WithContext(ctx).
Select("id", "name", "ext", "size", "preview", "object_key", "updated_at").
Where("owner = ? AND kb = ?", owner, kb).Order("updated_at desc").Find(&rows).Error
return rows, err
}
// GetDocByID 按文件 ID 取单篇文档(含全文 Content 与 ObjectKey),owner 作用域防越权。
func (p *Postgres) GetDocByID(ctx context.Context, owner, id string) (*Doc, error) {
if p.db == nil {
return nil, nil
}
var d Doc
if err := p.db.WithContext(ctx).Where("owner = ? AND id = ?", owner, id).First(&d).Error; err != nil {
return nil, err
}
return &d, nil
}
// ReplaceDocLinks 以源文件 ID 重建其出链(先删旧,再按 [[名称]] 解析目标 ID 后插新)—— 入库/编辑时调用。
// 目标文档尚未入库时 ToID 留空(悬空),待其入库时由 ResolveInboundLinks 回填。
func (p *Postgres) ReplaceDocLinks(ctx context.Context, owner, kb, fromID string, toNames []string) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("owner = ? AND kb = ? AND from_id = ?", owner, kb, fromID).Delete(&DocLink{}).Error; err != nil {
return err
}
for _, name := range toNames {
if name == "" {
continue
}
var t Doc
toID := ""
if e := tx.Select("id").Where("owner = ? AND kb = ? AND name = ?", owner, kb, name).First(&t).Error; e == nil {
toID = t.ID
}
if toID == fromID { // 自链跳过
continue
}
if err := tx.Create(&DocLink{Owner: owner, KB: kb, FromID: fromID, ToID: toID, ToName: name}).Error; err != nil {
return err
}
}
return nil
})
}
// ResolveInboundLinks 把指向 name 的悬空链接(ToID 空)回填为 id —— 目标文档入库后调用,使反链/关系图即时成形。
func (p *Postgres) ResolveInboundLinks(ctx context.Context, owner, kb, name, id string) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Model(&DocLink{}).
Where("owner = ? AND kb = ? AND to_name = ? AND (to_id = '' OR to_id IS NULL)", owner, kb, name).
Update("to_id", id).Error
}
// ListLinks 返回某 kb 已解析(两端均为本库文件)的 [[双链]](FromID→ToID),供反链/笔记关系图按 ID 渲染。
func (p *Postgres) ListLinks(ctx context.Context, owner, kb string) ([]DocLink, error) {
if p.db == nil {
return nil, nil
}
var rows []DocLink
err := p.db.WithContext(ctx).Where("owner = ? AND kb = ? AND to_id <> ''", owner, kb).Find(&rows).Error
return rows, err
}
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct {
BaseModel
Kind string `gorm:"size:16;index"` // chat / embedding
Provider string `gorm:"size:32"` // openai-compatible / vllm
BaseURL string `gorm:"size:255"` // 如 https://api.deepseek.com
APIKey string `gorm:"size:255"`
Model string `gorm:"size:64"` // 如 deepseek-chat / text-embedding-v3
Active bool
}
func (LLMModel) TableName() string { return "sundynix_model" }
// ListModels 列出某 kind 的模型配置(kind 空则全部)。
func (p *Postgres) ListModels(ctx context.Context, kind string) ([]LLMModel, error) {
if p.db == nil {
return nil, nil
}
var rows []LLMModel
q := p.db.WithContext(ctx).Order("id")
if kind != "" {
q = q.Where("kind = ?", kind)
}
err := q.Find(&rows).Error
return rows, err
}
// SaveModel 新增或更新一条模型配置(ID 空则新增,BeforeCreate 生成雪花 ID)。
func (p *Postgres) SaveModel(ctx context.Context, m *LLMModel) error {
if p.db == nil {
return errStoreDisabled
}
if m.ID == "" {
return p.db.WithContext(ctx).Create(m).Error
}
return p.db.WithContext(ctx).Save(m).Error
}
// SetActiveModel 把指定模型设为激活(同 kind 内其余取消),事务保证每 kind 唯一激活。
func (p *Postgres) SetActiveModel(ctx context.Context, id string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var m LLMModel
if err := tx.First(&m, "id = ?", id).Error; err != nil {
return err
}
if err := tx.Model(&LLMModel{}).Where("kind = ? AND active = ?", m.Kind, true).Update("active", false).Error; err != nil {
return err
}
return tx.Model(&LLMModel{}).Where("id = ?", id).Update("active", true).Error
})
}
// GetActiveModel 返回某 kind 当前激活模型(无则 nil)。
func (p *Postgres) GetActiveModel(ctx context.Context, kind string) (*LLMModel, error) {
if p.db == nil {
return nil, nil
}
var m LLMModel
err := p.db.WithContext(ctx).Where("kind = ? AND active = ?", kind, true).First(&m).Error
if err != nil {
return nil, nil // 未配置激活模型
}
return &m, nil
}
// DeleteModel 删除一条模型配置。
func (p *Postgres) DeleteModel(ctx context.Context, id string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Delete(&LLMModel{}, "id = ?", id).Error
}