feat(gateway): 闭合积分环 —— 充值/发放 + 提交前余额硬拦截(默认关)
让预付费积分成为闭环(发放→消耗→见底拦住): - 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) // 用量/积分/成本:全平台按天趋势 + 租户排行 / 单租户余额
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user