Commit 58d4e9bd by wans10 Committed by GitHub

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>
parent ccd535ef
...@@ -213,22 +213,7 @@ func runMidjourneyTaskUpdateOnce(ctx context.Context, report func(processed, tot ...@@ -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": "构图失败",
},
})
} }
} }
} }
......
...@@ -23,6 +23,9 @@ type Midjourney struct { ...@@ -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 { ...@@ -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.
......
...@@ -1353,6 +1353,17 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { ...@@ -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{}{
......
...@@ -89,6 +89,61 @@ func TestUserUpdateDoesNotOverwriteConcurrentAccountingOrTokenChanges(t *testing ...@@ -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)
......
...@@ -232,30 +232,6 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR ...@@ -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 ...@@ -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 ...@@ -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,操作 %s,ID %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 ...@@ -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 ...@@ -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 ...@@ -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,操作 %s,ID %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-排队中,说明任务已存在
//修改返回值 //修改返回值
......
...@@ -3,6 +3,8 @@ package service ...@@ -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 ( ...@@ -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 { ...@@ -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 {
......
...@@ -406,17 +406,27 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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) {
......
...@@ -161,7 +161,7 @@ func taskModelName(task *model.Task) string { ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment