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
|
||||
|
||||
Reference in New Issue
Block a user