Files
sundynix-agentix/sundynix-gateway/internal/store/credit.go
T
Blizzard bf75bb8a62 feat(gateway): SaaS P2 计量·增量3 —— GET /admin/usage 用量/积分/成本口径
系统级(store.WithoutTenant,跨租户)用量观测:
- 无 tenant:全平台按天 SUM 趋势 + 各租户用量排行(含当前余额)。
- 有 tenant:该租户按天趋势 + 当前积分余额。
- from/to(YYYYMMDD)缺省近 30 天。UsageTrend/UsageByTenant/TenantBalance store 方法。

live 验证:两口径数值自洽(balance=seed−credits、credits=tok×汇率、totals=trend之和)。
至此 P2 计量后端(事实源)齐:明细/账本/余额/rollup/查询。前端页 + P4 硬拦截/充值待需求拉动。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:42:21 +08:00

168 lines
6.5 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"
"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_idgrant→订单号)
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。
// 需系统级 ctxstore.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
})
}