diff --git a/controller/relay.go b/controller/relay.go index 7e427086..8dccfe76 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -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 diff --git a/service/billing_session.go b/service/billing_session.go index 96afcf03..42b0ffbc 100644 --- a/service/billing_session.go +++ b/service/billing_session.go @@ -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()) } diff --git a/service/tiered_settle.go b/service/tiered_settle.go index b31d7964..1f3f58fe 100644 --- a/service/tiered_settle.go +++ b/service/tiered_settle.go @@ -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) diff --git a/service/tiered_settle_test.go b/service/tiered_settle_test.go index fe3a30a0..29a3bfb4 100644 --- a/service/tiered_settle_test.go +++ b/service/tiered_settle_test.go @@ -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 {