fix(billing): harden tiered retry group-switch billing (#6570)

Follow-up to #6518 (issue #6480) addressing three review findings:

- Document and lock in arrears semantics for the wallet Reserve top-up:
  when an auto-group retry lands on a more expensive group, the full
  reservation delta is deducted unconditionally (balance may go
  negative), mirroring settlement, so the logged pre-consumed quota
  always reconciles with the actual balance movement. Genuine DB
  errors still fail the attempt with update_data_error. Subscription
  funding keeps its insufficient-quota behavior: subscriptions enforce
  a hard used<=total cap and do not support arrears.
- PriceData.FreeModel is cleared when a retry switches from a free
  group to a paid one, keeping it consistent with the billing session
  created at that point.
- getChannel refreshes GroupRatioInfo only after channel selection
  succeeds, and the retry loop records the channel in use_channel
  before PrepareTieredBillingForSelectedGroup can fail.
This commit is contained in:
Calcium-Ion
2026-08-01 09:35:51 +08:00
committed by GitHub
parent df43f80153
commit cfaba1dd67
4 changed files with 127 additions and 5 deletions
+3 -4
View File
@@ -199,12 +199,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = channelErr
break
}
addUsedChannel(c, channel.Id)
if billingErr := service.PrepareTieredBillingForSelectedGroup(c, relayInfo); billingErr != nil {
newAPIError = billingErr
break
}
addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
@@ -312,9 +312,6 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
}, nil
}
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry: %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
@@ -322,6 +319,8 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
}
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil {
return nil, newAPIError
+4
View File
@@ -232,6 +232,10 @@ func (s *BillingSession) preConsume(c *gin.Context, quota int) *types.NewAPIErro
func (s *BillingSession) reserveFunding(delta int) error {
switch funding := s.funding.(type) {
case *WalletFunding:
// 与结算补扣(SettleBilling 正差额 → WalletFunding.Settle)语义一致:
// 全额无条件扣减,余额不足的部分记为欠费(余额可为负),不中断请求,
// 保证日志记录的预扣额度与用户余额的实际变动始终对账一致。
// DecreaseUserQuota 仅在数据库错误时失败。
if err := model.DecreaseUserQuota(funding.userId, delta, false); err != nil {
return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
}
+11 -1
View File
@@ -132,9 +132,19 @@ func PrepareTieredBillingForSelectedGroup(c *gin.Context, relayInfo *relaycommon
types.ErrOptionWithSkipRetry(),
)
}
if snap == nil || snap.GroupRatio == 0 {
if snap == nil {
return nil
}
if snap.GroupRatio == 0 {
// Paid-to-free keeps FreeModel as-is: FreeModel means "pre-consume was
// skipped", which is not true once a session exists, and settlement
// already yields 0 for a zero group ratio.
return nil
}
// The selected group is paid; clear a FreeModel flag frozen when the
// initial group was free so downstream state stays consistent.
relayInfo.PriceData.FreeModel = false
if relayInfo.Billing == nil {
return PreConsumeBilling(c, snap.EstimatedQuotaAfterGroup, relayInfo)
+109
View File
@@ -389,6 +389,7 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
FreeModel: true,
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
@@ -396,6 +397,7 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
require.Nil(t, PrepareTieredBillingForSelectedGroup(ctx, relayInfo))
require.NotNil(t, relayInfo.Billing)
assert.False(t, relayInfo.PriceData.FreeModel, "FreeModel must be cleared after switching to a paid group")
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 0.20, relayInfo.TieredBillingSnapshot.GroupRatio)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
@@ -405,6 +407,113 @@ func TestPrepareTieredBillingForSelectedGroupStartsBillingAfterFreeGroup(t *test
assert.Equal(t, 400_000, userQuota)
}
func TestPrepareTieredBillingForSelectedGroupPaidToFreeKeepsFreeModelFalse(t *testing.T) {
const expr = `tier("base", p)`
billing := &recordingBillingSettler{preConsumedQuota: 50_000}
relayInfo := &relaycommon.RelayInfo{
Billing: billing,
FinalPreConsumedQuota: 50_000,
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: expr,
ExprHash: billingexpr.ExprHashString(expr),
GroupRatio: 0.10,
EstimatedQuotaBeforeGroup: 500_000,
EstimatedQuotaAfterGroup: 50_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0},
},
}
require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
// Pre-consume did happen under the paid group, so FreeModel stays false;
// settlement already yields 0 for GroupRatio == 0 and the session refunds.
assert.False(t, relayInfo.PriceData.FreeModel)
assert.Empty(t, billing.reserveTargets)
assert.Equal(t, 50_000, relayInfo.FinalPreConsumedQuota)
}
func TestPrepareTieredBillingForSelectedGroupTopUpArrearsAllowsNegativeBalance(t *testing.T) {
truncate(t)
const userID = 701
// Balance covers the initial 50k pre-consume (already deducted before this
// test's seed) but not the 50k top-up to the more expensive retry group.
// The top-up must NOT abort the request: the full delta is deducted, the
// uncovered 30k becomes arrears (negative balance), mirroring how
// settlement charges a positive delta unconditionally.
seedUser(t, userID, 20_000)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
IsPlayground: true,
FinalPreConsumedQuota: 50_000,
TieredBillingSnapshot: &billingexpr.BillingSnapshot{
BillingMode: "tiered_expr",
ExprString: `tier("base", p)`,
ExprHash: billingexpr.ExprHashString(`tier("base", p)`),
GroupRatio: 0.10,
EstimatedQuotaBeforeGroup: 500_000,
EstimatedQuotaAfterGroup: 50_000,
QuotaPerUnit: testQuotaPerUnit,
},
PriceData: types.PriceData{
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 0.20},
},
}
session := &BillingSession{
relayInfo: relayInfo,
funding: &WalletFunding{userId: userID, consumed: 50_000},
preConsumedQuota: 50_000,
}
relayInfo.Billing = session
require.Nil(t, PrepareTieredBillingForSelectedGroup(nil, relayInfo))
// Full reservation recorded; wallet charged the full delta into arrears.
assert.Equal(t, 100_000, session.GetPreConsumedQuota())
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
assert.Equal(t, 100_000, relayInfo.TieredBillingSnapshot.EstimatedQuotaAfterGroup)
userQuota, err := model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, -30_000, userQuota)
// Settlement still reconciles against the full reservation: actual 80k
// refunds the 20k over-reserve, landing at seed - (actual - initial) = -10k.
require.NoError(t, session.Settle(80_000))
userQuota, err = model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, -10_000, userQuota)
}
func TestBillingSessionReserveWalletTopUpDecrementsBalance(t *testing.T) {
truncate(t)
const userID = 702
seedUser(t, userID, 500_000)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
IsPlayground: true,
}
session := &BillingSession{
relayInfo: relayInfo,
funding: &WalletFunding{userId: userID, consumed: 50_000},
preConsumedQuota: 50_000,
}
require.NoError(t, session.Reserve(100_000))
assert.Equal(t, 100_000, session.GetPreConsumedQuota())
assert.Equal(t, 100_000, relayInfo.FinalPreConsumedQuota)
userQuota, err := model.GetUserQuota(userID, false)
require.NoError(t, err)
assert.Equal(t, 450_000, userQuota)
}
func TestTryTieredSettleUsesFinalGroupAfterRetry(t *testing.T) {
const expr = `tier("base", p)`
tests := []struct {