Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions model/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,23 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}

type RecordTaskBillingLogParams struct {
UserId int
LogType int
Content string
ChannelId int
ModelName string
Quota int
TokenId int
Group string
Other map[string]interface{}
UserId int
LogType int
Content string
ChannelId int
ModelName string
Quota int
PromptTokens int
CompletionTokens int
TokenId int
Group string
Other map[string]interface{}
}

// RecordTaskBillingLog 仅写入 logs 表的一条 task 计费记录(消费 / 退款)。
// 注意:本函数 *不会* 自动调整 quota_data 统计 —— 因为 task 的"部分退款 + 仍需补全 token"
// 等场景下,金额方向(refund)和 token 方向(增加)并不对称,无法只靠 LogType 推断符号。
// quota_data 的同步由上层 service 显式调用 LogQuotaDataAdjust 完成。
func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
if params.LogType == LogTypeConsume && !common.LogConsumeEnabled {
return
Expand All @@ -276,18 +282,20 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
}
}
log := &Log{
UserId: params.UserId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: params.LogType,
Content: params.Content,
TokenName: tokenName,
ModelName: params.ModelName,
Quota: params.Quota,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
Group: params.Group,
Other: common.MapToJsonStr(params.Other),
UserId: params.UserId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: params.LogType,
Content: params.Content,
TokenName: tokenName,
ModelName: params.ModelName,
Quota: params.Quota,
PromptTokens: params.PromptTokens,
CompletionTokens: params.CompletionTokens,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
Group: params.Group,
Other: common.MapToJsonStr(params.Other),
}
err := LOG_DB.Create(log).Error
if err != nil {
Expand Down
23 changes: 19 additions & 4 deletions model/usedata.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ func UpdateQuotaData() {
var CacheQuotaData = make(map[string]*QuotaData)
var CacheQuotaDataLock = sync.Mutex{}

func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) {
func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int, countDelta int) {
key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt)
quotaData, ok := CacheQuotaData[key]
if ok {
quotaData.Count += 1
quotaData.Count += countDelta
quotaData.Quota += quota
quotaData.TokenUsed += tokenUsed
} else {
Expand All @@ -47,7 +47,7 @@ func logQuotaDataCache(userId int, username string, modelName string, quota int,
Username: username,
ModelName: modelName,
CreatedAt: createdAt,
Count: 1,
Count: countDelta,
Quota: quota,
TokenUsed: tokenUsed,
}
Expand All @@ -61,7 +61,22 @@ func LogQuotaData(userId int, username string, modelName string, quota int, crea

CacheQuotaDataLock.Lock()
defer CacheQuotaDataLock.Unlock()
logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed)
logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed, 1)
}

// LogQuotaDataAdjust 用于异步任务的退款/补扣场景:仅调整 quota / token_used,不变化 count。
// quotaDelta、tokenDelta 支持正负:退款传负值,补扣传正值。
// - 钱不动只补 token 统计(如 token 重算 delta=0 但 totalTokens>0)也可以直接调用。
// - 调用前置开关由调用方负责(一般为 common.DataExportEnabled)。
func LogQuotaDataAdjust(userId int, username string, modelName string, quotaDelta int, createdAt int64, tokenDelta int) {
if quotaDelta == 0 && tokenDelta == 0 {
return
}
createdAt = createdAt - (createdAt % 3600)

CacheQuotaDataLock.Lock()
defer CacheQuotaDataLock.Unlock()
logQuotaDataCache(userId, username, modelName, quotaDelta, createdAt, tokenDelta, 0)
}

