829 lines
27 KiB
Go
829 lines
27 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math"
|
||
"strings"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/constant"
|
||
"github.com/QuantumNous/new-api/logger"
|
||
"github.com/QuantumNous/new-api/model"
|
||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// LogTaskConsumption 记录任务消费日志和统计信息(仅记录,不涉及实际扣费)。
|
||
// 实际扣费已由 BillingSession(PreConsumeBilling + SettleBilling)完成。
|
||
func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
|
||
tokenName := c.GetString("token_name")
|
||
logContent := fmt.Sprintf("操作 %s", info.Action)
|
||
|
||
// 视频按 token 计费分支:任务型视频渠道 + UsePrice + ModelPrice=0 + VideoOutputTokens>0。
|
||
// 该分支下 quota 已由 outputVideoTokens × ratios × group 直接算出,
|
||
// OtherRatios 的 seconds/size 不参与计费(已在 relay_task.go 步骤 5/6 跳过),
|
||
// 因此 logContent 应展示真实公式而非 "计算参数:seconds, size"。
|
||
isVideoTokenBilling := constant.IsVideoTaskChannel(info.ChannelType) &&
|
||
info.PriceData.UsePrice &&
|
||
info.PriceData.ModelPrice == 0 &&
|
||
info.PriceData.VideoOutputTokens > 0
|
||
|
||
// 视频按分辨率/条一口价(*_per_video):ModelPriceHelperVideo 将 ModelRatio 置 0、
|
||
// VideoOutputTokens 为 0,预扣已在 relay 中按条合并,不应再展示为「按次 $0」或 seconds 倍率文案。
|
||
isVideoPerVideoFlatBilling := constant.IsVideoTaskChannel(info.ChannelType) &&
|
||
info.PriceData.UsePrice &&
|
||
info.PriceData.ModelPrice == 0 &&
|
||
info.PriceData.VideoOutputTokens == 0 &&
|
||
info.PriceData.ModelRatio == 0
|
||
isVideoPerSecondBilling := isVideoPerVideoFlatBilling &&
|
||
info.PriceData.OtherRatios != nil &&
|
||
info.PriceData.OtherRatios["seconds"] > 0
|
||
var videoPerSecondDetail *videoPerSecondBillingDetail
|
||
if isVideoPerSecondBilling {
|
||
videoPerSecondDetail = videoPerSecondBillingDetailFromSubmit(c, info)
|
||
}
|
||
|
||
switch {
|
||
case common.StringsContains(constant.TaskPricePatches, info.OriginModelName):
|
||
logContent = fmt.Sprintf("%s,按次计费", logContent)
|
||
case isVideoTokenBilling:
|
||
// 例:操作 generate, 视频 tokens:86400 (输入文本 13), 模型倍率 15.00, 视频倍率 1.00 × 1.00
|
||
logContent = fmt.Sprintf(
|
||
"%s, 视频 tokens:%d (输入文本 %d), 模型倍率 %.2f, 视频倍率 %.2f × %.2f",
|
||
logContent,
|
||
info.PriceData.VideoOutputTokens,
|
||
info.PriceData.VideoInputTextTokens,
|
||
info.PriceData.ModelRatio,
|
||
info.PriceData.VideoRatio,
|
||
info.PriceData.VideoCompletionRatio,
|
||
)
|
||
case isVideoPerSecondBilling:
|
||
logContent = formatVideoPerSecondBillingDetail(logContent+",视频按秒计费", videoPerSecondDetail, info.PriceData.Quota)
|
||
case isVideoPerVideoFlatBilling:
|
||
logContent = fmt.Sprintf("%s,按视频数量计费", logContent)
|
||
default:
|
||
if len(info.PriceData.OtherRatios) > 0 {
|
||
var contents []string
|
||
for key, ra := range info.PriceData.OtherRatios {
|
||
if 1.0 != ra {
|
||
contents = append(contents, fmt.Sprintf("%s: %.2f", key, ra))
|
||
}
|
||
}
|
||
if len(contents) > 0 {
|
||
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
|
||
}
|
||
}
|
||
}
|
||
|
||
other := make(map[string]interface{})
|
||
other["request_path"] = c.Request.URL.Path
|
||
if strings.TrimSpace(info.PublicTaskID) != "" {
|
||
other["task_id"] = strings.TrimSpace(info.PublicTaskID)
|
||
}
|
||
other["model_price"] = info.PriceData.ModelPrice
|
||
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
|
||
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
|
||
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
|
||
}
|
||
if info.IsModelMapped {
|
||
other["is_model_mapped"] = true
|
||
other["upstream_model_name"] = info.UpstreamModelName
|
||
}
|
||
// 视频按 token 计费:写入完整计费元数据,供前端日志详情按 token 公式展示。
|
||
if isVideoTokenBilling {
|
||
other["billing_mode"] = "video_token"
|
||
other["model_ratio"] = info.PriceData.ModelRatio
|
||
other["video_ratio"] = info.PriceData.VideoRatio
|
||
other["video_completion_ratio"] = info.PriceData.VideoCompletionRatio
|
||
other["video_output_tokens"] = info.PriceData.VideoOutputTokens
|
||
other["video_input_text_tokens"] = info.PriceData.VideoInputTextTokens
|
||
}
|
||
if isVideoPerSecondBilling {
|
||
other["billing_mode"] = "video_per_second"
|
||
other["model_ratio"] = info.PriceData.ModelRatio
|
||
appendVideoPerSecondBillingDetailOther(other, videoPerSecondDetail, info.PriceData.Quota)
|
||
} else if isVideoPerVideoFlatBilling {
|
||
other["billing_mode"] = "video_per_video"
|
||
other["model_ratio"] = info.PriceData.ModelRatio
|
||
appendVideoPerVideoBillingDetailOther(c, other, info)
|
||
}
|
||
discPct := float64(100)
|
||
if info.PriceData.ChannelPriceDiscount != nil {
|
||
discPct = *info.PriceData.ChannelPriceDiscount
|
||
} else {
|
||
discPct = model.ResolveChannelPriceDiscountPercent(info.ChannelId)
|
||
}
|
||
other["channel_price_discount_percent"] = discPct
|
||
model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{
|
||
ChannelId: info.ChannelId,
|
||
ModelName: info.OriginModelName,
|
||
TokenName: tokenName,
|
||
Quota: info.PriceData.Quota,
|
||
Content: logContent,
|
||
TokenId: info.TokenId,
|
||
Group: info.UsingGroup,
|
||
Other: other,
|
||
})
|
||
model.UpdateUserUsedQuotaAndRequestCount(info.UserId, info.PriceData.Quota)
|
||
model.UpdateChannelUsedQuota(info.ChannelId, info.PriceData.Quota)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 异步任务计费辅助函数
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// resolveTokenKey 通过 TokenId 运行时获取令牌 Key(用于 Redis 缓存操作)。
|
||
// 如果令牌已被删除或查询失败,返回空字符串。
|
||
func resolveTokenKey(ctx context.Context, tokenId int, taskID string) string {
|
||
token, err := model.GetTokenById(tokenId)
|
||
if err != nil {
|
||
logger.LogWarn(ctx, fmt.Sprintf("获取令牌 key 失败 (tokenId=%d, task=%s): %s", tokenId, taskID, err.Error()))
|
||
return ""
|
||
}
|
||
return token.Key
|
||
}
|
||
|
||
// taskIsSubscription 判断任务是否通过订阅计费。
|
||
func taskIsSubscription(task *model.Task) bool {
|
||
return task.PrivateData.BillingSource == BillingSourceSubscription && task.PrivateData.SubscriptionId > 0
|
||
}
|
||
|
||
// taskAdjustFunding 调整任务的资金来源(钱包或订阅),delta > 0 表示扣费,delta < 0 表示退还。
|
||
func taskAdjustFunding(task *model.Task, delta int) error {
|
||
if taskIsSubscription(task) {
|
||
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
|
||
}
|
||
if delta > 0 {
|
||
return model.DecreaseUserQuota(task.UserId, delta)
|
||
}
|
||
return model.IncreaseUserQuota(task.UserId, -delta, false)
|
||
}
|
||
|
||
// taskAdjustTokenQuota 调整任务的令牌额度,delta > 0 表示扣费,delta < 0 表示退还。
|
||
// 需要通过 resolveTokenKey 运行时获取 key(不从 PrivateData 中读取)。
|
||
func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
|
||
if task.PrivateData.TokenId <= 0 || delta == 0 {
|
||
return
|
||
}
|
||
tokenKey := resolveTokenKey(ctx, task.PrivateData.TokenId, task.TaskID)
|
||
if tokenKey == "" {
|
||
return
|
||
}
|
||
var err error
|
||
if delta > 0 {
|
||
err = model.DecreaseTokenQuota(task.PrivateData.TokenId, tokenKey, delta)
|
||
} else {
|
||
err = model.IncreaseTokenQuota(task.PrivateData.TokenId, tokenKey, -delta)
|
||
}
|
||
if err != nil {
|
||
logger.LogWarn(ctx, fmt.Sprintf("调整令牌额度失败 (delta=%d, task=%s): %s", delta, task.TaskID, err.Error()))
|
||
}
|
||
}
|
||
|
||
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
|
||
func taskBillingOther(task *model.Task) map[string]interface{} {
|
||
other := make(map[string]interface{})
|
||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||
other["model_price"] = bc.ModelPrice
|
||
other["model_ratio"] = bc.ModelRatio
|
||
other["group_ratio"] = bc.GroupRatio
|
||
if len(bc.OtherRatios) > 0 {
|
||
for k, v := range bc.OtherRatios {
|
||
other[k] = v
|
||
}
|
||
}
|
||
// 任务差额日志补全视频计费模式,避免前端误判为“上游返回”并渲染 NaN。
|
||
if bc.ModelPrice == 0 && bc.ModelRatio == 0 {
|
||
if secs, ok := bc.OtherRatios["seconds"]; ok && secs > 0 {
|
||
other["billing_mode"] = "video_per_second"
|
||
}
|
||
}
|
||
}
|
||
props := task.Properties
|
||
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
|
||
other["is_model_mapped"] = true
|
||
other["upstream_model_name"] = props.UpstreamModelName
|
||
}
|
||
discPct := float64(0)
|
||
if bc := task.PrivateData.BillingContext; bc != nil && bc.ChannelPriceDiscountPercent > 0 {
|
||
discPct = bc.ChannelPriceDiscountPercent
|
||
}
|
||
if discPct <= 0 && task.ChannelId > 0 {
|
||
discPct = model.ResolveChannelPriceDiscountPercent(task.ChannelId)
|
||
}
|
||
if discPct <= 0 {
|
||
discPct = 100
|
||
}
|
||
other["channel_price_discount_percent"] = discPct
|
||
return other
|
||
}
|
||
|
||
func videoPerSecondBillingDetailFromSubmit(c *gin.Context, info *relaycommon.RelayInfo) *videoPerSecondBillingDetail {
|
||
if c == nil || info == nil {
|
||
return nil
|
||
}
|
||
req, err := relaycommon.GetTaskRequest(c)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
modelName := strings.TrimSpace(info.OriginModelName)
|
||
if modelName == "" {
|
||
return nil
|
||
}
|
||
rules, ok := ratio_setting.GetChannelVideoPricingRules(info.ChannelId, modelName)
|
||
if !ok || !ratio_setting.HasUsableVideoPerSecondRules(rules) {
|
||
var globalOK bool
|
||
rules, globalOK = ratio_setting.GetVideoPricingRules(modelName)
|
||
if !globalOK || !ratio_setting.HasUsableVideoPerSecondRules(rules) {
|
||
return nil
|
||
}
|
||
}
|
||
width, height := videoDimensionsFromTaskRequest(req)
|
||
hasAudio := taskRequestHasAudio(req)
|
||
mode := detectVideoBillingModeFromSubmitRequest(c)
|
||
match, ok := matchPerSecondPriceDetail(rules, mode, width, height, hasAudio)
|
||
if !ok || match.PricePerSecond <= 0 {
|
||
return nil
|
||
}
|
||
seconds := videoDurationFromTaskRequest(req)
|
||
if seconds <= 0 {
|
||
seconds = int(info.PriceData.OtherRatios["seconds"])
|
||
}
|
||
if seconds <= 0 {
|
||
return nil
|
||
}
|
||
groupRatio := info.PriceData.GroupRatioInfo.GroupRatio
|
||
if groupRatio <= 0 {
|
||
groupRatio = 1
|
||
}
|
||
return &videoPerSecondBillingDetail{
|
||
Mode: mode,
|
||
Seconds: seconds,
|
||
Width: width,
|
||
Height: height,
|
||
HasAudio: hasAudio,
|
||
Resolution: match.Resolution,
|
||
RuleWidth: match.RuleWidth,
|
||
RuleHeight: match.RuleHeight,
|
||
PricePerSecond: match.PricePerSecond,
|
||
GroupRatio: groupRatio,
|
||
QuotaPerUnit: common.QuotaPerUnit,
|
||
ChannelDiscountPercent: resolveVideoLogChannelDiscountPercent(info),
|
||
UnifiedAudio: match.UnifiedAudio,
|
||
}
|
||
}
|
||
|
||
func resolveVideoLogChannelDiscountPercent(info *relaycommon.RelayInfo) float64 {
|
||
if info != nil && info.PriceData.ChannelPriceDiscount != nil {
|
||
return *info.PriceData.ChannelPriceDiscount
|
||
}
|
||
if info != nil {
|
||
return model.ResolveChannelPriceDiscountPercent(info.ChannelId)
|
||
}
|
||
return 100
|
||
}
|
||
|
||
func videoDurationFromTaskRequest(req relaycommon.TaskSubmitReq) int {
|
||
if req.Metadata != nil {
|
||
if d := toInt(req.Metadata["duration"]); d > 0 {
|
||
return d
|
||
}
|
||
}
|
||
if strings.TrimSpace(req.Seconds) != "" {
|
||
if f := toFloat64(req.Seconds); f > 0 {
|
||
return int(math.Ceil(f))
|
||
}
|
||
}
|
||
if req.Duration > 0 {
|
||
return req.Duration
|
||
}
|
||
return 5
|
||
}
|
||
|
||
func videoDimensionsFromTaskRequest(req relaycommon.TaskSubmitReq) (int, int) {
|
||
if size := strings.TrimSpace(req.Size); size != "" {
|
||
parts := strings.Split(strings.ToLower(size), "x")
|
||
if len(parts) == 2 {
|
||
w := toInt(parts[0])
|
||
h := toInt(parts[1])
|
||
if w > 0 && h > 0 {
|
||
return w, h
|
||
}
|
||
}
|
||
}
|
||
if req.Metadata != nil {
|
||
w := toInt(req.Metadata["width"])
|
||
h := toInt(req.Metadata["height"])
|
||
if w > 0 && h > 0 {
|
||
return w, h
|
||
}
|
||
}
|
||
return 720, 1280
|
||
}
|
||
|
||
func taskRequestHasAudio(req relaycommon.TaskSubmitReq) bool {
|
||
if req.Metadata == nil {
|
||
return false
|
||
}
|
||
for _, key := range []string{"has_audio", "generate_audio"} {
|
||
if v, ok := req.Metadata[key]; ok {
|
||
switch x := v.(type) {
|
||
case bool:
|
||
return x
|
||
case string:
|
||
return strings.EqualFold(strings.TrimSpace(x), "true")
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func formatVideoPerSecondBillingDetail(prefix string, detail *videoPerSecondBillingDetail, quota int) string {
|
||
if detail == nil {
|
||
return fmt.Sprintf("%s(按最终成片时长向上取整 × 对应分辨率/音轨单价)", prefix)
|
||
}
|
||
priceLabel := "每秒价"
|
||
if !detail.UnifiedAudio {
|
||
if detail.HasAudio {
|
||
priceLabel = "有音轨价"
|
||
} else {
|
||
priceLabel = "无音轨价"
|
||
}
|
||
}
|
||
resolution := strings.TrimSpace(detail.Resolution)
|
||
if resolution == "" {
|
||
resolution = fmt.Sprintf("%dx%d", detail.RuleWidth, detail.RuleHeight)
|
||
}
|
||
pricePerSec := detail.PricePerSecond
|
||
if detail.EffectivePricePerSecond > 0 {
|
||
pricePerSec = detail.EffectivePricePerSecond
|
||
}
|
||
return fmt.Sprintf(
|
||
"%s:%d秒 × %s(%dx%d,实际 %dx%d,%s) %s $%g/秒(渠道$%g+全局$%g×加价%.0f%%) × QuotaPerUnit %.0f × 分组倍率 %.4g × 渠道折扣 %.4g%% = %d tokens",
|
||
prefix,
|
||
detail.Seconds,
|
||
resolution,
|
||
detail.RuleWidth,
|
||
detail.RuleHeight,
|
||
detail.Width,
|
||
detail.Height,
|
||
audioLabel(detail.HasAudio),
|
||
priceLabel,
|
||
pricePerSec,
|
||
detail.PricePerSecond,
|
||
detail.GlobalPricePerSecond,
|
||
detail.MarkupDiscountPercent,
|
||
detail.QuotaPerUnit,
|
||
detail.GroupRatio,
|
||
videoChannelDiscountPercent(detail),
|
||
quota,
|
||
)
|
||
}
|
||
|
||
func videoChannelDiscountPercent(detail *videoPerSecondBillingDetail) float64 {
|
||
if detail == nil || detail.ChannelDiscountPercent <= 0 {
|
||
return 100
|
||
}
|
||
return detail.ChannelDiscountPercent
|
||
}
|
||
|
||
func appendVideoPerSecondBillingDetailOther(other map[string]interface{}, detail *videoPerSecondBillingDetail, quota int) {
|
||
if other == nil || detail == nil {
|
||
return
|
||
}
|
||
other["video_seconds"] = detail.Seconds
|
||
other["video_width"] = detail.Width
|
||
other["video_height"] = detail.Height
|
||
other["video_has_audio"] = detail.HasAudio
|
||
other["video_resolution"] = detail.Resolution
|
||
other["video_rule_width"] = detail.RuleWidth
|
||
other["video_rule_height"] = detail.RuleHeight
|
||
other["video_price_per_second"] = detail.PricePerSecond
|
||
if detail.GlobalPricePerSecond > 0 {
|
||
other["global_video_price_per_second"] = detail.GlobalPricePerSecond
|
||
}
|
||
if detail.EffectivePricePerSecond > 0 {
|
||
other["effective_video_price_per_second"] = detail.EffectivePricePerSecond
|
||
}
|
||
if detail.MarkupDiscountPercent > 0 {
|
||
other["markup_discount_rate"] = detail.MarkupDiscountPercent
|
||
}
|
||
other["video_quota_per_unit"] = detail.QuotaPerUnit
|
||
other["channel_price_discount"] = videoChannelDiscountPercent(detail)
|
||
other["video_billed_quota"] = quota
|
||
other["video_unified_audio_price"] = detail.UnifiedAudio
|
||
}
|
||
|
||
type videoPerVideoBillingDetail struct {
|
||
Mode string
|
||
Count int
|
||
Width int
|
||
Height int
|
||
HasAudio bool
|
||
Resolution string
|
||
RuleWidth int
|
||
RuleHeight int
|
||
PricePerVideo float64
|
||
GroupRatio float64
|
||
QuotaPerUnit float64
|
||
ChannelDiscountPercent float64
|
||
UnifiedAudio bool
|
||
}
|
||
|
||
type videoPerVideoPriceMatch struct {
|
||
Resolution string
|
||
RuleWidth int
|
||
RuleHeight int
|
||
PricePerVideo float64
|
||
UnifiedAudio bool
|
||
}
|
||
|
||
func videoPerVideoBillingDetailFromSubmit(c *gin.Context, info *relaycommon.RelayInfo, quota int) *videoPerVideoBillingDetail {
|
||
if c == nil || info == nil {
|
||
return nil
|
||
}
|
||
req, err := relaycommon.GetTaskRequest(c)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
modelName := strings.TrimSpace(info.OriginModelName)
|
||
if modelName == "" {
|
||
return nil
|
||
}
|
||
rules, ok := ratio_setting.GetChannelVideoPricingRules(info.ChannelId, modelName)
|
||
if !ok || !ratio_setting.HasUsableVideoPerVideoRules(rules) {
|
||
var globalOK bool
|
||
rules, globalOK = ratio_setting.GetVideoPricingRules(modelName)
|
||
if !globalOK || !ratio_setting.HasUsableVideoPerVideoRules(rules) {
|
||
return nil
|
||
}
|
||
}
|
||
width, height := videoDimensionsFromTaskRequest(req)
|
||
hasAudio := taskRequestHasAudio(req)
|
||
mode := detectVideoBillingModeFromSubmitRequest(c)
|
||
match, ok := matchPerVideoPriceDetail(rules, mode, width, height, hasAudio)
|
||
if !ok || match.PricePerVideo <= 0 {
|
||
return nil
|
||
}
|
||
groupRatio := info.PriceData.GroupRatioInfo.GroupRatio
|
||
if groupRatio <= 0 {
|
||
groupRatio = 1
|
||
}
|
||
count := 1
|
||
finalPricePerVideo := match.PricePerVideo * groupRatio * (resolveVideoLogChannelDiscountPercent(info) / 100)
|
||
if common.QuotaPerUnit > 0 && quota > 0 {
|
||
finalPricePerVideo = float64(quota) / common.QuotaPerUnit / float64(count)
|
||
}
|
||
return &videoPerVideoBillingDetail{
|
||
Mode: mode,
|
||
Count: count,
|
||
Width: width,
|
||
Height: height,
|
||
HasAudio: hasAudio,
|
||
Resolution: match.Resolution,
|
||
RuleWidth: match.RuleWidth,
|
||
RuleHeight: match.RuleHeight,
|
||
PricePerVideo: finalPricePerVideo,
|
||
GroupRatio: groupRatio,
|
||
QuotaPerUnit: common.QuotaPerUnit,
|
||
ChannelDiscountPercent: resolveVideoLogChannelDiscountPercent(info),
|
||
UnifiedAudio: match.UnifiedAudio,
|
||
}
|
||
}
|
||
|
||
func matchPerVideoPriceDetail(r ratio_setting.VideoPricingRules, mode string, width, height int, hasAudio bool) (*videoPerVideoPriceMatch, bool) {
|
||
var rows []ratio_setting.VideoResolutionAudioPriceRule
|
||
switch mode {
|
||
case "image_to_video":
|
||
rows = r.ImageToVideoPerItem
|
||
case "video_to_video":
|
||
rows = r.VideoToVideoPerItem
|
||
default:
|
||
rows = r.TextToVideoPerItem
|
||
}
|
||
if match, ok := matchPerSecondPriceDetail(ratio_setting.VideoPricingRules{
|
||
TextToVideoPerSecond: rows,
|
||
}, "text_to_video", width, height, hasAudio); ok {
|
||
return &videoPerVideoPriceMatch{
|
||
Resolution: match.Resolution,
|
||
RuleWidth: match.RuleWidth,
|
||
RuleHeight: match.RuleHeight,
|
||
PricePerVideo: match.PricePerSecond,
|
||
UnifiedAudio: match.UnifiedAudio,
|
||
}, true
|
||
}
|
||
|
||
switch mode {
|
||
case "image_to_video":
|
||
return matchLegacyPerVideoRulesByPixelsDetail(width, height, r.ImageToVideoPerVideo)
|
||
case "video_to_video":
|
||
return matchLegacyVideoToVideoRulesByPixelsDetail(width, height, r.VideoToVideoInputPerVideo, r.VideoToVideoOutputPerVideo)
|
||
default:
|
||
return matchLegacyPerVideoRulesByPixelsDetail(width, height, r.TextToVideoPerVideo)
|
||
}
|
||
}
|
||
|
||
func matchLegacyVideoToVideoRulesByPixelsDetail(width, height int, inputRows, outputRows []ratio_setting.VideoResolutionPerVideoRule) (*videoPerVideoPriceMatch, bool) {
|
||
input, inputOK := matchLegacyPerVideoRulesByPixelsDetail(width, height, inputRows)
|
||
output, outputOK := matchLegacyPerVideoRulesByPixelsDetail(width, height, outputRows)
|
||
if !inputOK && !outputOK {
|
||
return nil, false
|
||
}
|
||
if inputOK && outputOK {
|
||
output.PricePerVideo += input.PricePerVideo
|
||
return output, true
|
||
}
|
||
if outputOK {
|
||
return output, true
|
||
}
|
||
return input, true
|
||
}
|
||
|
||
func matchLegacyPerVideoRulesByPixelsDetail(width, height int, rows []ratio_setting.VideoResolutionPerVideoRule) (*videoPerVideoPriceMatch, bool) {
|
||
if len(rows) == 0 || width <= 0 || height <= 0 {
|
||
return nil, false
|
||
}
|
||
targetPixels := width * height
|
||
targetRatio := targetVideoResolutionRatio(width, height)
|
||
best := -1
|
||
minDiffRatio := math.MaxFloat64
|
||
bestW, bestH := 0, 0
|
||
for i, row := range rows {
|
||
if row.VideoPrice <= 0 {
|
||
continue
|
||
}
|
||
ruleW, ruleH, ok := parseVideoResolutionFlexibleForRatio(row.Resolution, targetRatio)
|
||
if !ok || ruleW <= 0 || ruleH <= 0 {
|
||
continue
|
||
}
|
||
rulePixels := ruleW * ruleH
|
||
diffRatio := math.Abs(float64(targetPixels-rulePixels)) / float64(rulePixels)
|
||
if diffRatio < minDiffRatio {
|
||
minDiffRatio = diffRatio
|
||
best = i
|
||
bestW = ruleW
|
||
bestH = ruleH
|
||
}
|
||
}
|
||
if best < 0 {
|
||
return nil, false
|
||
}
|
||
row := rows[best]
|
||
return &videoPerVideoPriceMatch{
|
||
Resolution: row.Resolution,
|
||
RuleWidth: bestW,
|
||
RuleHeight: bestH,
|
||
PricePerVideo: row.VideoPrice,
|
||
UnifiedAudio: true,
|
||
}, true
|
||
}
|
||
|
||
func appendVideoPerVideoBillingDetailOther(c *gin.Context, other map[string]interface{}, info *relaycommon.RelayInfo) {
|
||
if other == nil || info == nil {
|
||
return
|
||
}
|
||
quota := info.PriceData.Quota
|
||
if quota < 0 {
|
||
quota = 0
|
||
}
|
||
videoCount := 1
|
||
quotaPerUnit := common.QuotaPerUnit
|
||
finalPricePerVideo := 0.0
|
||
if quotaPerUnit > 0 && videoCount > 0 {
|
||
finalPricePerVideo = float64(quota) / quotaPerUnit / float64(videoCount)
|
||
}
|
||
other["video_count"] = videoCount
|
||
other["video_price_per_video"] = finalPricePerVideo
|
||
other["video_quota_per_unit"] = quotaPerUnit
|
||
other["channel_price_discount"] = resolveVideoLogChannelDiscountPercent(info)
|
||
other["video_billed_quota"] = quota
|
||
|
||
if detail := videoPerVideoBillingDetailFromSubmit(c, info, quota); detail != nil {
|
||
other["video_count"] = detail.Count
|
||
other["video_width"] = detail.Width
|
||
other["video_height"] = detail.Height
|
||
other["video_has_audio"] = detail.HasAudio
|
||
other["video_resolution"] = detail.Resolution
|
||
other["video_rule_width"] = detail.RuleWidth
|
||
other["video_rule_height"] = detail.RuleHeight
|
||
other["video_price_per_video"] = detail.PricePerVideo
|
||
other["video_quota_per_unit"] = detail.QuotaPerUnit
|
||
other["channel_price_discount"] = detail.ChannelDiscountPercent
|
||
other["video_unified_audio_price"] = detail.UnifiedAudio
|
||
return
|
||
}
|
||
|
||
if c == nil {
|
||
return
|
||
}
|
||
req, err := relaycommon.GetTaskRequest(c)
|
||
if err != nil {
|
||
return
|
||
}
|
||
width, height := videoDimensionsFromTaskRequest(req)
|
||
if width > 0 {
|
||
other["video_width"] = width
|
||
}
|
||
if height > 0 {
|
||
other["video_height"] = height
|
||
}
|
||
if duration := videoDurationFromTaskRequest(req); duration > 0 {
|
||
other["video_seconds"] = duration
|
||
}
|
||
other["video_has_audio"] = taskRequestHasAudio(req)
|
||
}
|
||
|
||
func videoPerSecondBillingDetailOther(detail *videoPerSecondBillingDetail, quota int) map[string]interface{} {
|
||
other := make(map[string]interface{})
|
||
appendVideoPerSecondBillingDetailOther(other, detail, quota)
|
||
return other
|
||
}
|
||
|
||
func audioLabel(hasAudio bool) string {
|
||
if hasAudio {
|
||
return "有音轨"
|
||
}
|
||
return "无音轨"
|
||
}
|
||
|
||
// taskModelName 从 BillingContext 或 Properties 中获取模型名称。
|
||
func taskModelName(task *model.Task) string {
|
||
if bc := task.PrivateData.BillingContext; bc != nil && bc.OriginModelName != "" {
|
||
return bc.OriginModelName
|
||
}
|
||
return task.Properties.OriginModelName
|
||
}
|
||
|
||
// RefundTaskQuota 统一的任务失败退款逻辑。
|
||
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
|
||
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
|
||
quota := task.Quota
|
||
if quota == 0 {
|
||
return
|
||
}
|
||
|
||
// 1. 退还资金来源(钱包或订阅)
|
||
if err := taskAdjustFunding(task, -quota); err != nil {
|
||
logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
|
||
return
|
||
}
|
||
|
||
// 2. 退还令牌额度
|
||
taskAdjustTokenQuota(ctx, task, -quota)
|
||
|
||
// 3. 记录日志
|
||
other := taskBillingOther(task)
|
||
other["task_id"] = task.TaskID
|
||
other["reason"] = reason
|
||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||
UserId: task.UserId,
|
||
LogType: model.LogTypeRefund,
|
||
Content: "",
|
||
ChannelId: task.ChannelId,
|
||
ModelName: taskModelName(task),
|
||
TokenName: task.PrivateData.TokenName,
|
||
Quota: quota,
|
||
TokenId: task.PrivateData.TokenId,
|
||
Group: task.Group,
|
||
Other: other,
|
||
})
|
||
}
|
||
|
||
// RecalculateTaskQuota 通用的异步差额结算。
|
||
// actualQuota 是任务完成后的实际应扣额度,与预扣额度 (task.Quota) 做差额结算。
|
||
// reason 用于日志记录(例如 "token重算" 或 "adaptor调整")。
|
||
func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int, reason string, extraOther ...map[string]interface{}) {
|
||
if actualQuota <= 0 {
|
||
return
|
||
}
|
||
preConsumedQuota := task.Quota
|
||
quotaDelta := actualQuota - preConsumedQuota
|
||
|
||
if quotaDelta == 0 {
|
||
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)",
|
||
task.TaskID, logger.LogQuota(actualQuota), reason))
|
||
return
|
||
}
|
||
|
||
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 差额结算:delta=%s(实际:%s,预扣:%s,%s)",
|
||
task.TaskID,
|
||
logger.LogQuota(quotaDelta),
|
||
logger.LogQuota(actualQuota),
|
||
logger.LogQuota(preConsumedQuota),
|
||
reason,
|
||
))
|
||
|
||
// 调整资金来源
|
||
if err := taskAdjustFunding(task, quotaDelta); err != nil {
|
||
logger.LogError(ctx, fmt.Sprintf("差额结算资金调整失败 task %s: %s", task.TaskID, err.Error()))
|
||
return
|
||
}
|
||
|
||
// 调整令牌额度
|
||
taskAdjustTokenQuota(ctx, task, quotaDelta)
|
||
|
||
task.Quota = actualQuota
|
||
if task.ID > 0 {
|
||
if err := model.DB.Model(&model.Task{}).Where("id = ?", task.ID).Update("quota", actualQuota).Error; err != nil {
|
||
logger.LogWarn(ctx, fmt.Sprintf("更新任务实际计费额度失败 task %s: %s", task.TaskID, err.Error()))
|
||
}
|
||
}
|
||
|
||
var logType int
|
||
var logQuota int
|
||
if quotaDelta > 0 {
|
||
logType = model.LogTypeConsume
|
||
logQuota = quotaDelta
|
||
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
|
||
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
|
||
} else {
|
||
logType = model.LogTypeRefund
|
||
logQuota = -quotaDelta
|
||
}
|
||
other := taskBillingOther(task)
|
||
other["task_id"] = task.TaskID
|
||
//other["reason"] = reason
|
||
other["pre_consumed_quota"] = preConsumedQuota
|
||
other["actual_quota"] = actualQuota
|
||
for _, extra := range extraOther {
|
||
if extra == nil {
|
||
continue
|
||
}
|
||
for k, v := range extra {
|
||
if k == profitShareExtraTotalTokensKey {
|
||
continue
|
||
}
|
||
other[k] = v
|
||
}
|
||
}
|
||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||
UserId: task.UserId,
|
||
LogType: logType,
|
||
Content: reason,
|
||
ChannelId: task.ChannelId,
|
||
ModelName: taskModelName(task),
|
||
TokenName: task.PrivateData.TokenName,
|
||
Quota: logQuota,
|
||
TokenId: task.PrivateData.TokenId,
|
||
Group: task.Group,
|
||
Other: other,
|
||
})
|
||
}
|
||
|
||
// RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。
|
||
// 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度,
|
||
// 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。
|
||
func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) bool {
|
||
if totalTokens <= 0 {
|
||
return false
|
||
}
|
||
|
||
modelName := taskModelName(task)
|
||
|
||
// 获取模型价格和倍率
|
||
modelRatio, hasRatioSetting, _ := ratio_setting.GetModelRatio(modelName)
|
||
// 只有配置了倍率(非固定价格)时才按 token 重新计费
|
||
if !hasRatioSetting || modelRatio <= 0 {
|
||
return false
|
||
}
|
||
|
||
// 获取用户和组的倍率信息
|
||
group := task.Group
|
||
if group == "" {
|
||
user, err := model.GetUserById(task.UserId, false)
|
||
if err == nil {
|
||
group = user.Group
|
||
}
|
||
}
|
||
if group == "" {
|
||
return false
|
||
}
|
||
|
||
groupRatio := ratio_setting.GetGroupRatio(group)
|
||
userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(group, group)
|
||
|
||
var finalGroupRatio float64
|
||
if hasUserGroupRatio {
|
||
finalGroupRatio = userGroupRatio
|
||
} else {
|
||
finalGroupRatio = groupRatio
|
||
}
|
||
|
||
costDisc := model.ResolveChannelPriceDiscountPercent(task.ChannelId)
|
||
markupDisc := model.ResolveEffectiveMarkupDiscountPercentForInviteeBilling(task.UserId, task.ChannelId, modelName)
|
||
globalMr, globalOK, _ := ratio_setting.GetModelRatio(modelName)
|
||
if !globalOK {
|
||
globalMr = 0
|
||
}
|
||
effRate := model.EffectiveInputRate(modelRatio, globalMr, costDisc, markupDisc)
|
||
actualQuota := int(math.Round(float64(totalTokens) * effRate * finalGroupRatio))
|
||
|
||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, channelId=%d", totalTokens, modelRatio, finalGroupRatio, task.ChannelId)
|
||
RecalculateTaskQuota(ctx, task, actualQuota, reason, map[string]interface{}{
|
||
profitShareExtraTotalTokensKey: totalTokens,
|
||
})
|
||
return true
|
||
}
|