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:
Blizzard
2026-07-07 13:53:43 +08:00
parent 11d321f218
commit 93741a5504
5 changed files with 62 additions and 7 deletions
@@ -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,
+9 -1
View File
@@ -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 {