Files
sundynix-agentix/sundynix-gateway/internal/store/model.go
T
Blizzard d032c198c8 feat: SaaS P2 计量·增量1 —— 用量按租户落持久明细 + 积分/成本折算
链路打通 tenant → 计费事实源:
- 契约:Task.Meta 加 MetaTenantID;UsageEvent 加 TenantID。
- 提交:网关 task.Meta[MetaTenantID]=tenantID(c);dispatcher emitUsage 带租户。
- 明细表 sundynix_usage_event(追加式,task_id 唯一→幂等防重投重复计费):
  tenant/owner/model/tokens + credits_micro + cost_micros。
- 折算:credits=total_tok/TOKENS_PER_CREDIT×credit_weight(token 基准,设 1 即 token 直计);
  cost 按 Pricing 折算;Pricing 加 credit_weight 列(每模型积分权重,缺省 1)。
  模型名空则回退激活 chat 模型(近似,忽略 failover 备用模型,已在设计标注)。
- 网关 SubscribeUsage 折算落明细(保留 Redis 日计数作快速配额校验)。

live 验证:提交任务→一行 usage_event,tenant 匹配用户租户、
credits=89tok/1000×2×1e6=178000 微积分、cost=45/1000×1+44/1000×2=133000 微元 CNY,
折算数学与幂等键均正确。

设计见 SAAS_P2_DESIGN.md。增量2(credit_ledger 余额软扣 + rollup)待做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:54:29 +08:00

