diff --git a/service/billing_session.go b/service/billing_session.go index 5dadf7808..04fce6136 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -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 diff --git a/service/funding_source.go b/service/funding_source.go index 87672419c..98f5e874d 100644 --- a/service/funding_source.go +++ b/service/funding_source.go @@ -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) }