func SaveQuotaDataCache() {
Expand Down
18 changes: 18 additions & 0 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,24 @@ func UpdateUserUsedQuotaAndRequestCount(id int, quota int) {
updateUserUsedQuotaAndRequestCount(id, quota, 1)
}

// UpdateUserUsedQuotaDelta 仅调整 used_quota(不动 request_count),支持负值。
// 用于异步任务的退款 / 补扣场景,避免 request_count 被错误累加:
// - 退款:传入负值,used_quota 回退;
// - 补扣:传入正值,used_quota 增加。
//
// 与 UpdateUserUsedQuotaAndRequestCount 走同一批量通道(BatchUpdateTypeUsedQuota),
// 底层 SQL 是 `used_quota + ?`,天然支持正负。
func UpdateUserUsedQuotaDelta(id int, delta int) {
if delta == 0 {
return
}
if common.BatchUpdateEnabled {
addNewRecord(BatchUpdateTypeUsedQuota, id, delta)
return
}
updateUserUsedQuota(id, delta)
}

func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
err := DB.Model(&User{}).Where("id = ?", id).Updates(
map[string]interface{}{
Expand Down
51 changes: 43 additions & 8 deletions relay/channel/task/doubao/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,42 @@ func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) {
}

// ValidateRequestAndSetAction parses body, validates fields and sets default action.
// 额外职责:对登记了二维定价的模型(seedance 2.0 系列),前置拦截上游
// "暂不支持"的档位组合(如 pro 的 1080p+含视频、fast 的 1080p),
// 避免预扣后还要走上游失败 + 退款流程。
func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) {
// Accept only POST /v1/video/generations as "generate" action.
return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate)
if err := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate); err != nil {
return err
}

req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
modelName := req.Model
if modelName == "" {
return nil
}
if _, supported := ResolveBillingRatios(modelName, extractResolution(req.Metadata), hasVideoInMetadata(req.Metadata)); !supported {
return service.TaskErrorWrapperLocal(
fmt.Errorf("model %s does not support the requested resolution / video-input combination", modelName),
"unsupported_resolution_combination",
http.StatusBadRequest,
)
}
return nil
}

// extractResolution 从请求 metadata 中读取 resolution 字段;未显式指定时返回空串,
// 由 normalizeResolution 在定价查询时回退到豆包默认分辨率(720p)。
func extractResolution(metadata map[string]interface{}) string {
if metadata == nil {
return ""
}
if v, ok := metadata["resolution"].(string); ok {
return v
}
return ""
}

// BuildRequestURL constructs the upstream URL.
Expand All @@ -132,18 +165,20 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r
return nil
}

// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
// EstimateBilling 根据请求的输出分辨率与是否包含视频输入,
// 返回相对基线档的二维 OtherRatios(resolution / video_input)。
// "暂不支持"的组合已经在 ValidateRequestAndSetAction 阶段被拦截,
// 这里只会命中合法档位。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
ratios, supported := ResolveBillingRatios(info.OriginModelName, extractResolution(req.Metadata), hasVideoInMetadata(req.Metadata))
if !supported {
return nil
}
return nil
return ratios
}

// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
Expand Down
128 changes: 119 additions & 9 deletions relay/channel/task/doubao/constants.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package doubao

import "strings"