383 lines
16 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"
"github.com/sundynix/sundynix-shared/contract"
)
// KB 是一个知识库(按 owner 隔离 + 按 kind 组织:文件夹/项目/案件/通用)。
// 表名 sundynix_kb。(owner,name) 唯一 —— 同一用户下知识库名不重复。
// 向量/全文/图谱实际以 "owner/name" 作分区键,保证只有 owner 能查到自己的库。
type KB struct {
BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(gorm tenant 插件按上下文自动填/过滤)
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" }
func (KB) isTenantScoped() {}
// 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
TenantID string `gorm:"size:64;index"` // 多租户作用域(tenant 插件自动填/过滤)
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" }
func (Agent) isTenantScoped() {}
// 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
TenantID string `gorm:"size:64;index"` // 多租户作用域(入库异步无请求 ctx,SaveDoc 按 owner 补)
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…;笔记/文本为空)
Size int // 原文字数(rune
Preview string `gorm:"size:600"` // 前若干字预览(列表/反链用,不拉全文)
Content string `gorm:"type:text"` // 正文一律落 MinIO 后置空;仅 MinIO 不可用时回退内联存这里
ObjectKey string `gorm:"size:160"` // 正文在 MinIO 的对象键(常态非空;空=回退内联 Content)
}
func (Doc) TableName() string { return "sundynix_doc" }
func (Doc) isTenantScoped() {}
// DocLink 是文档间 [[双链]] 的索引(owner+kb 内 from→to),以 Doc.ID 关联,供反链/笔记关系图按 SQL 查询,
// 避免在前端扫全部正文。入库/编辑时按 from 文档重建其出链;目标尚未入库时 ToID 为空(悬空,记 ToName 待回填)。
type DocLink struct {
BaseModel
TenantID string `gorm:"size:64;index"` // 多租户作用域(ReplaceDocLinks 按 owner 补)
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" }
func (DocLink) isTenantScoped() {}
// 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, content, objectKey string, size int, preview string) (id, oldObjectKey string, err error) {
if p.db == nil {
return "", "", nil
}
// 入库为异步 worker(无请求 ctx 租户)→ 显式按 owner 补 tenant_id(插件在此不会自动填)。
tenantID := p.tenantIDForOwner(ctx, owner)
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{TenantID: tenantID, Owner: owner, KB: kb, Name: name, Ext: ext, 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 // 覆盖前的旧对象键
if tenantID != "" {
d.TenantID = tenantID // 覆盖路径也补齐(老行可能为空);查不到租户则不动,别把已有值抹空
}
d.Ext, d.Content, d.ObjectKey, d.Size, d.Preview = ext, 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
}
// DeleteDocByID 删除一份文件的 PG 痕迹(owner 作用域防越权):doc 行 + 其出链/入链。
// 入链(别处指向本文件)一并删,避免悬空脏链。文档不存在视作已删(返回 nil)。
func (p *Postgres) DeleteDocByID(ctx context.Context, owner, kb, id 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 = ? OR to_id = ?)", owner, kb, id, id).Delete(&DocLink{}).Error; err != nil {
return err
}
return tx.Where("owner = ? AND id = ?", owner, id).Delete(&Doc{}).Error
})
}
// 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
}
tenantID := p.tenantIDForOwner(ctx, owner) // 异步入库路径无请求 ctx 租户,按 owner 补
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{TenantID: tenantID, 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
}
// Pricing 是某模型的计价配置(token↔真钱):按模型分输入/输出单价(每 1K token)。
// 表名 sundynix_pricing。ModelID 关联 sundynix_model.id,唯一。
type Pricing struct {
BaseModel
ModelID string `gorm:"size:24;uniqueIndex"` // 关联 sundynix_model.id
InputPer1K float64 `gorm:"column:input_per_1k"` // 每 1K 输入 token 单价
OutputPer1K float64 `gorm:"column:output_per_1k"` // 每 1K 输出 token 单价
Currency string `gorm:"size:8"` // 币种(CNY / USD…)
CreditWeight float64 `gorm:"column:credit_weight"` // 积分权重(每 token 烧积分的倍率;0/缺省按 1.0 计)
}
func (Pricing) TableName() string { return "sundynix_pricing" }
// ListPricing 列出全部计价配置。
func (p *Postgres) ListPricing(ctx context.Context) ([]Pricing, error) {
if p.db == nil {
return nil, nil
}
var rows []Pricing
err := p.db.WithContext(ctx).Find(&rows).Error
return rows, err
}
// UpsertPricing 写入/更新某模型的计价(model_id 唯一,重复即覆盖单价/币种)。
func (p *Postgres) UpsertPricing(ctx context.Context, modelID string, inPer1K, outPer1K float64, currency string) error {
if p.db == nil {
return errStoreDisabled
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "model_id"}},
DoUpdates: clause.Assignments(map[string]any{"input_per_1k": inPer1K, "output_per_1k": outPer1K, "currency": currency, "updated_at": time.Now()}),
}).Create(&Pricing{ModelID: modelID, InputPer1K: inPer1K, OutputPer1K: outPer1K, Currency: currency}).Error
}
// 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
}
// 更新仅覆盖可编辑列:Active 由 SetActiveModel 单独管理,CreatedAt 不动——
// 避免整行 Save 把 active 清零(改 key/base_url 时不应顺手取消激活)。
return p.db.WithContext(ctx).Model(&LLMModel{}).Where("id = ?", m.ID).
Select("Kind", "Provider", "BaseURL", "APIKey", "Model").Updates(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
}
// ActiveConfig 取某 kind 的激活模型配置;chat 还把"其它已登记 chat 模型"按序填进 Fallbacks
// 供 dispatcher 串成 failover 链(主 provider 抖动/挂掉时自动切备,平台不整体宕)。
func (p *Postgres) ActiveConfig(ctx context.Context, kind string) *contract.ModelConfig {
row, _ := p.GetActiveModel(ctx, kind)
if row == nil {
return nil
}
cfg := &contract.ModelConfig{Provider: row.Provider, BaseURL: row.BaseURL, APIKey: row.APIKey, Model: row.Model}
if kind == contract.ConfigKindChat {
all, _ := p.ListModels(ctx, kind)
for _, m := range all {
if m.ID == row.ID {
continue // 跳过激活模型(它已是主)
}
cfg.Fallbacks = append(cfg.Fallbacks, contract.ModelConfig{
Provider: m.Provider, BaseURL: m.BaseURL, APIKey: m.APIKey, Model: m.Model,
})
}
}
return cfg
}
// 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
}