Files
new-api/service/billing.go
CaIon 0c0ccf510b refactor: 抽象统一计费会话 BillingSession
将散落在多个文件中的预扣费/结算/退款逻辑抽象为统一的 BillingSession 生命周期管理:

- 新增 BillingSettler 接口 (relay/common/billing.go) 避免循环引用
- 新增 FundingSource 接口 + WalletFunding / SubscriptionFunding 实现 (service/funding_source.go)
- 新增 BillingSession 封装预扣/结算/退款原子操作 (service/billing_session.go)
- 新增 SettleBilling 统一结算辅助函数,替换各 handler 中的 quotaDelta 模式
- 重写 PreConsumeBilling 为 BillingSession 工厂入口
- controller/relay.go 退款守卫改用 BillingSession.Refund()

修复的 Bug:
- 令牌额度泄漏:PreConsumeTokenQuota 成功但 DecreaseUserQuota 失败时未回滚
- 订阅退款遗漏:FinalPreConsumedQuota=0 但 SubscriptionPreConsumed>0 时跳过退款
- 订阅多扣费:subConsume 强制为 1 但 FinalPreConsumedQuota 不同步
- 退款路径不统一:钱包/订阅退款逻辑现统一由 FundingSource.Refund 分派
2026-02-06 23:14:25 +08:00

75 lines
2.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
const (
BillingSourceWallet = "wallet"
BillingSourceSubscription = "subscription"
)
// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
if apiErr != nil {
return apiErr
}
relayInfo.Billing = session
return nil
}
// ---------------------------------------------------------------------------
// SettleBilling — 后结算辅助函数
// ---------------------------------------------------------------------------
// SettleBilling 执行计费结算。如果 RelayInfo 上有 BillingSession 则通过 session 结算,
// 否则回退到旧的 PostConsumeQuota 路径(兼容按次计费等场景)。
func SettleBilling(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, actualQuota int) error {
if relayInfo.Billing != nil {
preConsumed := relayInfo.Billing.GetPreConsumedQuota()
delta := actualQuota - preConsumed
if delta > 0 {
logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s实际消耗%s预扣费%s",
logger.FormatQuota(delta),
logger.FormatQuota(actualQuota),
logger.FormatQuota(preConsumed),
))
} else if delta < 0 {
logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s实际消耗%s预扣费%s",
logger.FormatQuota(-delta),
logger.FormatQuota(actualQuota),
logger.FormatQuota(preConsumed),
))
} else {
logger.LogInfo(ctx, fmt.Sprintf("预扣费与实际消耗一致,无需调整:%s按次计费",
logger.FormatQuota(actualQuota),
))
}
if err := relayInfo.Billing.Settle(actualQuota); err != nil {
return err
}
// 发送额度通知
if actualQuota != 0 {
checkAndSendQuotaNotify(relayInfo, actualQuota-preConsumed, preConsumed)
}
return nil
}
// 回退:无 BillingSession 时使用旧路径
quotaDelta := actualQuota - relayInfo.FinalPreConsumedQuota
if quotaDelta != 0 {
return PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true)
}
return nil
}