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:
+3
-4
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user