mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 05:20:18 +00:00
fix: 修复 BillingSession 多个边界问题
- Settle 部分失败保护:新增 fundingSettled 标记,资金来源提交后 令牌调整失败不再导致 Refund 误退已结算的资金 - 订阅多扣费修复:trySubscription 传 subConsume 而非 preConsumedQuota 给 preConsume,保证三者(amount/preConsume/FinalPreConsumedQuota)一致 - 令牌回滚错误记录:preConsume 中 funding 失败时令牌回滚错误不再丢弃 - 移除钱包路径死代码:用户额度不足的 strings.Contains 匹配不可能命中 - WalletFunding.Refund 不重试:IncreaseUserQuota 非幂等,重试会多退
This commit is contained in:
@@ -27,12 +27,15 @@ type BillingSession struct {
|
||||
funding FundingSource
|
||||
preConsumedQuota int // 实际预扣额度(信任用户可能为 0)
|
||||
tokenConsumed int // 令牌额度实际扣减量
|
||||
settled bool // Settle 已调用
|
||||
fundingSettled bool // funding.Settle 已成功,资金来源已提交
|
||||
settled bool // Settle 全部完成(资金 + 令牌)
|
||||
refunded bool // Refund 已调用
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Settle 根据实际消耗额度进行结算。
|
||||
// 资金来源和令牌额度分两步提交:若资金来源已提交但令牌调整失败,
|
||||
// 会标记 fundingSettled 防止 Refund 对已提交的资金来源执行退款。
|
||||
func (s *BillingSession) Settle(actualQuota int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -44,20 +47,25 @@ func (s *BillingSession) Settle(actualQuota int) error {
|
||||
s.settled = true
|
||||
return nil
|
||||
}
|
||||
// 1) 调整资金来源
|
||||
if err := s.funding.Settle(delta); err != nil {
|
||||
return err
|
||||
// 1) 调整资金来源(仅在尚未提交时执行,防止重复调用)
|
||||
if !s.fundingSettled {
|
||||
if err := s.funding.Settle(delta); err != nil {
|
||||
return err
|
||||
}
|
||||
s.fundingSettled = true
|
||||
}
|
||||
// 2) 调整令牌额度
|
||||
var tokenErr error
|
||||
if !s.relayInfo.IsPlayground {
|
||||
if delta > 0 {
|
||||
if err := model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
tokenErr = model.DecreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, delta)
|
||||
} else {
|
||||
if err := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta); err != nil {
|
||||
return err
|
||||
}
|
||||
tokenErr = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, -delta)
|
||||
}
|
||||
if tokenErr != nil {
|
||||
// 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金
|
||||
common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s",
|
||||
s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error()))
|
||||
}
|
||||
}
|
||||
// 3) 更新 relayInfo 上的订阅 PostDelta(用于日志)
|
||||
@@ -65,7 +73,7 @@ func (s *BillingSession) Settle(actualQuota int) error {
|
||||
s.relayInfo.SubscriptionPostDelta += int64(delta)
|
||||
}
|
||||
s.settled = true
|
||||
return nil
|
||||
return tokenErr
|
||||
}
|
||||
|
||||
// Refund 退还所有预扣费,幂等安全,异步执行。
|
||||
@@ -113,7 +121,8 @@ func (s *BillingSession) NeedsRefund() bool {
|
||||
}
|
||||
|
||||
func (s *BillingSession) needsRefundLocked() bool {
|
||||
if s.settled || s.refunded {
|
||||
if s.settled || s.refunded || s.fundingSettled {
|
||||
// fundingSettled 时资金来源已提交结算,不能再退预扣费
|
||||
return false
|
||||
}
|
||||
if s.tokenConsumed > 0 {
|
||||
@@ -158,18 +167,19 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
|
||||
|
||||
// ---- 2) 预扣资金来源 ----
|
||||
if err := s.funding.PreConsume(effectiveQuota); err != nil {
|
||||
// 回滚令牌额度
|
||||
// 预扣费失败,回滚令牌额度
|
||||
if s.tokenConsumed > 0 && !s.relayInfo.IsPlayground {
|
||||
_ = model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed)
|
||||
if rollbackErr := model.IncreaseTokenQuota(s.relayInfo.TokenId, s.relayInfo.TokenKey, s.tokenConsumed); rollbackErr != nil {
|
||||
common.SysLog(fmt.Sprintf("error rolling back token quota (userId=%d, tokenId=%d, amount=%d, fundingErr=%s): %s",
|
||||
s.relayInfo.UserId, s.relayInfo.TokenId, s.tokenConsumed, err.Error(), rollbackErr.Error()))
|
||||
}
|
||||
s.tokenConsumed = 0
|
||||
}
|
||||
// TODO: model 层应定义哨兵错误(如 ErrNoActiveSubscription),用 errors.Is 替代字符串匹配
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
if strings.Contains(errMsg, "用户额度不足") || strings.Contains(errMsg, "预扣费额度失败") {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
@@ -202,8 +212,10 @@ func (s *BillingSession) shouldTrust(c *gin.Context) bool {
|
||||
case BillingSourceWallet:
|
||||
return s.relayInfo.UserQuota > trustQuota
|
||||
case BillingSourceSubscription:
|
||||
// 订阅暂不支持信任旁路(订阅剩余额度需要额外查询,且预扣开销小)
|
||||
// 后续可以在此处添加订阅信任逻辑
|
||||
// 订阅不能启用信任旁路。原因:
|
||||
// 1. PreConsumeUserSubscription 要求 amount>0 来创建预扣记录并锁定订阅
|
||||
// 2. SubscriptionFunding.PreConsume 忽略参数,始终用 s.amount 预扣
|
||||
// 3. 若信任旁路将 effectiveQuota 设为 0,会导致 preConsumedQuota 与实际订阅预扣不一致
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
@@ -286,7 +298,9 @@ func NewBillingSession(c *gin.Context, relayInfo *relaycommon.RelayInfo, preCons
|
||||
amount: subConsume,
|
||||
},
|
||||
}
|
||||
if apiErr := session.preConsume(c, preConsumedQuota); apiErr != nil {
|
||||
// 必须传 subConsume 而非 preConsumedQuota,保证 SubscriptionFunding.amount、
|
||||
// preConsume 参数和 FinalPreConsumedQuota 三者一致,避免订阅多扣费。
|
||||
if apiErr := session.preConsume(c, int(subConsume)); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
return session, nil
|
||||
|
||||
@@ -58,6 +58,8 @@ func (w *WalletFunding) Refund() error {
|
||||
if w.consumed <= 0 {
|
||||
return nil
|
||||
}
|
||||
// IncreaseUserQuota 是 quota += N 的非幂等操作,不能重试,否则会多退额度。
|
||||
// 订阅的 RefundSubscriptionPreConsume 有 requestId 幂等保护所以可以重试。
|
||||
return model.IncreaseUserQuota(w.userId, w.consumed, false)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user