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>
This commit is contained in:
Blizzard
2026-07-07 11:42:21 +08:00
parent d058a63434
commit bf75bb8a62
4 changed files with 108 additions and 1 deletions
+68
View File
@@ -79,6 +79,74 @@ func upsertRollup(tx *gorm.DB, tenantID, day string, tok, creditsMicro, costMicr
}).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 {