fix: settle excess token usage with negative balance (#304)

This commit is contained in:
MasterChiefCN authored and GitHub committed 2026-08-25 10:28:12 +08:00
1 parent a23e399ccf
commit c2a05db80f
3 files changed
+184 -11

No files matched your search

+41 -11
View File
@@ -21,7 +21,6 @@ var (
ErrTaskNotRetryable = errors.New("task is not retryable")
ErrBillingStateConflict = errors.New("billing state conflict")
ErrBillingUsageUnavailable = errors.New("billing usage unavailable")
ErrBillingUnderreserved = errors.New("billing amount exceeds reservation")
ErrChannelModelInUse = errors.New("channel model is in use")
)
@@ -604,7 +603,10 @@ func (r *Repository) MarkBillingUncertain(id string, errorText string) error {
}
func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
var observedUsage *BillingUsage
var observedActual int64
observedActualAvailable := false
err := r.db.Transaction(func(tx *gorm.DB) error {
var order model.BillingOrder
if err := tx.First(&order, "id = ?", id).Error; err != nil {
return err
@@ -620,6 +622,7 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
if err != nil {
return err
}
observedUsage = usage
reserved := order.ReservedAmountMicrocredits
if reserved <= 0 {
reserved = order.AmountMicrocredits
@@ -628,14 +631,14 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
if err != nil {
return err
}
if actual > reserved {
return ErrBillingUnderreserved
}
refund := reserved - actual
observedActual = actual
observedActualAvailable = true
refund := max(reserved-actual, int64(0))
supplement := max(actual-reserved, int64(0))
updated := tx.Model(&model.CreditAccount{}).
Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, reserved).
Updates(map[string]any{
"available_microcredits": gorm.Expr("available_microcredits + ?", refund),
"available_microcredits": gorm.Expr("available_microcredits + ?", refund-supplement),
"reserved_microcredits": gorm.Expr("reserved_microcredits - ?", reserved),
"version": gorm.Expr("version + 1"), "updated_at": time.Now(),
})
@@ -660,10 +663,14 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
if err := tx.Model(&order).Updates(updates).Error; err != nil {
return err
}
consumeNote := ""
if supplement > 0 {
consumeNote = "Token 实际用量超过预授权,已补扣差额"
}
if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume,
AmountMicrocredits: -actual, ReservedDeltaMicrocredits: -reserved,
AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved,
AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits,
BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene}).Error; err != nil {
BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: consumeNote}).Error; err != nil {
return err
}
if refund > 0 {
@@ -715,6 +722,26 @@ func (r *Repository) SettleBillingOrder(id string, providerRequestID string) err
Scene: order.Scene,
}).Error
})
if err != nil && observedUsage != nil {
// usage 是上游已经确认的事实;即使结算因账户状态异常回滚,也要保留给用户和管理员核对。
updates := map[string]any{
"input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens,
"cached_tokens": observedUsage.CachedTokens, "usage_available": true, "updated_at": time.Now(),
}
if observedActualAvailable {
updates["actual_amount_microcredits"] = observedActual
}
if providerRequestID != "" {
updates["provider_request_id"] = providerRequestID
}
usageErr := r.db.Model(&model.BillingOrder{}).
Where("id = ? AND status NOT IN ?", id, []model.BillingStatus{model.BillingStatusSettled, model.BillingStatusRefunded}).
Updates(updates).Error
if usageErr != nil {
return errors.Join(err, usageErr)
}
}
return err
}
func zeroPricedTokenOrder(order model.BillingOrder) bool {
@@ -822,8 +849,11 @@ func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
return err
}
updated := tx.Model(&model.CreditAccount{}).
Where("user_id = ? AND available_microcredits + ? >= 0", userID, amount).
accountQuery := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID)
if amount < 0 {
accountQuery = accountQuery.Where("available_microcredits + ? >= 0", amount)
}
updated := accountQuery.
Updates(map[string]any{
"available_microcredits": gorm.Expr("available_microcredits + ?", amount),
"version": gorm.Expr("version + 1"),