fix: 修复 BillingSession 多个边界问题

- Settle 部分失败保护:新增 fundingSettled 标记,资金来源提交后
  令牌调整失败不再导致 Refund 误退已结算的资金
- 订阅多扣费修复:trySubscription 传 subConsume 而非 preConsumedQuota
  给 preConsume,保证三者(amount/preConsume/FinalPreConsumedQuota)一致
- 令牌回滚错误记录:preConsume 中 funding 失败时令牌回滚错误不再丢弃
- 移除钱包路径死代码:用户额度不足的 strings.Contains 匹配不可能命中
- WalletFunding.Refund 不重试:IncreaseUserQuota 非幂等,重试会多退
This commit is contained in:
CaIon
2026-02-06 23:37:32 +08:00
parent 0c0ccf510b
commit 15fc77d400
2 changed files with 36 additions and 20 deletions

View File

@@ -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

View File

@@ -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)
}