fix(billing): 异步任务退款时同步减少 used_quota (#6795)

* fix(billing): 异步任务退款时同步减少 used_quota
退款时仅恢复了 quota(剩余额度),但未同步减少 used_quota(已用额度),
导致"总额度"(quota + used_quota)随退款次数持续虚增,超出用户实际充值金额。

修复三处退款路径:
- RefundTaskQuota:任务失败完整退款
- RecalculateTaskQuota:差额结算退款分支
- controller/midjourney.go:Midjourney 任务失败退款

新增 model.UpdateUserUsedQuota 公开函数,仅调整 used_quota 不影响 request_count。

* fix(billing): 任务退款时同步扣减渠道 used_quota

* fix(billing): complete async task refund accounting

* style(model): group internal Midjourney fields

---------

Co-authored-by: CaIon <i@caion.me>
This commit is contained in:
wans10
2026-08-13 22:06:40 +08:00
committed by GitHub
co-authored by CaIon
parent ccd535ef8e
commit 58d4e9bd3b
9 changed files with 667 additions and 81 deletions
+16
View File
@@ -23,6 +23,9 @@ type Midjourney struct {
Quota int `json:"quota"`
Buttons string `json:"buttons"`
Properties string `json:"properties"`
TokenId int `json:"-" gorm:"default:0"`
BillingChannelId int `json:"-" gorm:"default:0"`
}
// TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
@@ -170,6 +173,19 @@ func (midjourney *Midjourney) Update() error {
return err
}
func (midjourney *Midjourney) UpdateBillingState() error {
return DB.Model(midjourney).
Select("quota", "token_id", "billing_channel_id").
Updates(midjourney).Error
}
func (midjourney *Midjourney) GetBillingChannelId() int {
if midjourney.BillingChannelId > 0 {
return midjourney.BillingChannelId
}
return midjourney.ChannelId
}
// UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Returns (true, nil) if this caller won the update, (false, nil) if
// another process already moved the task out of fromStatus.
+11
View File
@@ -1353,6 +1353,17 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
updateUserUsedQuotaAndRequestCount(id, quota, 1)
}
// UpdateUserUsedQuota adjusts accumulated usage without changing request count.
func UpdateUserUsedQuota(id int, quota int) {
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUsedQuota, id, quota)
return
}
if err := DB.Model(&User{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error; err != nil {
common.SysLog("failed to update user used quota: " + err.Error())
}
}
func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
+55
View File
@@ -89,6 +89,61 @@ func TestUserUpdateDoesNotOverwriteConcurrentAccountingOrTokenChanges(t *testing
assert.Equal(t, "rotated-token", got.GetAccessToken())
}
func TestUsageAccountingSupportsSignedDirectAndBatchDeltas(t *testing.T) {
setupUserUpdateTestState(t)
resetBatchUpdateTestState(t)
user := User{
Id: 10,
Username: "usage-adjustment-user",
Password: "password",
Status: common.UserStatusEnabled,
UsedQuota: 1000,
RequestCount: 3,
}
channel := Channel{
Id: 10,
Name: "usage-adjustment-channel",
Key: "sk-test",
Status: common.ChannelStatusEnabled,
UsedQuota: 1000,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Create(&channel).Error)
UpdateUserUsedQuota(user.Id, -200)
UpdateUserUsedQuota(user.Id, 50)
UpdateChannelUsedQuota(channel.Id, -200)
UpdateChannelUsedQuota(channel.Id, 50)
var got User
require.NoError(t, DB.Select("used_quota", "request_count").First(&got, user.Id).Error)
assert.Equal(t, 850, got.UsedQuota)
assert.Equal(t, 3, got.RequestCount)
var gotChannel Channel
require.NoError(t, DB.Select("used_quota").First(&gotChannel, channel.Id).Error)
assert.Equal(t, int64(850), gotChannel.UsedQuota)
common.BatchUpdateEnabled = true
UpdateUserUsedQuota(user.Id, 400)
UpdateUserUsedQuota(user.Id, -100)
UpdateChannelUsedQuota(channel.Id, 400)
UpdateChannelUsedQuota(channel.Id, -100)
require.NoError(t, DB.Select("used_quota", "request_count").First(&got, user.Id).Error)
assert.Equal(t, 850, got.UsedQuota, "batch deltas must remain queued until flush")
assert.Equal(t, 3, got.RequestCount)
require.NoError(t, DB.Select("used_quota").First(&gotChannel, channel.Id).Error)
assert.Equal(t, int64(850), gotChannel.UsedQuota, "batch deltas must remain queued until flush")
batchUpdate()
require.NoError(t, DB.Select("used_quota", "request_count").First(&got, user.Id).Error)
assert.Equal(t, 1150, got.UsedQuota)
assert.Equal(t, 3, got.RequestCount)
require.NoError(t, DB.Select("used_quota").First(&gotChannel, channel.Id).Error)
assert.Equal(t, int64(1150), gotChannel.UsedQuota)
}
func TestUpdateUserAccessTokenOnlyUpdatesAccessToken(t *testing.T) {
setupUserUpdateTestState(t)