From 93741a550436d88c81e7c3ca6443b5f11fac6ffc Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 7 Jul 2026 13:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E9=97=AD=E5=90=88=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E7=8E=AF=20=E2=80=94=E2=80=94=20=E5=85=85=E5=80=BC/?= =?UTF-8?q?=E5=8F=91=E6=94=BE=20+=20=E6=8F=90=E4=BA=A4=E5=89=8D=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E7=A1=AC=E6=8B=A6=E6=88=AA(=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=85=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让预付费积分成为闭环(发放→消耗→见底拦住): - POST /admin/credits/grant:给租户充值/发放(正=grant,负=adjust 校正), 复用 store.GrantCredits(账本分录 + 物化余额,一事务)。 - 提交门控:credit_enforce 开启且租户余额≤0 → 拒绝新任务 402;默认关=软扣不拦。 开关入 billing-config(sundynix_setting KV,后台可切)。 - 修 bug:GrantCredits 是跨租户管理操作,须 store.WithoutTenant——否则 tenant 插件 会把账本分录的 tenant_id 覆盖成 admin 自己的租户(余额记目标、分录记 admin,破坏对账)。 live 验证:enforce 关→余额0可提交;开→余额0拒 402;充值后可提交、余额递减; 修复后 grant 分录落到目标租户、balance==SUM(ledger) 不变量成立。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/internal/handler/admin.go | 46 +++++++++++++++++-- .../internal/handler/task_handler.go | 7 +++ sundynix-gateway/internal/router/router.go | 3 +- sundynix-gateway/internal/store/credit.go | 3 ++ sundynix-gateway/internal/store/setting.go | 10 +++- 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/sundynix-gateway/internal/handler/admin.go b/sundynix-gateway/internal/handler/admin.go index 989ae94..ed86f6a 100644 --- a/sundynix-gateway/internal/handler/admin.go +++ b/sundynix-gateway/internal/handler/admin.go @@ -251,28 +251,64 @@ func (h *Handler) SavePricing(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -// BillingConfig: GET /api/v1/admin/billing-config —— 全局计费规则(当前仅 token→积分汇率)。 +// BillingConfig: GET /api/v1/admin/billing-config —— 全局计费规则(token→积分汇率 + 硬拦截开关)。 func (h *Handler) BillingConfig(c *gin.Context) { - tpc := h.db.GetSetting(c.Request.Context(), store.SettingTokensPerCredit) - c.JSON(http.StatusOK, gin.H{"tokens_per_credit": tpc}) // 空串=未设,前端回退默认 + ctx := c.Request.Context() + c.JSON(http.StatusOK, gin.H{ + "tokens_per_credit": h.db.GetSetting(ctx, store.SettingTokensPerCredit), // 空串=未设,前端回退默认 + "credit_enforce": h.db.CreditEnforceEnabled(ctx), // 余额≤0 是否拒绝新任务 + }) } -// SaveBillingConfig: PUT /api/v1/admin/billing-config —— 设 token→积分汇率(>0)。 +// SaveBillingConfig: PUT /api/v1/admin/billing-config —— 设 token→积分汇率(>0)+ 硬拦截开关。 func (h *Handler) SaveBillingConfig(c *gin.Context) { var b struct { TokensPerCredit float64 `json:"tokens_per_credit"` + CreditEnforce bool `json:"credit_enforce"` } if err := c.ShouldBindJSON(&b); err != nil || b.TokensPerCredit <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "tokens_per_credit 必须 > 0"}) return } - if err := h.db.SetSetting(c.Request.Context(), store.SettingTokensPerCredit, strconv.FormatFloat(b.TokensPerCredit, 'f', -1, 64)); err != nil { + ctx := c.Request.Context() + if err := h.db.SetSetting(ctx, store.SettingTokensPerCredit, strconv.FormatFloat(b.TokensPerCredit, 'f', -1, 64)); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + enforce := "off" + if b.CreditEnforce { + enforce = "on" + } + if err := h.db.SetSetting(ctx, store.SettingCreditEnforce, enforce); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"status": "ok"}) } +// GrantCredits: POST /api/v1/admin/credits/grant —— 给租户充值/发放积分(记账本 + 增余额)。 +func (h *Handler) GrantCredits(c *gin.Context) { + var b struct { + TenantID string `json:"tenant_id"` + Credits float64 `json:"credits"` // 单位:积分(正=充值/发放,负=扣减/校正) + Memo string `json:"memo"` + } + if err := c.ShouldBindJSON(&b); err != nil || b.TenantID == "" || b.Credits == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tenant_id 必填、credits 不能为 0"}) + return + } + ctx := c.Request.Context() + kind := store.LedgerGrant + if b.Credits < 0 { + kind = store.LedgerAdjust // 负数记为人工校正 + } + if err := h.db.GrantCredits(ctx, b.TenantID, kind, int64(b.Credits*1e6), "", b.Memo); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "balance_micro": h.db.TenantBalance(ctx, b.TenantID)}) +} + // SaveModel: POST /api/v1/admin/models —— 新增/更新一条模型配置。 func (h *Handler) SaveModel(c *gin.Context) { var b modelBody diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 669314e..2af12c7 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -56,6 +56,13 @@ func (h *Handler) SubmitTask(c *gin.Context) { return } } + // 积分硬拦截(默认关;开关 credit_enforce):租户积分余额 ≤0 则拒绝新任务,提示充值。 + if tid := tenantID(c); tid != "" && h.db.CreditEnforceEnabled(c.Request.Context()) { + if h.db.TenantBalance(c.Request.Context(), tid) <= 0 { + c.JSON(http.StatusPaymentRequired, gin.H{"error": "租户积分余额不足,请充值后再试", "balance_micro": 0}) + return + } + } // 附上用户标识(召回偏好记忆)与会话标识(召回短期多轮历史)。 // 真实场景由鉴权/会话中间件注入;此处用请求头,缺省匿名/默认会话。 task.Meta[contract.MetaUserID] = userID(c) diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 7b377e7..468e682 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -98,8 +98,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.POST("/models/test", h.TestModel) admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱 + 积分权重) admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价 + 积分权重 - admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率) + admin.GET("/billing-config", h.BillingConfig) // 全局计费规则(token→积分汇率 + 硬拦截开关) admin.PUT("/billing-config", h.SaveBillingConfig) + admin.POST("/credits/grant", h.GrantCredits) // 给租户充值/发放积分 admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册 admin.GET("/overview", h.AdminOverview) // 系统级聚合:全平台用户/任务/评测/模型态/提示词态/健康 admin.GET("/usage", h.AdminUsage) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额 diff --git a/sundynix-gateway/internal/store/credit.go b/sundynix-gateway/internal/store/credit.go index 57013ce..7432264 100644 --- a/sundynix-gateway/internal/store/credit.go +++ b/sundynix-gateway/internal/store/credit.go @@ -155,6 +155,9 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred if kind == "" { kind = LedgerGrant } + // 跨租户管理操作:admin 请求 ctx 带着 admin 自己的租户,须旁路,否则 tenant 插件会把 + // 账本分录的 tenant_id 覆盖成 admin 的租户(余额记到目标租户、分录却记到 admin,破坏对账)。 + ctx = WithoutTenant(ctx) 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, diff --git a/sundynix-gateway/internal/store/setting.go b/sundynix-gateway/internal/store/setting.go index 566947c..9ed1a24 100644 --- a/sundynix-gateway/internal/store/setting.go +++ b/sundynix-gateway/internal/store/setting.go @@ -17,7 +17,15 @@ type Setting struct { func (Setting) TableName() string { return "sundynix_setting" } // 平台设置键。 -const SettingTokensPerCredit = "tokens_per_credit" +const ( + SettingTokensPerCredit = "tokens_per_credit" + SettingCreditEnforce = "credit_enforce" // "on"=余额≤0 拒绝新任务;其它/未设=软扣不拦 +) + +// CreditEnforceEnabled 是否开启积分硬拦截(默认关:软扣,余额可为负)。 +func (p *Postgres) CreditEnforceEnabled(ctx context.Context) bool { + return p.GetSetting(ctx, SettingCreditEnforce) == "on" +} // GetSetting 读一个平台设置;不存在返回空串(调用方回退默认)。 func (p *Postgres) GetSetting(ctx context.Context, key string) string {