From f6ea03b34b208e9b933e3645b9245238e3043886 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 8 Jul 2026 09:31:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20GET=20/me/usage=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E7=94=A8=E6=88=B7=E8=87=AA=E5=B7=B1=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E7=9A=84=E7=94=A8=E9=87=8F=E6=98=8E=E7=BB=86=EF=BC=88?= =?UTF-8?q?=E4=BD=99=E9=A2=9D+=E8=B6=8B=E5=8A=BF+=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=B6=88=E8=80=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面向用户口径(非 admin,受插件按请求 ctx 租户自动隔离):返回积分余额 + credit_enforce + 按天消耗趋势 + 区间合计 + 最近 10 条消耗明细。复用 UsageTrend/TenantBalance,新增 RecentUsage(按 tenant 取近 N 条 usage_event)。 Co-Authored-By: Claude Opus 4.8 --- .../internal/handler/task_handler.go | 44 +++++++++++++++++++ sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/usage.go | 14 ++++++ 3 files changed, 59 insertions(+) diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 9a99457..70a8080 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -550,6 +550,50 @@ func (h *Handler) TenantCurrent(c *gin.Context) { }) } +// MyUsage: GET /api/v1/me/usage?days= —— 当前用户自己租户的用量明细(余额 + 按天趋势 + 最近消耗)。 +// 面向用户口径(非 admin):只看自己租户,受插件按请求 ctx 租户自动隔离。 +func (h *Handler) MyUsage(c *gin.Context) { + tid := tenantID(c) + if tid == "" { + c.JSON(http.StatusOK, gin.H{"tenant": nil}) + return + } + ctx := c.Request.Context() + days := 30 + if v := c.Query("days"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 90 { + days = n + } + } + now := time.Now() + from := now.AddDate(0, 0, -(days - 1)).Format("20060102") + to := now.Format("20060102") + + trend := h.db.UsageTrend(ctx, tid, from, to) + var totTok, totCredits, totCost, totTasks int64 + for _, d := range trend { + totTok += d.TotalTok + totCredits += d.CreditsMicro + totCost += d.CostMicros + totTasks += d.TaskCount + } + recent := make([]gin.H, 0, 10) + for _, u := range h.db.RecentUsage(ctx, tid, 10) { + recent = append(recent, gin.H{ + "task_id": u.TaskID, "model": u.Model, "total_tok": u.TotalTok, + "credits_micro": u.CreditsMicro, "cost_micros": u.CostMicros, "currency": u.Currency, "ts": u.TS, + }) + } + c.JSON(http.StatusOK, gin.H{ + "from": from, "to": to, + "balance_micro": h.db.TenantBalance(ctx, tid), + "credit_enforce": h.db.CreditEnforceEnabled(ctx), + "trend": trend, + "totals": gin.H{"total_tok": totTok, "credits_micro": totCredits, "cost_micros": totCost, "task_count": totTasks}, + "recent": recent, + }) +} + // sessionID 从请求取会话标识(真实场景应由会话中间件注入)。 func sessionID(c *gin.Context) string { if s := c.GetHeader("X-Session-ID"); s != "" { diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 468e682..a0afa10 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -57,6 +57,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected) p.POST("/tasks/:id/approve", middleware.Audit(db), h.ApproveTask) // HITL 人工审批决定(批准/拒绝,审计) p.GET("/tenants/current", h.TenantCurrent) // 当前租户上下文 + 角色(多租户) + p.GET("/me/usage", h.MyUsage) // 我的用量明细(余额 + 趋势 + 最近消耗) p.GET("/tasks/:id/eval", h.TaskEval) // 自动化评测结果(综合/质量/忠实度/分级) p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert) p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板) diff --git a/sundynix-gateway/internal/store/usage.go b/sundynix-gateway/internal/store/usage.go index eadf7ff..aaf1cb8 100644 --- a/sundynix-gateway/internal/store/usage.go +++ b/sundynix-gateway/internal/store/usage.go @@ -114,6 +114,20 @@ func (p *Postgres) SaveUsageEvent(ctx context.Context, ev *contract.UsageEvent) }) } +// RecentUsage 返回某租户最近 n 条用量明细(供用户看"最近消耗")。受租户表→用户 ctx 自动过滤, +// 这里也显式带 tenant_id 双保险。 +func (p *Postgres) RecentUsage(ctx context.Context, tenantID string, n int) []UsageEvent { + if p.db == nil || tenantID == "" { + return nil + } + if n <= 0 { + n = 10 + } + var out []UsageEvent + p.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Order("ts desc").Limit(n).Find(&out) + return out +} + // pricingForModelName 按模型名查计价(join model 表,pricing 以 model_id 关联)。查不到返回 nil。 func (p *Postgres) pricingForModelName(ctx context.Context, name string) *Pricing { if p.db == nil || name == "" {