feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
+14
-1
@@ -175,7 +175,20 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
|
|||||||
误过滤的回归;RecentTasks/RecentRuns 按 owner+tenant 过滤"我的运行"(raw Table 查询手动补);
|
误过滤的回归;RecentTasks/RecentRuns 按 owner+tenant 过滤"我的运行"(raw Table 查询手动补);
|
||||||
BackfillRowTenants 扩到 doc/doc_link/task/eval。live 验证:创建自动填/异步补/跨用户读被过滤/admin
|
BackfillRowTenants 扩到 doc/doc_link/task/eval。live 验证:创建自动填/异步补/跨用户读被过滤/admin
|
||||||
全局口径全过。**PG 核心表(KB/Agent/Task/Eval/Doc/DocLink)隔离闭合。**
|
全局口径全过。**PG 核心表(KB/Agent/Task/Eval/Doc/DocLink)隔离闭合。**
|
||||||
- [ ] Redis/MinIO/Milvus/Neo4j 的 tenant 前缀/namespace(各存储隔离,PG 已闭合)| M
|
- [~] Redis/MinIO/Milvus/Neo4j 的 tenant 前缀 —— **实为已隔离,暂缓**:`scopedKB=userID+"/"+name`
|
||||||
|
贯穿全存储(Milvus expr / Neo4j `{kb}` / MinIO key `owner/kb/doc` / Redis 按 user 键),userID 服务端注入
|
||||||
|
不可伪造 → 已按 owner 隔离;owner→tenant 1:1 故亦按租户隔离。仅当**多成员租户共享同一 KB**(工作区)
|
||||||
|
时才需把命名空间从 `owner/name` 改 `tenant/name`——按需求拉动,别过早抽象。
|
||||||
|
- **[~] P2 用量计量(SAAS_P2_DESIGN.md)** —— 积分为单位(token 直计=汇率1),扣量走扣积分。
|
||||||
|
- [x] **增量1:链路 + 明细 + 折算**✅(d032c19)—— Meta/契约加 tenant→dispatcher 带;`usage_event`
|
||||||
|
追加式明细(task_id 唯一幂等);credits(token基准×credit_weight) + cost(Pricing)折算落库。live 验证折算数学+租户传播。
|
||||||
|
- [x] **增量2:账本 + 余额 + rollup**✅ —— `credit_ledger`(append-only,grant/usage/adjust) +
|
||||||
|
`tenant.credit_balance_micro` 物化余额(软扣可负) + `usage_rollup`(租户/天 upsert 累加);
|
||||||
|
消费 usage_event 时**一个事务内**落明细→扣积分(账本+余额)→累加 rollup,幂等锚在 usage_event.task_id
|
||||||
|
唯一(RowsAffected==0→重投跳过,绝不重复计费);`GrantCredits` 充值 API(增量3 admin 接)。
|
||||||
|
live 验证:ledger 负分录 / 余额递减 / rollup 两任务累加 / **balance==seed+SUM(ledger) 不变量成立**。
|
||||||
|
- [ ] 增量3:`GET /admin/usage`(用量+积分+成本+余额趋势) | S
|
||||||
|
- [ ] (P4 硬拦截 `CREDIT_ENFORCE` / 充值发放 / 前端用量页 —— 需求拉动)
|
||||||
- [ ] 真正的 RBAC:User 加 role 字段 + 角色/权限表,`RequireAdmin`(middleware/auth.go:46) 从白名单升级为角色校验 | M
|
- [ ] 真正的 RBAC:User 加 role 字段 + 角色/权限表,`RequireAdmin`(middleware/auth.go:46) 从白名单升级为角色校验 | M
|
||||||
- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M
|
- [ ] 用户管理接口:列举 / 禁用 / 改角色(现仅注册/登录/查我,handler/auth.go)| M
|
||||||
- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L
|
- [ ] 多租户:Task/Eval/KB/Agent 加 tenant_id,owner_id 之上补租户隔离边界 + 租户表/关联表(store 全表,单租户假设)| L
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 积分账本分录类型。
|
||||||
|
const (
|
||||||
|
LedgerGrant = "grant" // 充值 / 发放(+)
|
||||||
|
LedgerUsage = "usage" // 用量消耗(−)
|
||||||
|
LedgerAdjust = "adjust" // 人工校正(±)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreditLedger 是积分账本一条分录(append-only)——扣量 / 充值的事实源。
|
||||||
|
// 租户当前余额 = SUM(credits_micro);为快取物化在 sundynix_tenant.credit_balance_micro。
|
||||||
|
// 表名 sundynix_credit_ledger。
|
||||||
|
type CreditLedger struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;index"`
|
||||||
|
Kind string `gorm:"size:16"` // grant / usage / adjust
|
||||||
|
CreditsMicro int64 `gorm:"column:credits_micro"` // 带符号增量(usage 为负)
|
||||||
|
Ref string `gorm:"size:64;index"` // 关联(usage→task_id,grant→订单号)
|
||||||
|
Memo string `gorm:"size:255"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CreditLedger) TableName() string { return "sundynix_credit_ledger" }
|
||||||
|
func (CreditLedger) isTenantScoped() {}
|
||||||
|
|
||||||
|
// UsageRollup 是用量按 租户/天 的聚合快照(配额 / 账单读一行,不扫明细)。
|
||||||
|
// 消费 UsageEvent 时 upsert 累加。表名 sundynix_usage_rollup。
|
||||||
|
type UsageRollup struct {
|
||||||
|
BaseModel
|
||||||
|
TenantID string `gorm:"size:64;uniqueIndex:idx_rollup_td"`
|
||||||
|
Day string `gorm:"size:8;uniqueIndex:idx_rollup_td"` // 20060102
|
||||||
|
TotalTok int64
|
||||||
|
CreditsMicro int64 `gorm:"column:credits_micro"`
|
||||||
|
CostMicros int64 `gorm:"column:cost_micros"`
|
||||||
|
TaskCount int64
|
||||||
|
Currency string `gorm:"size:8"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsageRollup) TableName() string { return "sundynix_usage_rollup" }
|
||||||
|
func (UsageRollup) isTenantScoped() {}
|
||||||
|
|
||||||
|
// applyUsageCredit 在事务内记 usage 分录(负)+ 扣物化余额(软扣,可为负)。
|
||||||
|
// 幂等由上层的 usage_event(task_id 唯一) 插入门控保证——仅在新明细插入后调用一次。
|
||||||
|
func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64) error {
|
||||||
|
if tenantID == "" || creditsMicro <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Create(&CreditLedger{
|
||||||
|
TenantID: tenantID, Kind: LedgerUsage, CreditsMicro: -creditsMicro, Ref: taskID, Memo: "task usage",
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||||||
|
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", creditsMicro)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertRollup 在事务内累加 租户/天 聚合。
|
||||||
|
func upsertRollup(tx *gorm.DB, tenantID, day string, tok, creditsMicro, costMicros int64, currency string) error {
|
||||||
|
if tenantID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"total_tok": gorm.Expr("sundynix_usage_rollup.total_tok + ?", tok),
|
||||||
|
"credits_micro": gorm.Expr("sundynix_usage_rollup.credits_micro + ?", creditsMicro),
|
||||||
|
"cost_micros": gorm.Expr("sundynix_usage_rollup.cost_micros + ?", costMicros),
|
||||||
|
"task_count": gorm.Expr("sundynix_usage_rollup.task_count + 1"),
|
||||||
|
}),
|
||||||
|
}).Create(&UsageRollup{
|
||||||
|
TenantID: tenantID, Day: day, TotalTok: tok, CreditsMicro: creditsMicro,
|
||||||
|
CostMicros: costMicros, TaskCount: 1, Currency: currency,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantCredits 给租户充值 / 发放积分(记 grant 分录 + 增物化余额)。供充值 / 套餐发放 / 人工校正复用。
|
||||||
|
func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, creditsMicro int64, ref, memo string) error {
|
||||||
|
if p.db == nil || tenantID == "" || creditsMicro == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = LedgerGrant
|
||||||
|
}
|
||||||
|
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&CreditLedger{
|
||||||
|
TenantID: tenantID, Kind: kind, CreditsMicro: creditsMicro, Ref: ref, Memo: memo,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||||||
|
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", creditsMicro)).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,10 +83,11 @@ func (GuardrailEvent) TableName() string { return "sundynix_guardrail_event" }
|
|||||||
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
|
// Tenant 是多租户的计费/隔离单位(组织/账户)。个人用户 = 一个单人默认租户;团队/企业 = 多成员。
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
Name string `gorm:"size:128"`
|
Name string `gorm:"size:128"`
|
||||||
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
||||||
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
||||||
Status string `gorm:"size:16;default:active"` // active / suspended
|
Status string `gorm:"size:16;default:active"` // active / suspended
|
||||||
|
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Tenant) TableName() string { return "sundynix_tenant" }
|
func (Tenant) TableName() string { return "sundynix_tenant" }
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
migrateLegacyIntIDs(db)
|
migrateLegacyIntIDs(db)
|
||||||
migrateDocLinkToID(db)
|
migrateDocLinkToID(db)
|
||||||
|
|
||||||
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Task{}, &Eval{}, &LLMModel{}, &KB{}, &Doc{}, &Agent{}, &DocLink{}, &Pricing{}, &Prompt{}, &AuditLog{}, &GuardrailEvent{}, &Tenant{}, &TenantMember{}, &UsageEvent{}, &CreditLedger{}, &UsageRollup{}); err != nil {
|
||||||
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
|
||||||
return &Postgres{}
|
return &Postgres{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"github.com/sundynix/sundynix-shared/contract"
|
"github.com/sundynix/sundynix-shared/contract"
|
||||||
@@ -77,11 +79,26 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent)
|
|||||||
CreditsMicro: creditsMicro, CostMicros: costMicros, Currency: currency,
|
CreditsMicro: creditsMicro, CostMicros: costMicros, Currency: currency,
|
||||||
Exceeded: ev.Exceeded, TS: ev.TS,
|
Exceeded: ev.Exceeded, TS: ev.TS,
|
||||||
}
|
}
|
||||||
// 幂等:同一 task_id 已有明细则不重复插入(防 NATS 重投重复计费)。
|
day := time.UnixMilli(ev.TS).Format("20060102")
|
||||||
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "task_id"}},
|
// 一个事务内:落明细 → 扣积分(账本+余额) → 累加 rollup。
|
||||||
DoNothing: true,
|
// 幂等锚点:usage_event 的 task_id 唯一;插入若被冲突吞掉(RowsAffected==0)→重投,跳过后续,绝不重复计费。
|
||||||
}).Create(row).Error
|
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "task_id"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(row)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return nil // 重投:明细已存在,账本/余额/rollup 均不再动
|
||||||
|
}
|
||||||
|
if err := applyUsageCredit(tx, ev.TenantID, ev.TaskID, creditsMicro); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return upsertRollup(tx, ev.TenantID, day, int64(ev.TotalTok), creditsMicro, costMicros, currency)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pricingForModelName 按模型名查计价(join model 表,pricing 以 model_id 关联)。查不到返回 nil。
|
// pricingForModelName 按模型名查计价(join model 表,pricing 以 model_id 关联)。查不到返回 nil。
|
||||||
|
|||||||
Reference in New Issue
Block a user