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 } // UsageDay 是按天聚合的用量(趋势用)。 type UsageDay struct { Day string `json:"day"` TotalTok int64 `json:"total_tok"` CreditsMicro int64 `json:"credits_micro"` CostMicros int64 `json:"cost_micros"` TaskCount int64 `json:"task_count"` } // UsageTenantSum 是某租户在区间内的用量汇总(+当前余额),供全平台排行。 type UsageTenantSum struct { TenantID string `json:"tenant_id"` Name string `json:"name"` TotalTok int64 `json:"total_tok"` CreditsMicro int64 `json:"credits_micro"` CostMicros int64 `json:"cost_micros"` TaskCount int64 `json:"task_count"` BalanceMicro int64 `json:"balance_micro"` } // UsageTrend 返回按天聚合的用量(tenantID 空=全平台跨租户 SUM)。区间 [from,to] 为 YYYYMMDD。 // 需系统级 ctx(store.WithoutTenant)才跨租户;否则受插件按调用者租户过滤。 func (p *Postgres) UsageTrend(ctx context.Context, tenantID, from, to string) []UsageDay { if p.db == nil { return nil } q := p.db.WithContext(ctx).Model(&UsageRollup{}). Select("day, sum(total_tok) as total_tok, sum(credits_micro) as credits_micro, sum(cost_micros) as cost_micros, sum(task_count) as task_count"). Where("day >= ? AND day <= ?", from, to).Group("day").Order("day") if tenantID != "" { q = q.Where("tenant_id = ?", tenantID) } var out []UsageDay q.Scan(&out) return out } // UsageByTenant 返回各租户在区间内的用量汇总(含当前余额),按积分消耗降序,供全平台排行。 func (p *Postgres) UsageByTenant(ctx context.Context, from, to string, limit int) []UsageTenantSum { if p.db == nil { return nil } if limit <= 0 { limit = 20 } var out []UsageTenantSum p.db.WithContext(ctx).Table("sundynix_usage_rollup r"). Select("r.tenant_id, t.name, sum(r.total_tok) as total_tok, sum(r.credits_micro) as credits_micro, "+ "sum(r.cost_micros) as cost_micros, sum(r.task_count) as task_count, "+ "coalesce(max(t.credit_balance_micro),0) as balance_micro"). Joins("left join sundynix_tenant t on t.id = r.tenant_id"). Where("r.day >= ? AND r.day <= ?", from, to). Group("r.tenant_id, t.name").Order("credits_micro desc").Limit(limit).Scan(&out) return out } // TenantBalance 返回某租户当前物化积分余额(微积分)。 func (p *Postgres) TenantBalance(ctx context.Context, tenantID string) int64 { if p.db == nil || tenantID == "" { return 0 } var t Tenant if err := p.db.WithContext(ctx).Select("credit_balance_micro").First(&t, "id = ?", tenantID).Error; err != nil { return 0 } return t.CreditBalanceMicro } // 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 }) }