From 9c92d5c671e7915c52dd509e2012fb060ee606d5 Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Sat, 11 Jul 2026 00:21:06 +0800 Subject: [PATCH 1/2] fix task refund used quota accounting --- model/channel.go | 28 ++++++++++++++++++++++-- model/user.go | 39 ++++++++++++++++++++++++++++------ service/task_billing.go | 8 ++++++- service/task_billing_test.go | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 10 deletions(-) diff --git a/model/channel.go b/model/channel.go index dbd1deaef4d..290d8f2aa73 100644 --- a/model/channel.go +++ b/model/channel.go @@ -37,7 +37,7 @@ type Channel struct { Balance float64 `json:"balance"` // in USD BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"` Models string `json:"models"` - Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + Group string `json:"group" gorm:"type:varchar(1024);default:'default'"` UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` ModelMapping *string `json:"model_mapping" gorm:"type:text"` //MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"` @@ -860,8 +860,32 @@ func UpdateChannelUsedQuota(id int, quota int) { updateChannelUsedQuota(id, quota) } +func DecreaseChannelUsedQuota(id int, quota int) { + if id <= 0 || quota <= 0 { + return + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeChannelUsedQuota, id, -quota) + return + } + err := DB.Model(&Channel{}).Where("id = ?", id).Update( + "used_quota", + gorm.Expr("CASE WHEN used_quota < ? THEN 0 ELSE used_quota - ? END", quota, quota), + ).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to decrease channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err)) + } +} + func updateChannelUsedQuota(id int, quota int) { - err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error + var expr clause.Expr + if quota < 0 { + refund := -quota + expr = gorm.Expr("CASE WHEN used_quota < ? THEN 0 ELSE used_quota - ? END", refund, refund) + } else { + expr = gorm.Expr("used_quota + ?", quota) + } + err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", expr).Error if err != nil { common.SysLog(fmt.Sprintf("failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err)) } diff --git a/model/user.go b/model/user.go index 08ffabccd65..d36a8d672dd 100644 --- a/model/user.go +++ b/model/user.go @@ -1136,6 +1136,23 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { updateUserUsedQuotaAndRequestCount(id, quota, 1) } +func DecreaseUserUsedQuota(id int, quota int) { + if id <= 0 || quota <= 0 { + return + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUsedQuota, id, -quota) + return + } + err := DB.Model(&User{}).Where("id = ?", id).Update( + "used_quota", + gorm.Expr("CASE WHEN used_quota < ? THEN 0 ELSE used_quota - ? END", quota, quota), + ).Error + if err != nil { + common.SysLog("failed to decrease user used quota: " + err.Error()) + } +} + func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) { err := DB.Model(&User{}).Where("id = ?", id).Updates( map[string]interface{}{ @@ -1159,13 +1176,21 @@ func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, r return } - err := DB.Model(&User{}).Where("id = ?", id).Updates( - map[string]interface{}{ - "quota": gorm.Expr("quota + ?", quota), - "used_quota": gorm.Expr("used_quota + ?", usedQuota), - "request_count": gorm.Expr("request_count + ?", requestCount), - }, - ).Error + updates := map[string]interface{}{} + if quota != 0 { + updates["quota"] = gorm.Expr("quota + ?", quota) + } + if usedQuota > 0 { + updates["used_quota"] = gorm.Expr("used_quota + ?", usedQuota) + } else if usedQuota < 0 { + refund := -usedQuota + updates["used_quota"] = gorm.Expr("CASE WHEN used_quota < ? THEN 0 ELSE used_quota - ? END", refund, refund) + } + if requestCount != 0 { + updates["request_count"] = gorm.Expr("request_count + ?", requestCount) + } + + err := DB.Model(&User{}).Where("id = ?", id).Updates(updates).Error if err != nil { common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error()) } diff --git a/service/task_billing.go b/service/task_billing.go index 9e43ee2b2e1..01165475c08 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -165,7 +165,11 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { // 2. 退还令牌额度 taskAdjustTokenQuota(ctx, task, -quota) - // 3. 记录日志 + // 3. 回退已用额度统计,避免退款后“剩余额度 + 已用额度”虚增。 + model.DecreaseUserUsedQuota(task.UserId, quota) + model.DecreaseChannelUsedQuota(task.ChannelId, quota) + + // 4. 记录日志 other := taskBillingOther(task) other["task_id"] = task.TaskID other["reason"] = reason @@ -231,6 +235,8 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int } else { logType = model.LogTypeRefund logQuota = -quotaDelta + model.DecreaseUserUsedQuota(task.UserId, logQuota) + model.DecreaseChannelUsedQuota(task.ChannelId, logQuota) } other := taskBillingOther(task) other["task_id"] = task.TaskID diff --git a/service/task_billing_test.go b/service/task_billing_test.go index b6b1c080467..8b596d220dc 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -150,6 +150,20 @@ func getUserQuota(t *testing.T, id int) int { return user.Quota } +func getUserUsedQuota(t *testing.T, id int) int { + t.Helper() + var user model.User + require.NoError(t, model.DB.Select("used_quota").Where("id = ?", id).First(&user).Error) + return user.UsedQuota +} + +func getChannelUsedQuota(t *testing.T, id int) int64 { + t.Helper() + var channel model.Channel + require.NoError(t, model.DB.Select("used_quota").Where("id = ?", id).First(&channel).Error) + return channel.UsedQuota +} + func getTokenRemainQuota(t *testing.T, id int) int { t.Helper() var token model.Token @@ -293,6 +307,29 @@ func TestRefundTaskQuota_NoToken(t *testing.T) { assert.Equal(t, model.LogTypeRefund, log.Type) } +func TestRefundTaskQuota_DecreasesUsageTotals(t *testing.T) { + truncate(t) + ctx := context.Background() + + const userID, tokenID, channelID = 5, 5, 5 + const initQuota, preConsumed = 10000, 1500 + const tokenRemain = 5000 + + seedUser(t, userID, initQuota) + seedToken(t, tokenID, userID, "sk-usage-refund", tokenRemain) + seedChannel(t, channelID) + require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("used_quota", preConsumed).Error) + require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", channelID).Update("used_quota", preConsumed).Error) + + task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) + + RefundTaskQuota(ctx, task, "failed task should not inflate total quota") + + assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID)) + assert.Zero(t, getUserUsedQuota(t, userID)) + assert.Zero(t, getChannelUsedQuota(t, channelID)) +} + // =========================================================================== // RecalculateTaskQuota tests // =========================================================================== @@ -342,6 +379,8 @@ func TestRecalculate_NegativeDelta(t *testing.T) { seedUser(t, userID, initQuota) seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain) seedChannel(t, channelID) + require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", userID).Update("used_quota", preConsumed).Error) + require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", channelID).Update("used_quota", preConsumed).Error) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) @@ -355,6 +394,8 @@ func TestRecalculate_NegativeDelta(t *testing.T) { // task.Quota updated assert.Equal(t, actualQuota, task.Quota) + assert.Equal(t, actualQuota, getUserUsedQuota(t, userID)) + assert.EqualValues(t, actualQuota, getChannelUsedQuota(t, channelID)) // Log type should be Refund log := getLastLog(t) From fb4de271082f75ab69a23c0b2d00bf3181c64417 Mon Sep 17 00:00:00 2001 From: cat0825 <1759138827@qq.com> Date: Sat, 11 Jul 2026 00:42:08 +0800 Subject: [PATCH 2/2] fix: drop unrelated channels.group length change from task refund PR Keep #4211 scoped to used-quota reversal only. Channel group column widening belongs in #6017. --- model/channel.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/model/channel.go b/model/channel.go index 290d8f2aa73..888a20d72e8 100644 --- a/model/channel.go +++ b/model/channel.go @@ -37,7 +37,7 @@ type Channel struct { Balance float64 `json:"balance"` // in USD BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"` Models string `json:"models"` - Group string `json:"group" gorm:"type:varchar(1024);default:'default'"` + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` ModelMapping *string `json:"model_mapping" gorm:"type:text"` //MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"`