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
+1 -16
View File
@@ -213,22 +213,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot
if err != nil { if err != nil {
logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error()) logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error())
} else if won && shouldReturnQuota { } else if won && shouldReturnQuota {
err = model.IncreaseUserQuota(task.UserId, task.Quota, false) service.RefundMidjourneyQuota(ctx, task, "构图失败")
if err != nil {
logger.LogError(ctx, "fail to increase user quota: "+err.Error())
}
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: task.ChannelId,
ModelName: service.CovertMjpActionToModelName(task.Action),
Quota: task.Quota,
Other: map[string]interface{}{
"task_id": task.MjId,
"reason": "构图失败",
},
})
} }
} }
} }
+16
View File
@@ -23,6 +23,9 @@ type Midjourney struct {
Quota int `json:"quota"` Quota int `json:"quota"`
Buttons string `json:"buttons"` Buttons string `json:"buttons"`
Properties string `json:"properties"` Properties string `json:"properties"`
TokenId int `json:"-" gorm:"default:0"`
BillingChannelId int `json:"-" gorm:"default:0"`
} }
// TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 // TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段
@@ -170,6 +173,19 @@ func (midjourney *Midjourney) Update() error {
return err 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). // UpdateWithStatus performs a conditional UPDATE guarded by fromStatus (CAS).
// Returns (true, nil) if this caller won the update, (false, nil) if // Returns (true, nil) if this caller won the update, (false, nil) if
// another process already moved the task out of fromStatus. // 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) 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) { func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates( err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{ map[string]interface{}{
+55
View File
@@ -89,6 +89,61 @@ func TestUserUpdateDoesNotOverwriteConcurrentAccountingOrTokenChanges(t *testing
assert.Equal(t, "rotated-token", got.GetAccessToken()) 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) { func TestUpdateUserAccessTokenOnlyUpdatesAccessToken(t *testing.T) {
setupUserUpdateTestState(t) setupUserUpdateTestState(t)
+62 -50
View File
@@ -232,30 +232,6 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR
if err != nil { if err != nil {
return &mjResp.Response return &mjResp.Response
} }
defer func() {
if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 {
err := service.PostConsumeQuota(info, priceData.Quota, 0, true)
if err != nil {
common.SysLog("error consuming token remain quota: " + err.Error())
}
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace)
other := service.GenerateMjOtherInfo(info, priceData)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: info.ChannelId,
ModelName: modelName,
TokenName: tokenName,
Quota: priceData.Quota,
Content: logContent,
TokenId: info.TokenId,
Group: info.UsingGroup,
Other: other,
})
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, priceData.Quota)
model.UpdateChannelUsedQuota(info.ChannelId, priceData.Quota)
}
}()
midjResponse := &mjResp.Response midjResponse := &mjResp.Response
midjourneyTask := &model.Midjourney{ midjourneyTask := &model.Midjourney{
UserId: info.UserId, UserId: info.UserId,
@@ -274,12 +250,42 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR
Progress: "0%", Progress: "0%",
FailReason: "", FailReason: "",
ChannelId: c.GetInt("channel_id"), ChannelId: c.GetInt("channel_id"),
Quota: priceData.Quota, }
billingPrepared, billingErr := service.PrepareMidjourneyTaskBilling(
info,
midjourneyTask,
priceData.Quota,
mjResp.StatusCode == http.StatusOK && midjResponse.Code == 1,
)
if billingErr != nil {
common.SysLog("error consuming Midjourney quota: " + billingErr.Error())
} }
err = midjourneyTask.Insert() err = midjourneyTask.Insert()
if err != nil { if err != nil {
return service.MidjourneyErrorWrapper(constant.MjRequestError, "insert_midjourney_task_failed") return service.MidjourneyErrorWrapper(constant.MjRequestError, "insert_midjourney_task_failed")
} }
billingApplied, billingErr := service.SettleMidjourneyTaskBilling(info, midjourneyTask, billingPrepared)
if billingErr != nil {
common.SysLog("error settling Midjourney quota: " + billingErr.Error())
}
if billingApplied {
billingChannelId := midjourneyTask.GetBillingChannelId()
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace)
other := service.GenerateMjOtherInfo(info, priceData)
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
ChannelId: billingChannelId,
ModelName: modelName,
TokenName: tokenName,
Quota: midjourneyTask.Quota,
Content: logContent,
TokenId: midjourneyTask.TokenId,
Group: info.UsingGroup,
Other: other,
})
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, midjourneyTask.Quota)
model.UpdateChannelUsedQuota(billingChannelId, midjourneyTask.Quota)
}
c.Writer.WriteHeader(mjResp.StatusCode) c.Writer.WriteHeader(mjResp.StatusCode)
respBody, err := json.Marshal(midjResponse) respBody, err := json.Marshal(midjResponse)
if err != nil { if err != nil {
@@ -539,30 +545,6 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
} }
midjResponse := &midjResponseWithStatus.Response midjResponse := &midjResponseWithStatus.Response
defer func() {
if consumeQuota && midjResponseWithStatus.StatusCode == 200 {
err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true)
if err != nil {
common.SysLog("error consuming token remain quota: " + err.Error())
}
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %sID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result)
other := service.GenerateMjOtherInfo(relayInfo, priceData)
model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId,
ModelName: modelName,
TokenName: tokenName,
Quota: priceData.Quota,
Content: logContent,
TokenId: relayInfo.TokenId,
Group: relayInfo.UsingGroup,
Other: other,
})
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, priceData.Quota)
model.UpdateChannelUsedQuota(relayInfo.ChannelId, priceData.Quota)
}
}()
// 文档:https://github.com/novicezk/midjourney-proxy/blob/main/docs/api.md // 文档:https://github.com/novicezk/midjourney-proxy/blob/main/docs/api.md
//1-提交成功 //1-提交成功
// 21-任务已存在(处理中或者有结果了) {"code":21,"description":"任务已存在","result":"0741798445574458","properties":{"status":"SUCCESS","imageUrl":"https://xxxx"}} // 21-任务已存在(处理中或者有结果了) {"code":21,"description":"任务已存在","result":"0741798445574458","properties":{"status":"SUCCESS","imageUrl":"https://xxxx"}}
@@ -587,7 +569,6 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
Progress: "0%", Progress: "0%",
FailReason: "", FailReason: "",
ChannelId: c.GetInt("channel_id"), ChannelId: c.GetInt("channel_id"),
Quota: priceData.Quota,
} }
if midjResponse.Code == 3 { if midjResponse.Code == 3 {
//无实例账号自动禁用渠道(No available account instance //无实例账号自动禁用渠道(No available account instance
@@ -632,6 +613,15 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
midjourneyTask.Progress = "100%" midjourneyTask.Progress = "100%"
midjourneyTask.Status = "SUCCESS" midjourneyTask.Status = "SUCCESS"
} }
billingPrepared, billingErr := service.PrepareMidjourneyTaskBilling(
relayInfo,
midjourneyTask,
priceData.Quota,
consumeQuota && midjResponseWithStatus.StatusCode == http.StatusOK,
)
if billingErr != nil {
common.SysLog("error consuming Midjourney quota: " + billingErr.Error())
}
err = midjourneyTask.Insert() err = midjourneyTask.Insert()
if err != nil { if err != nil {
return &dto.MidjourneyResponse{ return &dto.MidjourneyResponse{
@@ -639,6 +629,28 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt
Description: "insert_midjourney_task_failed", Description: "insert_midjourney_task_failed",
} }
} }
billingApplied, billingErr := service.SettleMidjourneyTaskBilling(relayInfo, midjourneyTask, billingPrepared)
if billingErr != nil {
common.SysLog("error settling Midjourney quota: " + billingErr.Error())
}
if billingApplied {
billingChannelId := midjourneyTask.GetBillingChannelId()
tokenName := c.GetString("token_name")
logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %sID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result)
other := service.GenerateMjOtherInfo(relayInfo, priceData)
model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: billingChannelId,
ModelName: modelName,
TokenName: tokenName,
Quota: midjourneyTask.Quota,
Content: logContent,
TokenId: midjourneyTask.TokenId,
Group: relayInfo.UsingGroup,
Other: other,
})
model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, midjourneyTask.Quota)
model.UpdateChannelUsedQuota(billingChannelId, midjourneyTask.Quota)
}
if midjResponse.Code == 22 { //22-排队中,说明任务已存在 if midjResponse.Code == 22 { //22-排队中,说明任务已存在
//修改返回值 //修改返回值
+111
View File
@@ -3,6 +3,8 @@ package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@@ -13,6 +15,8 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant" relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
@@ -27,6 +31,113 @@ func CovertMjpActionToModelName(mjAction string) string {
return modelName return modelName
} }
// PrepareMidjourneyTaskBilling sets the durable refund marker before the task is inserted.
func PrepareMidjourneyTaskBilling(relayInfo *relaycommon.RelayInfo, task *model.Midjourney, quota int, shouldBill bool) (bool, error) {
if task == nil {
return false, errors.New("Midjourney task is nil")
}
task.Quota = 0
task.TokenId = 0
task.BillingChannelId = 0
if !shouldBill {
return false, nil
}
if relayInfo == nil {
return false, errors.New("relay info is nil")
}
if quota < 0 {
return false, errors.New("quota cannot be negative")
}
if relayInfo.BillingSource == BillingSourceSubscription {
return false, errors.New("legacy Midjourney billing does not support subscriptions")
}
task.Quota = quota
task.BillingChannelId = task.ChannelId
if relayInfo.ChannelMeta != nil && relayInfo.ChannelId > 0 {
task.BillingChannelId = relayInfo.ChannelId
}
return true, nil
}
// SettleMidjourneyTaskBilling charges a persisted legacy task and records the applied stages.
func SettleMidjourneyTaskBilling(relayInfo *relaycommon.RelayInfo, task *model.Midjourney, prepared bool) (bool, error) {
if !prepared {
return false, nil
}
if relayInfo == nil {
return false, errors.New("relay info is nil")
}
if task == nil || task.Id == 0 {
return false, errors.New("Midjourney task must be persisted before billing")
}
result, billingErr := postConsumeQuotaWithResult(relayInfo, task.Quota, 0, true)
if !result.FundingApplied {
task.Quota = 0
task.TokenId = 0
task.BillingChannelId = 0
if updateErr := task.UpdateBillingState(); updateErr != nil {
return false, errors.Join(billingErr, fmt.Errorf("clear Midjourney billing state: %w", updateErr))
}
return false, billingErr
}
task.TokenId = 0
if result.TokenApplied {
task.TokenId = relayInfo.TokenId
}
if updateErr := task.UpdateBillingState(); updateErr != nil {
return true, errors.Join(billingErr, fmt.Errorf("update Midjourney billing state: %w", updateErr))
}
return true, billingErr
}
// RefundMidjourneyQuota reverses every accounting element recorded for a billed legacy task.
func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason string) bool {
quota := task.Quota
if quota == 0 {
return true
}
if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还 Midjourney 用户额度失败 task %s: %s", task.MjId, err.Error()))
return false
}
if task.TokenId > 0 {
tokenKey := resolveTokenKey(ctx, task.TokenId, task.MjId)
if tokenKey != "" {
if err := model.IncreaseTokenQuota(task.TokenId, tokenKey, quota); err != nil {
logger.LogWarn(ctx, fmt.Sprintf("退还 Midjourney 令牌额度失败 task %s: %s", task.MjId, err.Error()))
}
}
}
billingChannelId := task.GetBillingChannelId()
model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(billingChannelId, -quota)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: billingChannelId,
ModelName: CovertMjpActionToModelName(task.Action),
Quota: quota,
TokenId: task.TokenId,
Other: map[string]interface{}{
"task_id": task.MjId,
"reason": reason,
},
})
task.Quota = 0
if err := task.UpdateBillingState(); err != nil {
logger.LogError(ctx, fmt.Sprintf("Midjourney 退款成功但清除 quota 失败 task %s: %s", task.MjId, err.Error()))
}
return true
}
func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) { func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) {
action := "" action := ""
if relayMode == relayconstant.RelayModeMidjourneyAction { if relayMode == relayconstant.RelayModeMidjourneyAction {
+18 -6
View File
@@ -406,17 +406,27 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
return nil return nil
} }
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) { type postConsumeQuotaResult struct {
FundingApplied bool
TokenApplied bool
}
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) error {
_, err := postConsumeQuotaWithResult(relayInfo, quota, preConsumedQuota, sendEmail)
return err
}
func postConsumeQuotaWithResult(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (result postConsumeQuotaResult, err error) {
// 1) Consume from wallet quota OR subscription item // 1) Consume from wallet quota OR subscription item
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription { if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
if relayInfo.SubscriptionId == 0 { if relayInfo.SubscriptionId == 0 {
return errors.New("subscription id is missing") return result, errors.New("subscription id is missing")
} }
delta := int64(quota) delta := int64(quota)
if delta != 0 { if delta != 0 {
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil { if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
return err return result, err
} }
relayInfo.SubscriptionPostDelta += delta relayInfo.SubscriptionPostDelta += delta
} }
@@ -428,9 +438,10 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
} }
if err != nil { if err != nil {
return err return result, err
} }
} }
result.FundingApplied = true
if !relayInfo.IsPlayground { if !relayInfo.IsPlayground {
if quota > 0 { if quota > 0 {
@@ -439,8 +450,9 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota) err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota)
} }
if err != nil { if err != nil {
return err return result, err
} }
result.TokenApplied = true
} }
if sendEmail { if sendEmail {
@@ -449,7 +461,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
} }
} }
return nil return result, nil
} }
func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) { func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) {
+11 -5
View File
@@ -161,7 +161,7 @@ func taskModelName(task *model.Task) string {
} }
// RefundTaskQuota 统一的任务失败退款逻辑。 // RefundTaskQuota 统一的任务失败退款逻辑。
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度 // 当异步任务失败时,退还资金与令牌额度,并回减用户和渠道用量
// 返回资金来源是否已成功退还;失败时保留 quota,供显式重试或人工对账。 // 返回资金来源是否已成功退还;失败时保留 quota,供显式重试或人工对账。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool { func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool {
quota := task.Quota quota := task.Quota
@@ -178,7 +178,11 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool
// 2. 退还令牌额度 // 2. 退还令牌额度
taskAdjustTokenQuota(ctx, task, -quota) taskAdjustTokenQuota(ctx, task, -quota)
// 3. 记录日志 // 3. 回减预扣时累计的用户和渠道用量,请求次数保持不变
model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(task.ChannelId, -quota)
// 4. 记录日志
other := taskBillingOther(task) other := taskBillingOther(task)
other["task_id"] = task.TaskID other["task_id"] = task.TaskID
other["reason"] = reason other["reason"] = reason
@@ -194,7 +198,7 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool
Other: other, Other: other,
}) })
// 4. 资金退款完成后再清除持久化标记。 // 5. 资金退款完成后再清除持久化标记。
// 回写失败必须显式告警,避免漏掉潜在的重复退款风险。 // 回写失败必须显式告警,避免漏掉潜在的重复退款风险。
task.Quota = 0 task.Quota = 0
if err := task.UpdateQuota(); err != nil { if err := task.UpdateQuota(); err != nil {
@@ -242,13 +246,15 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
logger.LogError(ctx, fmt.Sprintf("差额结算回写 quota 失败 task %s: %s", task.TaskID, err.Error())) logger.LogError(ctx, fmt.Sprintf("差额结算回写 quota 失败 task %s: %s", task.TaskID, err.Error()))
} }
// 提交阶段已经累计过一次请求;结算阶段只调整最终用量。
model.UpdateUserUsedQuota(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
var logType int var logType int
var logQuota int var logQuota int
if quotaDelta > 0 { if quotaDelta > 0 {
logType = model.LogTypeConsume logType = model.LogTypeConsume
logQuota = quotaDelta logQuota = quotaDelta
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
} else { } else {
logType = model.LogTypeRefund logType = model.LogTypeRefund
logQuota = -quotaDelta logQuota = -quotaDelta
+382 -4
View File
@@ -45,6 +45,7 @@ func TestMain(m *testing.M) {
&model.Token{}, &model.Token{},
&model.Log{}, &model.Log{},
&model.Channel{}, &model.Channel{},
&model.Midjourney{},
&model.TopUp{}, &model.TopUp{},
&model.UserSubscription{}, &model.UserSubscription{},
&model.SystemTask{}, &model.SystemTask{},
@@ -68,6 +69,7 @@ func truncate(t *testing.T) {
model.DB.Exec("DELETE FROM tokens") model.DB.Exec("DELETE FROM tokens")
model.DB.Exec("DELETE FROM logs") model.DB.Exec("DELETE FROM logs")
model.DB.Exec("DELETE FROM channels") model.DB.Exec("DELETE FROM channels")
model.DB.Exec("DELETE FROM midjourneys")
model.DB.Exec("DELETE FROM top_ups") model.DB.Exec("DELETE FROM top_ups")
model.DB.Exec("DELETE FROM user_subscriptions") model.DB.Exec("DELETE FROM user_subscriptions")
model.DB.Exec("DELETE FROM system_task_locks") model.DB.Exec("DELETE FROM system_task_locks")
@@ -115,6 +117,20 @@ func seedChannel(t *testing.T, id int) {
require.NoError(t, model.DB.Create(ch).Error) require.NoError(t, model.DB.Create(ch).Error)
} }
func seedChargedAccounting(t *testing.T, userID, channelID, tokenID, quota, requestCount int) {
t.Helper()
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"used_quota": quota,
"request_count": requestCount,
}).Error)
require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", channelID).
Update("used_quota", quota).Error)
if tokenID > 0 {
require.NoError(t, model.DB.Model(&model.Token{}).Where("id = ?", tokenID).
Update("used_quota", quota).Error)
}
}
func makeTask(userId, channelId, quota, tokenId int, billingSource string, subscriptionId int) *model.Task { func makeTask(userId, channelId, quota, tokenId int, billingSource string, subscriptionId int) *model.Task {
return &model.Task{ return &model.Task{
TaskID: "task_" + time.Now().Format("150405.000"), TaskID: "task_" + time.Now().Format("150405.000"),
@@ -249,6 +265,20 @@ func getUserQuota(t *testing.T, id int) int {
return user.Quota return user.Quota
} }
func getUserUsageAccounting(t *testing.T, id int) (int, int) {
t.Helper()
var user model.User
require.NoError(t, model.DB.Select("used_quota", "request_count").Where("id = ?", id).First(&user).Error)
return user.UsedQuota, user.RequestCount
}
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 { func getTokenRemainQuota(t *testing.T, id int) int {
t.Helper() t.Helper()
var token model.Token var token model.Token
@@ -277,6 +307,13 @@ func getTaskQuota(t *testing.T, id int64) int {
return task.Quota return task.Quota
} }
func getMidjourneyTask(t *testing.T, id int) model.Midjourney {
t.Helper()
var task model.Midjourney
require.NoError(t, model.DB.First(&task, id).Error)
return task
}
func getLastLog(t *testing.T) *model.Log { func getLastLog(t *testing.T) *model.Log {
t.Helper() t.Helper()
var log model.Log var log model.Log
@@ -294,6 +331,291 @@ func countLogs(t *testing.T) int64 {
return count return count
} }
// ===========================================================================
// Legacy Midjourney billing tests
// ===========================================================================
func TestPrepareMidjourneyTaskBillingKeepsUnbilledMarkerClear(t *testing.T) {
task := &model.Midjourney{Quota: 900, TokenId: 7, BillingChannelId: 8}
prepared, err := PrepareMidjourneyTaskBilling(&relaycommon.RelayInfo{}, task, 900, false)
require.NoError(t, err)
assert.False(t, prepared)
assert.Zero(t, task.Quota)
assert.Zero(t, task.TokenId)
assert.Zero(t, task.BillingChannelId)
}
func TestSettleMidjourneyTaskBillingRequiresPersistedTask(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 49, 49, 49
const initialUserQuota, initialTokenQuota, chargedQuota = 10000, 5000, 3000
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-midjourney-unpersisted", initialTokenQuota)
seedChannel(t, channelID)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
TokenId: tokenID,
TokenKey: "sk-midjourney-unpersisted",
UserQuota: initialUserQuota,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelId: channelID,
},
}
task := &model.Midjourney{UserId: userID, ChannelId: channelID}
prepared, err := PrepareMidjourneyTaskBilling(relayInfo, task, chargedQuota, true)
require.NoError(t, err)
require.True(t, prepared)
billed, err := SettleMidjourneyTaskBilling(relayInfo, task, prepared)
require.Error(t, err)
assert.False(t, billed)
assert.Equal(t, initialUserQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota, getTokenRemainQuota(t, tokenID))
}
func TestMidjourneyRefundRestoresEveryAccountingElementOnBillingChannel(t *testing.T) {
truncate(t)
ctx := context.Background()
const userID, tokenID, billingChannelID, executionChannelID = 50, 50, 50, 51
const initialUserQuota, initialTokenQuota, chargedQuota = 10000, 5000, 3000
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-midjourney", initialTokenQuota)
seedChannel(t, billingChannelID)
seedChannel(t, executionChannelID)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
TokenId: tokenID,
TokenKey: "sk-midjourney",
UserQuota: initialUserQuota,
UsingGroup: "default",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelId: billingChannelID,
},
}
task := &model.Midjourney{
UserId: userID,
Action: "IMAGINE",
MjId: "mj-accounting-refund",
ChannelId: executionChannelID,
Progress: "0%",
}
prepared, err := PrepareMidjourneyTaskBilling(relayInfo, task, chargedQuota, true)
require.NoError(t, err)
require.True(t, prepared)
assert.Equal(t, chargedQuota, task.Quota)
assert.Zero(t, task.TokenId)
assert.Equal(t, billingChannelID, task.BillingChannelId)
require.NoError(t, task.Insert())
billed, err := SettleMidjourneyTaskBilling(relayInfo, task, prepared)
require.NoError(t, err)
require.True(t, billed)
assert.Equal(t, initialUserQuota-chargedQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota-chargedQuota, getTokenRemainQuota(t, tokenID))
persisted := getMidjourneyTask(t, task.Id)
assert.Equal(t, chargedQuota, persisted.Quota)
assert.Equal(t, tokenID, persisted.TokenId)
assert.Equal(t, billingChannelID, persisted.BillingChannelId)
seedChargedAccounting(t, userID, billingChannelID, tokenID, chargedQuota, 1)
assert.True(t, RefundMidjourneyQuota(ctx, task, "构图失败"))
assert.Equal(t, initialUserQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota, getTokenRemainQuota(t, tokenID))
assert.Zero(t, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, billingChannelID))
assert.Zero(t, getChannelUsedQuota(t, executionChannelID))
persisted = getMidjourneyTask(t, task.Id)
assert.Zero(t, persisted.Quota)
assert.Equal(t, tokenID, persisted.TokenId)
assert.Equal(t, billingChannelID, persisted.BillingChannelId)
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, model.LogTypeRefund, log.Type)
assert.Equal(t, chargedQuota, log.Quota)
assert.Equal(t, tokenID, log.TokenId)
assert.Equal(t, billingChannelID, log.ChannelId)
assert.True(t, RefundMidjourneyQuota(ctx, task, "duplicate poll"))
assert.Equal(t, int64(1), countLogs(t))
}
func TestSettleMidjourneyTaskBillingFundingFailureClearsMarkers(t *testing.T) {
truncate(t)
const userID, tokenID, channelID = 52, 52, 52
const initialUserQuota, initialTokenQuota, chargedQuota = 10000, 5000, 3000
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-midjourney-funding-failure", initialTokenQuota)
seedChannel(t, channelID)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
TokenId: tokenID,
TokenKey: "sk-midjourney-funding-failure",
UserQuota: initialUserQuota,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelId: channelID,
},
}
task := &model.Midjourney{UserId: userID, MjId: "mj-funding-failure", ChannelId: channelID}
prepared, err := PrepareMidjourneyTaskBilling(relayInfo, task, chargedQuota, true)
require.NoError(t, err)
require.True(t, prepared)
require.NoError(t, task.Insert())
require.NoError(t, model.DB.Exec(`
CREATE TRIGGER fail_midjourney_user_update
BEFORE UPDATE ON users
WHEN OLD.id = 52
BEGIN
SELECT RAISE(ABORT, 'forced user quota failure');
END;
`).Error)
t.Cleanup(func() {
model.DB.Exec("DROP TRIGGER IF EXISTS fail_midjourney_user_update")
})
billed, err := SettleMidjourneyTaskBilling(relayInfo, task, prepared)
require.Error(t, err)
assert.False(t, billed)
assert.Equal(t, initialUserQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota, getTokenRemainQuota(t, tokenID))
persisted := getMidjourneyTask(t, task.Id)
assert.Zero(t, persisted.Quota)
assert.Zero(t, persisted.TokenId)
assert.Zero(t, persisted.BillingChannelId)
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Zero(t, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
assert.Zero(t, countLogs(t))
}
func TestSettleMidjourneyTaskBillingTokenFailureKeepsFundingRefundable(t *testing.T) {
truncate(t)
ctx := context.Background()
const userID, tokenID, channelID = 53, 53, 53
const initialUserQuota, initialTokenQuota, chargedQuota = 10000, 5000, 3000
seedUser(t, userID, initialUserQuota)
seedToken(t, tokenID, userID, "sk-midjourney-token-failure", initialTokenQuota)
seedChannel(t, channelID)
relayInfo := &relaycommon.RelayInfo{
UserId: userID,
TokenId: tokenID,
TokenKey: "sk-midjourney-token-failure",
UserQuota: initialUserQuota,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelId: channelID,
},
}
task := &model.Midjourney{UserId: userID, MjId: "mj-token-failure", ChannelId: channelID}
prepared, err := PrepareMidjourneyTaskBilling(relayInfo, task, chargedQuota, true)
require.NoError(t, err)
require.True(t, prepared)
require.NoError(t, task.Insert())
require.NoError(t, model.DB.Exec(`
CREATE TRIGGER fail_midjourney_token_update
BEFORE UPDATE ON tokens
WHEN OLD.id = 53
BEGIN
SELECT RAISE(ABORT, 'forced token quota failure');
END;
`).Error)
t.Cleanup(func() {
model.DB.Exec("DROP TRIGGER IF EXISTS fail_midjourney_token_update")
})
billed, err := SettleMidjourneyTaskBilling(relayInfo, task, prepared)
require.Error(t, err)
require.True(t, billed)
assert.Equal(t, initialUserQuota-chargedQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota, getTokenRemainQuota(t, tokenID))
assert.Zero(t, getTokenUsedQuota(t, tokenID))
persisted := getMidjourneyTask(t, task.Id)
assert.Equal(t, chargedQuota, persisted.Quota)
assert.Zero(t, persisted.TokenId)
assert.Equal(t, channelID, persisted.BillingChannelId)
seedChargedAccounting(t, userID, channelID, 0, chargedQuota, 1)
assert.True(t, RefundMidjourneyQuota(ctx, task, "token settlement failed"))
assert.Equal(t, initialUserQuota, getUserQuota(t, userID))
assert.Equal(t, initialTokenQuota, getTokenRemainQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Zero(t, log.TokenId)
}
func TestPrepareMidjourneyTaskBillingRejectsSubscriptionBeforeCharge(t *testing.T) {
task := &model.Midjourney{Quota: 900, TokenId: 7, BillingChannelId: 8}
relayInfo := &relaycommon.RelayInfo{BillingSource: BillingSourceSubscription, SubscriptionId: 1}
prepared, err := PrepareMidjourneyTaskBilling(relayInfo, task, 900, true)
require.Error(t, err)
assert.False(t, prepared)
assert.Zero(t, task.Quota)
assert.Zero(t, task.TokenId)
assert.Zero(t, task.BillingChannelId)
}
func TestRefundMidjourneyQuotaUsesLegacyChannelFallbackWithoutTokenAdjustment(t *testing.T) {
truncate(t)
ctx := context.Background()
const userID, tokenID, channelID = 54, 54, 54
const walletAfterCharge, tokenQuota, chargedQuota = 7000, 5000, 3000
seedUser(t, userID, walletAfterCharge)
seedToken(t, tokenID, userID, "sk-midjourney-legacy", tokenQuota)
seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, 0, chargedQuota, 1)
task := &model.Midjourney{
UserId: userID,
MjId: "mj-legacy-fallback",
Action: "IMAGINE",
ChannelId: channelID,
Quota: chargedQuota,
TokenId: 0,
Progress: "0%",
}
require.NoError(t, task.Insert())
assert.True(t, RefundMidjourneyQuota(ctx, task, "legacy failure"))
assert.Equal(t, walletAfterCharge+chargedQuota, getUserQuota(t, userID))
assert.Equal(t, tokenQuota, getTokenRemainQuota(t, tokenID))
assert.Zero(t, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
log := getLastLog(t)
require.NotNil(t, log)
assert.Equal(t, channelID, log.ChannelId)
assert.Zero(t, log.TokenId)
}
// =========================================================================== // ===========================================================================
// RefundTaskQuota tests // RefundTaskQuota tests
// =========================================================================== // ===========================================================================
@@ -309,6 +631,7 @@ func TestRefundTaskQuota_Wallet(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-test-key", tokenRemain) seedToken(t, tokenID, userID, "sk-test-key", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
require.NoError(t, model.DB.Create(task).Error) require.NoError(t, model.DB.Create(task).Error)
@@ -320,7 +643,11 @@ func TestRefundTaskQuota_Wallet(t *testing.T) {
// Token remain_quota should increase, used_quota should decrease // Token remain_quota should increase, used_quota should decrease
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
assert.Equal(t, -preConsumed, getTokenUsedQuota(t, tokenID)) assert.Zero(t, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
// A refund log should be created // A refund log should be created
log := getLastLog(t) log := getLastLog(t)
@@ -345,6 +672,7 @@ func TestRefundTaskQuota_Subscription(t *testing.T) {
seedToken(t, tokenID, userID, "sk-sub-key", tokenRemain) seedToken(t, tokenID, userID, "sk-sub-key", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedSubscription(t, subID, userID, subTotal, subUsed) seedSubscription(t, subID, userID, subTotal, subUsed)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
require.NoError(t, model.DB.Create(task).Error) require.NoError(t, model.DB.Create(task).Error)
@@ -356,6 +684,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) {
// Token should also be refunded // Token should also be refunded
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
assert.Zero(t, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
log := getLastLog(t) log := getLastLog(t)
require.NotNil(t, log) require.NotNil(t, log)
@@ -390,6 +723,7 @@ func TestRefundTaskQuota_NoToken(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, 0, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0) // TokenId=0 task := makeTask(userID, channelID, preConsumed, 0, BillingSourceWallet, 0) // TokenId=0
require.NoError(t, model.DB.Create(task).Error) require.NoError(t, model.DB.Create(task).Error)
@@ -398,6 +732,10 @@ func TestRefundTaskQuota_NoToken(t *testing.T) {
// User quota refunded // User quota refunded
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID)) assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
// Log created // Log created
log := getLastLog(t) log := getLastLog(t)
@@ -406,19 +744,26 @@ func TestRefundTaskQuota_NoToken(t *testing.T) {
assert.Zero(t, getTaskQuota(t, task.ID)) assert.Zero(t, getTaskQuota(t, task.ID))
} }
func TestRefundTaskQuota_FundingFailureKeepsPendingMarker(t *testing.T) { func TestRefundTaskQuota_FundingFailureKeepsAccountingAndPendingMarker(t *testing.T) {
truncate(t) truncate(t)
ctx := context.Background() ctx := context.Background()
const userID, preConsumed = 5, 1200 const userID, channelID, preConsumed = 5, 5, 1200
seedUser(t, userID, 5000) seedUser(t, userID, 5000)
task := makeTask(userID, 0, preConsumed, 0, BillingSourceSubscription, 9999) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, 0, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, 0, BillingSourceSubscription, 9999)
task.Status = model.TaskStatusFailure task.Status = model.TaskStatusFailure
require.NoError(t, model.DB.Create(task).Error) require.NoError(t, model.DB.Create(task).Error)
assert.False(t, RefundTaskQuota(ctx, task, "subscription missing")) assert.False(t, RefundTaskQuota(ctx, task, "subscription missing"))
assert.Equal(t, 5000, getUserQuota(t, userID))
assert.Equal(t, preConsumed, task.Quota) assert.Equal(t, preConsumed, task.Quota)
assert.Equal(t, preConsumed, getTaskQuota(t, task.ID)) assert.Equal(t, preConsumed, getTaskQuota(t, task.ID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, preConsumed, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(preConsumed), getChannelUsedQuota(t, channelID))
assert.Equal(t, int64(0), countLogs(t)) assert.Equal(t, int64(0), countLogs(t))
} }
@@ -438,6 +783,7 @@ func TestRecalculate_PositiveDelta(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-recalc-pos", tokenRemain) seedToken(t, tokenID, userID, "sk-recalc-pos", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
@@ -448,6 +794,11 @@ func TestRecalculate_PositiveDelta(t *testing.T) {
// Token should also be charged the delta // Token should also be charged the delta
assert.Equal(t, tokenRemain-(actualQuota-preConsumed), getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain-(actualQuota-preConsumed), getTokenRemainQuota(t, tokenID))
assert.Equal(t, actualQuota, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, actualQuota, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(actualQuota), getChannelUsedQuota(t, channelID))
// task.Quota should be updated to actualQuota // task.Quota should be updated to actualQuota
assert.Equal(t, actualQuota, task.Quota) assert.Equal(t, actualQuota, task.Quota)
@@ -471,6 +822,7 @@ func TestRecalculate_NegativeDelta(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain) seedToken(t, tokenID, userID, "sk-recalc-neg", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
@@ -481,6 +833,11 @@ func TestRecalculate_NegativeDelta(t *testing.T) {
// Token should be refunded the difference // Token should be refunded the difference
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, actualQuota, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, actualQuota, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(actualQuota), getChannelUsedQuota(t, channelID))
// task.Quota updated // task.Quota updated
assert.Equal(t, actualQuota, task.Quota) assert.Equal(t, actualQuota, task.Quota)
@@ -544,6 +901,7 @@ func TestRecalculate_Subscription_NegativeDelta(t *testing.T) {
seedToken(t, tokenID, userID, "sk-sub-recalc", tokenRemain) seedToken(t, tokenID, userID, "sk-sub-recalc", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedSubscription(t, subID, userID, subTotal, subUsed) seedSubscription(t, subID, userID, subTotal, subUsed)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceSubscription, subID)
@@ -554,6 +912,11 @@ func TestRecalculate_Subscription_NegativeDelta(t *testing.T) {
// Token refunded // Token refunded
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, actualQuota, getTokenUsedQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, actualQuota, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(actualQuota), getChannelUsedQuota(t, channelID))
assert.Equal(t, actualQuota, task.Quota) assert.Equal(t, actualQuota, task.Quota)
@@ -627,6 +990,7 @@ func TestCASGuardedRefund_Win(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-cas-refund-win", tokenRemain) seedToken(t, tokenID, userID, "sk-cas-refund-win", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.Status = model.TaskStatus(model.TaskStatusInProgress) task.Status = model.TaskStatus(model.TaskStatusInProgress)
@@ -643,6 +1007,10 @@ func TestCASGuardedRefund_Win(t *testing.T) {
// Refund should have happened // Refund should have happened
assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID)) assert.Equal(t, initQuota+preConsumed, getUserQuota(t, userID))
assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+preConsumed, getTokenRemainQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Zero(t, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Zero(t, getChannelUsedQuota(t, channelID))
log := getLastLog(t) log := getLastLog(t)
require.NotNil(t, log) require.NotNil(t, log)
@@ -660,6 +1028,7 @@ func TestCASGuardedRefund_Lose(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-cas-refund-lose", tokenRemain) seedToken(t, tokenID, userID, "sk-cas-refund-lose", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
// Create task with IN_PROGRESS in DB // Create task with IN_PROGRESS in DB
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
@@ -676,6 +1045,10 @@ func TestCASGuardedRefund_Lose(t *testing.T) {
// CAS lost: user quota should NOT change (no double refund) // CAS lost: user quota should NOT change (no double refund)
assert.Equal(t, initQuota, getUserQuota(t, userID)) assert.Equal(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, preConsumed, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(preConsumed), getChannelUsedQuota(t, channelID))
// No billing log should be created // No billing log should be created
assert.Equal(t, int64(0), countLogs(t)) assert.Equal(t, int64(0), countLogs(t))
@@ -693,6 +1066,7 @@ func TestCASGuardedSettle_Win(t *testing.T) {
seedUser(t, userID, initQuota) seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-cas-settle-win", tokenRemain) seedToken(t, tokenID, userID, "sk-cas-settle-win", tokenRemain)
seedChannel(t, channelID) seedChannel(t, channelID)
seedChargedAccounting(t, userID, channelID, tokenID, preConsumed, 1)
task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0) task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.Status = model.TaskStatus(model.TaskStatusInProgress) task.Status = model.TaskStatus(model.TaskStatusInProgress)
@@ -708,6 +1082,10 @@ func TestCASGuardedSettle_Win(t *testing.T) {
// Settlement should refund the over-charge (5000 - 3000 = 2000 back to user) // Settlement should refund the over-charge (5000 - 3000 = 2000 back to user)
assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID)) assert.Equal(t, initQuota+(preConsumed-actualQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID)) assert.Equal(t, tokenRemain+(preConsumed-actualQuota), getTokenRemainQuota(t, tokenID))
usedQuota, requestCount := getUserUsageAccounting(t, userID)
assert.Equal(t, actualQuota, usedQuota)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(actualQuota), getChannelUsedQuota(t, channelID))
// task.Quota should be updated to actualQuota // task.Quota should be updated to actualQuota
assert.Equal(t, actualQuota, task.Quota) assert.Equal(t, actualQuota, task.Quota)