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:
+3
-1
@@ -187,7 +187,9 @@ RBAC 未做,暂以单管理员账号代理;概览口径必须是**系统级*
|
|||||||
消费 usage_event 时**一个事务内**落明细→扣积分(账本+余额)→累加 rollup,幂等锚在 usage_event.task_id
|
消费 usage_event 时**一个事务内**落明细→扣积分(账本+余额)→累加 rollup,幂等锚在 usage_event.task_id
|
||||||
唯一(RowsAffected==0→重投跳过,绝不重复计费);`GrantCredits` 充值 API(增量3 admin 接)。
|
唯一(RowsAffected==0→重投跳过,绝不重复计费);`GrantCredits` 充值 API(增量3 admin 接)。
|
||||||
live 验证:ledger 负分录 / 余额递减 / rollup 两任务累加 / **balance==seed+SUM(ledger) 不变量成立**。
|
live 验证:ledger 负分录 / 余额递减 / rollup 两任务累加 / **balance==seed+SUM(ledger) 不变量成立**。
|
||||||
- [ ] 增量3:`GET /admin/usage`(用量+积分+成本+余额趋势) | S
|
- [x] **增量3:`GET /admin/usage`**✅ —— 系统级(WithoutTenant):无 tenant→全平台按天 SUM + 各租户
|
||||||
|
用量排行(含余额);有 tenant→该租户按天趋势 + 当前余额。from/to(YYYYMMDD)缺省近 30 天。
|
||||||
|
live 验证两口径数值自洽(balance=seed−credits、credits=tok×汇率)。**P2 后端(计量事实源)齐。**
|
||||||
- [ ] (P4 硬拦截 `CREDIT_ENFORCE` / 充值发放 / 前端用量页 —— 需求拉动)
|
- [ ] (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
|
||||||
|
|||||||
@@ -365,3 +365,39 @@ func mask(s string) string {
|
|||||||
}
|
}
|
||||||
return "••••" + s[len(s)-4:]
|
return "••••" + s[len(s)-4:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminUsage: GET /api/v1/admin/usage?tenant=&from=&to= —— 系统级用量/积分/成本口径。
|
||||||
|
// 无 tenant → 全平台按天 SUM + 各租户排行;有 tenant → 该租户按天趋势 + 当前余额。
|
||||||
|
// from/to 为 YYYYMMDD,缺省近 30 天。走 WithoutTenant(跨租户,与 admin/overview 同口径)。
|
||||||
|
func (h *Handler) AdminUsage(c *gin.Context) {
|
||||||
|
ctx := store.WithoutTenant(c.Request.Context())
|
||||||
|
now := time.Now()
|
||||||
|
from := c.Query("from")
|
||||||
|
if from == "" {
|
||||||
|
from = now.AddDate(0, 0, -29).Format("20060102")
|
||||||
|
}
|
||||||
|
to := c.Query("to")
|
||||||
|
if to == "" {
|
||||||
|
to = now.Format("20060102")
|
||||||
|
}
|
||||||
|
tenant := c.Query("tenant")
|
||||||
|
|
||||||
|
trend := h.db.UsageTrend(ctx, tenant, from, to)
|
||||||
|
var totTok, totCredits, totCost, totTasks int64
|
||||||
|
for _, d := range trend {
|
||||||
|
totTok += d.TotalTok
|
||||||
|
totCredits += d.CreditsMicro
|
||||||
|
totCost += d.CostMicros
|
||||||
|
totTasks += d.TaskCount
|
||||||
|
}
|
||||||
|
resp := gin.H{
|
||||||
|
"from": from, "to": to, "tenant": tenant, "trend": trend,
|
||||||
|
"totals": gin.H{"total_tok": totTok, "credits_micro": totCredits, "cost_micros": totCost, "task_count": totTasks},
|
||||||
|
}
|
||||||
|
if tenant != "" {
|
||||||
|
resp["balance_micro"] = h.db.TenantBalance(ctx, tenant)
|
||||||
|
} else {
|
||||||
|
resp["tenants"] = h.db.UsageByTenant(ctx, from, to, 20) // 全平台:各租户用量排行(含余额)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||||
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康
|
||||||
|
admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||||
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
admin.GET("/audit", h.AuditList) // 敏感操作审计流(倒序,翻页)
|
||||||
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
admin.GET("/guardrail-events", h.GuardrailEvents) // 护栏命中安全事件流(倒序,翻页)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,74 @@ func upsertRollup(tx *gorm.DB, tenantID, day string, tok, creditsMicro, costMicr
|
|||||||
}).Error
|
}).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 分录 + 增物化余额)。供充值 / 套餐发放 / 人工校正复用。
|
// GrantCredits 给租户充值 / 发放积分(记 grant 分录 + 增物化余额)。供充值 / 套餐发放 / 人工校正复用。
|
||||||
func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, creditsMicro int64, ref, memo string) error {
|
func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, creditsMicro int64, ref, memo string) error {
|
||||||
if p.db == nil || tenantID == "" || creditsMicro == 0 {
|
if p.db == nil || tenantID == "" || creditsMicro == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user