var ModelList = []string{
"doubao-seedance-1-0-pro-250528",
"doubao-seedance-1-0-lite-t2v",
Expand All @@ -11,15 +13,123 @@ var ModelList = []string{

var ChannelName = "doubao-video"

// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
// Resolution 档位的标准化常量。
const (
resolution480p = "480p"
resolution720p = "720p"
resolution1080p = "1080p"
)

// pricingKey 由 (模型名, 输出分辨率, 是否包含视频输入) 三元组唯一定位一档实际单价。
type pricingKey struct {
Model string
Resolution string
WithVideoIn bool
}

// pricingRatioMap 登记"该档位实际单价 / 该模型基线单价(480p-720p 不含视频档)"的比例。
// 管理员应以每个模型的"基线档"ModelRatio 作为 1.0 基准,
// 当请求命中非基线档(1080p 或包含视频输入)时,本表给出对应倍率。
//
// 豆包 2026-01-28 价格表:
//
// doubao-seedance-2-0-260128(基线 46 元 / 百万 tokens):
//
// 480p / 720p 不含视频:46 → 1.0
// 480p / 720p 含视频 :28 → 28/46
// 1080p 不含视频 :51 → 51/46
// 1080p 含视频 :31 → 31/46
//
// doubao-seedance-2-0-fast-260128(基线 37 元 / 百万 tokens):
//
// 480p / 720p 不含视频:37 → 1.0
// 480p / 720p 含视频 :22 → 22/37
// 1080p :暂不支持
var pricingRatioMap = map[pricingKey]float64{
{Model: "doubao-seedance-2-0-260128", Resolution: resolution480p, WithVideoIn: false}: 1.0,
{Model: "doubao-seedance-2-0-260128", Resolution: resolution720p, WithVideoIn: false}: 1.0,
{Model: "doubao-seedance-2-0-260128", Resolution: resolution480p, WithVideoIn: true}: 28.0 / 46.0,
{Model: "doubao-seedance-2-0-260128", Resolution: resolution720p, WithVideoIn: true}: 28.0 / 46.0,
{Model: "doubao-seedance-2-0-260128", Resolution: resolution1080p, WithVideoIn: false}: 51.0 / 46.0,
{Model: "doubao-seedance-2-0-260128", Resolution: resolution1080p, WithVideoIn: true}: 31.0 / 46.0,

{Model: "doubao-seedance-2-0-fast-260128", Resolution: resolution480p, WithVideoIn: false}: 1.0,
{Model: "doubao-seedance-2-0-fast-260128", Resolution: resolution720p, WithVideoIn: false}: 1.0,
{Model: "doubao-seedance-2-0-fast-260128", Resolution: resolution480p, WithVideoIn: true}: 22.0 / 37.0,
{Model: "doubao-seedance-2-0-fast-260128", Resolution: resolution720p, WithVideoIn: true}: 22.0 / 37.0,
}

func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
// hasPricingConfig 指明哪些模型走二维倍率计算;
// 未登记的模型(如 seedance-1-x 系列)跳过倍率处理,保持原 ModelRatio 全额计费。
var hasPricingConfig = map[string]bool{
"doubao-seedance-2-0-260128": true,
"doubao-seedance-2-0-fast-260128": true,
}

// normalizeResolution 将 "480P"/"1080p" 等大小写变体统一为标准形式。
// 未指定或未知分辨率一律回退为 720p(豆包 seedance 默认输出分辨率)。
func normalizeResolution(r string) string {
switch strings.ToLower(strings.TrimSpace(r)) {
case resolution480p:
return resolution480p
case resolution1080p:
return resolution1080p
case "", resolution720p:
return resolution720p
default:
return resolution720p
}
}

// ResolveBillingRatios 根据模型、输出分辨率与是否含视频输入,
// 返回相对"基线档(480p/720p 不含视频)"的二维倍率 (resolution / video_input)。
//
// - supported == false 表示当前组合被上游标注为"暂不支持",调用方应直接拒绝请求。
// - 对未登记定价配置的模型返回 (nil, true),表示"不需要任何折扣,走基础 ModelRatio"。
//
// 拆维语义(保证两维乘积严格等于该档位 totalRatio):
//
// - resolution = pricingRatioMap[(model, res, 不含视频)] // 横向:分辨率溢价(以"不含视频"档为基准)
// - video_input = pricingRatioMap[(model, res, 含视频)] / resolution
// // 纵向:同分辨率下"含视频"相对"不含视频"的折扣
//
// 验证(pro 2.0 为例):
//
// 480p/720p, 不含视频:res=1.0, vid=1.0 → 1.0
// 480p/720p, 含视频 :res=1.0, vid=28/46 → 28/46 ✓
// 1080p, 不含视频:res=51/46, vid=1.0 → 51/46 ✓
// 1080p, 含视频 :res=51/46, vid=(31/46)/(51/46)=31/51 → 31/46 ✓
func ResolveBillingRatios(modelName, resolution string, withVideoIn bool) (map[string]float64, bool) {
if !hasPricingConfig[modelName] {
return nil, true
}
res := normalizeResolution(resolution)
totalRatio, ok := pricingRatioMap[pricingKey{Model: modelName, Resolution: res, WithVideoIn: withVideoIn}]
if !ok {
return nil, false
}

// 该分辨率下"不含视频"档的绝对倍率(横向基准)。
baseForRes, hasBase := pricingRatioMap[pricingKey{Model: modelName, Resolution: res, WithVideoIn: false}]
if !hasBase {
// 理论上不会发生:有含视频档就必有不含视频档(本体定价表保持这条约束)。
// 兜底按 totalRatio 单键上报,避免返回错误。
ratios := map[string]float64{}
if totalRatio != 1.0 {
ratios["pricing_tier"] = totalRatio
}
return ratios, true
}

ratios := map[string]float64{}
if baseForRes != 1.0 {
ratios["resolution"] = baseForRes
}
if withVideoIn && baseForRes > 0 {
videoRatio := totalRatio / baseForRes
if videoRatio != 1.0 {
ratios["video_input"] = videoRatio
}
}
return ratios, true
}
Loading
Loading