feat(model):模型计费功能改造,支持按秒计费的方式
This commit is contained in:
1 parent
97da0a70aa
commit
e6293c4c7c
44 files changed
+2695
-640
No files matched your search
+59
-38
@@ -45,11 +45,12 @@ type ModelItem struct {
|
||||
DeprecatedAt *string `json:"deprecated_at"`
|
||||
SunsetDate *string `json:"sunset_date"`
|
||||
ReplacementModel string `json:"replacement_model"`
|
||||
// 定价摘要(来自 mdl_pricing,min_tokens=0 的基准行)
|
||||
PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered"
|
||||
InputPrice float64 `json:"input_price"` // $/1M tokens
|
||||
OutputPrice float64 `json:"output_price"` // $/1M tokens
|
||||
PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式)
|
||||
// 定价摘要(来自 mdl_pricing 的 pricing JSONB)
|
||||
PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered" | "per_second"
|
||||
InputPrice float64 `json:"input_price"` // $/1M tokens
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 规格→$/秒(仅 per_second 模式)
|
||||
OutputPrice float64 `json:"output_price"` // $/1M tokens
|
||||
PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式)
|
||||
// 可用渠道列表(来自 chn_abilities + chn_channels)
|
||||
Channels []ModelChannelInfo `json:"channels"`
|
||||
}
|
||||
@@ -94,19 +95,20 @@ type ModelDeleteReq struct {
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
|
||||
}
|
||||
|
||||
// PricingItem 定价项(支持按次/按量/阶梯)
|
||||
// PricingItem 定价项(支持按次/按量/阶梯/按秒)
|
||||
type PricingItem struct {
|
||||
BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered" dc:"计费模式"`
|
||||
MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"`
|
||||
MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"`
|
||||
InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"`
|
||||
OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"`
|
||||
CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
|
||||
BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered,per_second" dc:"计费模式"`
|
||||
MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"`
|
||||
MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"`
|
||||
InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"`
|
||||
OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"`
|
||||
CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices" dc:"按秒单价矩阵(仅 per_second):分辨率规格→每秒单价(本位币),\"*\"为兜底价,如 {\"480p\":0.25,\"720p\":0.5,\"*\":0.5}"`
|
||||
}
|
||||
|
||||
// TimeSegmentItem 时段定价项(mdl_pricing.time_segments JSONB 数组元素)。
|
||||
// TimeSegmentItem 时段定价项(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。
|
||||
// 语义:按数组顺序先命中先生效(促销时段放前面覆盖常驻时段),未命中=默认价(乘数 1.0);
|
||||
// 最终费用 = 各项小计 × 租户乘数 × 时段乘数。
|
||||
type TimeSegmentItem struct {
|
||||
@@ -119,6 +121,22 @@ type TimeSegmentItem struct {
|
||||
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:10" dc:"价格乘数,0.5=半价"`
|
||||
}
|
||||
|
||||
// ParamConditionItem 参数倍率匹配条件(mdl_pricing.pricing JSONB 顶层 param_multipliers 数组元素)。
|
||||
// 匹配对象为网关归一化后的任务体({model, prompt, seconds, metadata{...}}),
|
||||
// 支持数组通配([*],任一元素命中即命中),字符串数字自动强转参与比较。
|
||||
type ParamConditionItem struct {
|
||||
Path string `json:"path" v:"required" dc:"任务体 JSON 路径,如 metadata.image / seconds / metadata.content[*].video_url"`
|
||||
Match string `json:"match" v:"required|in:has_value,equals,gt,gte,lt,lte,between" dc:"匹配方式"`
|
||||
Value any `json:"value,omitempty" dc:"比对值:equals/gt/gte/lt/lte 单值;between 为 [min,max];has_value 不填"`
|
||||
}
|
||||
|
||||
// ParamMultiplierItem 参数倍率规则:conditions 全部满足(隐式与)时倍率计入连乘;规则间连乘。
|
||||
type ParamMultiplierItem struct {
|
||||
Conditions []ParamConditionItem `json:"conditions" v:"required" dc:"条件列表(全部满足才命中)"`
|
||||
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:100" dc:"命中倍率,1.5=加价50%"`
|
||||
Note string `json:"note" dc:"命中说明(写入计费上下文供对账解释)"`
|
||||
}
|
||||
|
||||
// PricingGetReq 获取模型定价
|
||||
type PricingGetReq struct {
|
||||
g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"`
|
||||
@@ -126,9 +144,10 @@ type PricingGetReq struct {
|
||||
}
|
||||
|
||||
type PricingGetRes struct {
|
||||
List []PricingItem `json:"list"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(锚点行 time_segments)"`
|
||||
// 以下为锚点行展示字段(仅 min_tokens=0 行生效)
|
||||
List []PricingItem `json:"list"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(pricing JSONB 顶层)"`
|
||||
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(pricing JSONB 顶层,横切所有计费模式)"`
|
||||
// 以下为定价行展示字段
|
||||
PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"`
|
||||
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"`
|
||||
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"`
|
||||
@@ -136,11 +155,12 @@ type PricingGetRes struct {
|
||||
|
||||
// PricingSetReq 设置模型定价(全量替换)
|
||||
type PricingSetReq struct {
|
||||
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
|
||||
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
|
||||
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
|
||||
// 以下为锚点行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
|
||||
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
|
||||
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
|
||||
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
|
||||
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选,全量替换;空数组清除)"`
|
||||
// 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
|
||||
PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"`
|
||||
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"`
|
||||
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"`
|
||||
@@ -277,22 +297,23 @@ type ModelImportReq struct {
|
||||
Models []ModelImportItem `json:"models" v:"required#请至少导入一个模型"`
|
||||
}
|
||||
|
||||
// ModelImportItem 导入模型项
|
||||
// ModelImportItem 导入模型项(含定价/时段/参数倍率,全量替换语义)
|
||||
type ModelImportItem struct {
|
||||
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
Description string `json:"description" dc:"模型描述"`
|
||||
Tags []string `json:"tags" dc:"标签列表"`
|
||||
SunsetDate string `json:"sunset_date" dc:"下线日期"`
|
||||
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
|
||||
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
|
||||
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
|
||||
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
Description string `json:"description" dc:"模型描述"`
|
||||
Tags []string `json:"tags" dc:"标签列表"`
|
||||
SunsetDate string `json:"sunset_date" dc:"下线日期"`
|
||||
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
|
||||
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
|
||||
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"`
|
||||
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
|
||||
}
|
||||
|
||||
// ModelImportRes 导入结果响应
|
||||
|
||||
@@ -20,23 +20,24 @@ type MarketplaceListRes struct {
|
||||
}
|
||||
|
||||
type MarketplaceModelItem struct {
|
||||
ModelId string `json:"model_id"` // 模型标识
|
||||
ModelName string `json:"model_name"` // 模型显示名称
|
||||
Category string `json:"category"` // 分类(chat/embedding/image等)
|
||||
Description string `json:"description"` // 描述
|
||||
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
|
||||
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
|
||||
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered
|
||||
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
|
||||
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
|
||||
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
|
||||
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
|
||||
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
|
||||
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
|
||||
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
|
||||
Tags []string `json:"tags"` // 标签
|
||||
Capabilities g.Map `json:"capabilities"` // 能力标签
|
||||
ModelId string `json:"model_id"` // 模型标识
|
||||
ModelName string `json:"model_name"` // 模型显示名称
|
||||
Category string `json:"category"` // 分类(chat/embedding/image等)
|
||||
Description string `json:"description"` // 描述
|
||||
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
|
||||
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
|
||||
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered/per_second
|
||||
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
|
||||
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
|
||||
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 按秒单价矩阵(规格→USD/秒,仅 per_second),"*"为兜底价
|
||||
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
|
||||
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
|
||||
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
|
||||
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
|
||||
Tags []string `json:"tags"` // 标签
|
||||
Capabilities g.Map `json:"capabilities"` // 能力标签
|
||||
}
|
||||
|
||||
type MarketplaceDetailReq struct {
|
||||
|
||||
@@ -36,6 +36,8 @@ type TimePriceItem struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
// per_second 模型填 PerSecondPrice(兜底档 "*" 的换算价,无 "*" 取矩阵最低档)
|
||||
PerSecondPrice *float64 `json:"per_second_price"`
|
||||
}
|
||||
|
||||
// TenantAvailableModelItem 租户可用模型信息
|
||||
@@ -58,6 +60,9 @@ type TenantAvailableModelItem struct {
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice *float64 `json:"cache_creation_price"`
|
||||
PricingTiers []PricingTierItem `json:"pricing_tiers"`
|
||||
// PerSecondPrices 按秒计费矩阵(billing_mode=per_second):分辨率规格 → 每秒单价(本位币),
|
||||
// "*" 为兜底价。租户倍率/折扣在计费时作用,展示为平台原价。
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"`
|
||||
// TimePrices 时段价目(平台配置了时段定价的模型才有;价格为换算后的展示价)
|
||||
TimePrices []TimePriceItem `json:"time_prices"`
|
||||
// DiscountLabel 折扣标签(平台定价锚点行配置的营销展示文案,如"7折起";NULL=不展示)
|
||||
|
||||
@@ -78,6 +78,7 @@ type BilUsageLogsColumns struct {
|
||||
BillingSnapshot string // 完整计费计算过程快照(JSONB)
|
||||
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
DurationSeconds string // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
|
||||
}
|
||||
|
||||
// bilUsageLogsColumns holds the columns for the table bil_usage_logs.
|
||||
@@ -139,6 +140,7 @@ var bilUsageLogsColumns = BilUsageLogsColumns{
|
||||
BillingSnapshot: "billing_snapshot",
|
||||
ProjectId: "project_id",
|
||||
TaskId: "task_id",
|
||||
DurationSeconds: "duration_seconds",
|
||||
}
|
||||
|
||||
// NewBilUsageLogsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -21,42 +21,28 @@ type MdlPricingDao struct {
|
||||
|
||||
// MdlPricingColumns defines and stores column names for the table mdl_pricing.
|
||||
type MdlPricingColumns struct {
|
||||
Id string //
|
||||
ModelId string // 关联模型ID
|
||||
BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens string // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens string // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice string // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice string // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice string // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
CacheReadPrice string // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice string // 缓存创建每 1M token 价格(直接定价)
|
||||
TimeSegments string // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
|
||||
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Id string //
|
||||
ModelId string // 关联模型ID
|
||||
BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing string // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
}
|
||||
|
||||
// mdlPricingColumns holds the columns for the table mdl_pricing.
|
||||
var mdlPricingColumns = MdlPricingColumns{
|
||||
Id: "id",
|
||||
ModelId: "model_id",
|
||||
BillingMode: "billing_mode",
|
||||
MinTokens: "min_tokens",
|
||||
MaxTokens: "max_tokens",
|
||||
InputPrice: "input_price",
|
||||
OutputPrice: "output_price",
|
||||
PerRequestPrice: "per_request_price",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
CacheReadPrice: "cache_read_price",
|
||||
CacheCreationPrice: "cache_creation_price",
|
||||
TimeSegments: "time_segments",
|
||||
PriceNote: "price_note",
|
||||
DiscountLabel: "discount_label",
|
||||
PriceChangeNote: "price_change_note",
|
||||
Id: "id",
|
||||
ModelId: "model_id",
|
||||
BillingMode: "billing_mode",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
PriceNote: "price_note",
|
||||
DiscountLabel: "discount_label",
|
||||
PriceChangeNote: "price_change_note",
|
||||
Pricing: "pricing",
|
||||
}
|
||||
|
||||
// NewMdlPricingDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -74,8 +74,8 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
|
||||
// 2. 强制非流式:剥离 stream 字段,防 DoResponse 走 SSE 分支
|
||||
cleanBody := stripStreamField(body)
|
||||
|
||||
// 3. 估价(图片走 per_request 价)
|
||||
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil)
|
||||
// 3. 估价(图片走 per_request 价;nil body = 参数倍率不参与,同步图片链路 ratios 不随任务持久化)
|
||||
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil, nil)
|
||||
if err != nil {
|
||||
writeSyncImageError(w, http.StatusInternalServerError, "estimate cost failed: "+err.Error())
|
||||
return
|
||||
|
||||
+218
-118
@@ -3,6 +3,7 @@ package admin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
@@ -76,13 +77,19 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
if req.Search != "" {
|
||||
query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
|
||||
}
|
||||
// 定价状态筛选
|
||||
// 定价状态筛选(pricing JSONB 唯一真相:任一模式有正价即已定价)
|
||||
// 已配价谓词:token 价>0 / per_request 价>0 / per_second 矩阵非空 / tiered 任一档价>0
|
||||
const pricedPredicate = `EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND (
|
||||
(pricing->>'input_price')::numeric > 0
|
||||
OR (pricing->>'output_price')::numeric > 0
|
||||
OR (pricing->>'price')::numeric > 0
|
||||
OR (pricing->'prices' IS NOT NULL AND pricing->'prices' <> '{}'::jsonb)
|
||||
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(pricing->'tiers', '[]'::jsonb)) tx WHERE (tx->>'input_price')::numeric > 0 OR (tx->>'output_price')::numeric > 0)
|
||||
))`
|
||||
if req.PricingStatus == "priced" {
|
||||
// 已定价:存在 min_tokens=0 的记录,且输入价格或输出价格不为 0
|
||||
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND (mdl_pricing.input_price > 0 OR mdl_pricing.output_price > 0 OR mdl_pricing.per_request_price > 0))")
|
||||
query = query.Where(pricedPredicate)
|
||||
} else if req.PricingStatus == "unpriced" {
|
||||
// 未定价:存在 min_tokens=0 的记录,但所有价格字段都为 0
|
||||
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND mdl_pricing.input_price = 0 AND mdl_pricing.output_price = 0 AND (mdl_pricing.per_request_price IS NULL OR mdl_pricing.per_request_price = 0))")
|
||||
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id) AND NOT " + pricedPredicate)
|
||||
}
|
||||
|
||||
var total int
|
||||
@@ -118,17 +125,15 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
}
|
||||
type pricingRow struct {
|
||||
ModelId int64 `json:"model_id"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
ModelId int64 `json:"model_id"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
Pricing string `json:"pricing"`
|
||||
}
|
||||
var pricingRows []pricingRow
|
||||
if len(modelIDs) > 0 {
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
Fields("model_id, billing_mode, input_price, output_price, per_request_price").
|
||||
Where("model_id IN (?) AND min_tokens = 0", modelIDs).
|
||||
Fields("model_id, billing_mode, pricing").
|
||||
Where("model_id IN (?)", modelIDs).
|
||||
Scan(&pricingRows)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
@@ -197,13 +202,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
item.SunsetDate = &s
|
||||
}
|
||||
item.ReplacementModel = m.ReplacementModel
|
||||
// 填充定价摘要
|
||||
// 填充定价摘要(从 pricing JSONB 解析;tiered 取首档价,与旧列表口径一致)
|
||||
if p, ok := pricingMap[m.ID]; ok {
|
||||
item.PricingMode = p.BillingMode
|
||||
item.InputPrice = p.InputPrice
|
||||
item.OutputPrice = p.OutputPrice
|
||||
if p.PerRequestPrice != nil {
|
||||
item.PerRequestPrice = *p.PerRequestPrice
|
||||
if blob := billing.ParsePricingBlob(p.Pricing); blob != nil {
|
||||
switch {
|
||||
case len(blob.Tiers) > 0:
|
||||
item.InputPrice = blob.Tiers[0].InputPrice
|
||||
item.OutputPrice = blob.Tiers[0].OutputPrice
|
||||
default:
|
||||
if blob.InputPrice != nil {
|
||||
item.InputPrice = *blob.InputPrice
|
||||
}
|
||||
if blob.OutputPrice != nil {
|
||||
item.OutputPrice = *blob.OutputPrice
|
||||
}
|
||||
if blob.Price != nil {
|
||||
item.PerRequestPrice = *blob.Price
|
||||
}
|
||||
if len(blob.Prices) > 0 {
|
||||
item.PerSecondPrices = blob.Prices
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 填充可用渠道
|
||||
@@ -255,13 +275,11 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认 token 模式空定价行(未配价,SetModelPricing 时覆盖 pricing JSONB)
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: id,
|
||||
BillingMode: "token",
|
||||
MinTokens: 0,
|
||||
MaxTokens: nil,
|
||||
InputPrice: 0,
|
||||
OutputPrice: 0,
|
||||
Pricing: "{}",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -440,65 +458,82 @@ func parseCapabilities(raw string) map[string]bool {
|
||||
return caps
|
||||
}
|
||||
|
||||
// GetModelPricing 获取模型定价
|
||||
// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容)
|
||||
func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) {
|
||||
var rows []struct {
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode"`
|
||||
MinTokens int64 `json:"min_tokens" orm:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens" orm:"max_tokens"`
|
||||
InputPrice float64 `json:"input_price" orm:"input_price"`
|
||||
OutputPrice float64 `json:"output_price" orm:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" orm:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price" orm:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price" orm:"cache_creation_price"`
|
||||
TimeSegments string `json:"time_segments" orm:"time_segments"`
|
||||
PriceNote *string `json:"price_note" orm:"price_note"`
|
||||
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
|
||||
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
|
||||
var row *struct {
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode"`
|
||||
Pricing string `json:"pricing" orm:"pricing"`
|
||||
PriceNote *string `json:"price_note" orm:"price_note"`
|
||||
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
|
||||
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
|
||||
}
|
||||
|
||||
err := dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", req.ModelID).
|
||||
OrderAsc("min_tokens").
|
||||
Scan(&rows)
|
||||
Scan(&row)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]v1.PricingItem, 0, len(rows))
|
||||
result := make([]v1.PricingItem, 0, 1)
|
||||
var timeSegments []billing.TimeSegment
|
||||
var paramRules []billing.ParamRule
|
||||
var priceNote, discountLabel, priceChangeNote string
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
result = append(result, v1.PricingItem{
|
||||
BillingMode: r.BillingMode,
|
||||
MinTokens: r.MinTokens,
|
||||
MaxTokens: r.MaxTokens,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
CacheCreationPrice: r.CacheCreationPrice,
|
||||
})
|
||||
// 时段定价与展示字段只认锚点行(min_tokens=0),阶梯行上的残留忽略
|
||||
if r.MinTokens == 0 {
|
||||
if r.TimeSegments != "" && r.TimeSegments != "null" {
|
||||
if err := json.Unmarshal([]byte(r.TimeSegments), &timeSegments); err != nil {
|
||||
g.Log().Warningf(ctx, "admin: 模型 %d 时段定价解析失败: %v", req.ModelID, err)
|
||||
timeSegments = nil
|
||||
}
|
||||
}
|
||||
priceNote = derefStr(r.PriceNote)
|
||||
discountLabel = derefStr(r.DiscountLabel)
|
||||
priceChangeNote = derefStr(r.PriceChangeNote)
|
||||
if row != nil {
|
||||
mode := row.BillingMode
|
||||
blob := billing.ParsePricingBlob(row.Pricing)
|
||||
if blob != nil {
|
||||
paramRules = blob.ParamMultipliers
|
||||
}
|
||||
switch {
|
||||
case blob != nil && len(blob.Tiers) > 0:
|
||||
// tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)
|
||||
for _, tier := range blob.Tiers {
|
||||
result = append(result, v1.PricingItem{
|
||||
BillingMode: mode,
|
||||
MinTokens: tier.MinTokens,
|
||||
MaxTokens: tier.MaxTokens,
|
||||
InputPrice: tier.InputPrice,
|
||||
OutputPrice: tier.OutputPrice,
|
||||
})
|
||||
}
|
||||
default:
|
||||
item := v1.PricingItem{BillingMode: mode}
|
||||
if blob != nil {
|
||||
if blob.InputPrice != nil {
|
||||
item.InputPrice = *blob.InputPrice
|
||||
}
|
||||
if blob.OutputPrice != nil {
|
||||
item.OutputPrice = *blob.OutputPrice
|
||||
}
|
||||
if blob.CacheReadPrice != nil {
|
||||
item.CacheReadPrice = *blob.CacheReadPrice
|
||||
}
|
||||
if blob.CacheCreationPrice != nil {
|
||||
item.CacheCreationPrice = *blob.CacheCreationPrice
|
||||
}
|
||||
if blob.Price != nil {
|
||||
p := *blob.Price
|
||||
item.PerRequestPrice = &p
|
||||
}
|
||||
if len(blob.Prices) > 0 {
|
||||
item.PerSecondPrices = blob.Prices
|
||||
}
|
||||
timeSegments = blob.TimeSegments
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
priceNote = derefStr(row.PriceNote)
|
||||
discountLabel = derefStr(row.DiscountLabel)
|
||||
priceChangeNote = derefStr(row.PriceChangeNote)
|
||||
}
|
||||
return &v1.PricingGetRes{
|
||||
List: result,
|
||||
TimeSegments: timeSegmentsToAPI(timeSegments),
|
||||
PriceNote: priceNote,
|
||||
DiscountLabel: discountLabel,
|
||||
PriceChangeNote: priceChangeNote,
|
||||
List: result,
|
||||
TimeSegments: timeSegmentsToAPI(timeSegments),
|
||||
ParamMultipliers: paramMultipliersToAPI(paramRules),
|
||||
PriceNote: priceNote,
|
||||
DiscountLabel: discountLabel,
|
||||
PriceChangeNote: priceChangeNote,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -553,17 +588,112 @@ func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// writeTimeSegmentsForModel 把时段定价写到模型定价锚点行(min_tokens=0),空列表=清除(全量替换语义)。
|
||||
// 定价保存与模型导入共用。清除时写 "[]" 而非 SQL NULL:避免依赖 ORM 对 nil 值 UPDATE 的默认行为
|
||||
// (被省略会让「关闭时段」静默失效),所有读取方(计费/租户/管理端)均已把空数组视为未配置。
|
||||
// 用列名直写而非 do 字段:do.MdlPricing 的 TimeSegments 字段需迁移落库后 gf gen dao 重新生成才存在,
|
||||
// 直写列名与生成物解耦(wire 格式与 custom_pricing_tiers JSONB 先例一致)。调用方需保证事务 ctx 传播。
|
||||
// paramMultipliersFromAPI API 参数倍率规则 → billing 规则(含校验)
|
||||
func paramMultipliersFromAPI(items []v1.ParamMultiplierItem) ([]billing.ParamRule, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rules := make([]billing.ParamRule, 0, len(items))
|
||||
for _, item := range items {
|
||||
rule := billing.ParamRule{
|
||||
Multiplier: item.Multiplier,
|
||||
Note: strings.TrimSpace(item.Note),
|
||||
}
|
||||
for _, c := range item.Conditions {
|
||||
rule.Conditions = append(rule.Conditions, billing.ParamCondition{
|
||||
Path: strings.TrimSpace(c.Path),
|
||||
Match: c.Match,
|
||||
Value: c.Value,
|
||||
})
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
if err := billing.ValidateParamMultipliers(rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// paramMultipliersToAPI billing 规则 → API 参数倍率项
|
||||
func paramMultipliersToAPI(rules []billing.ParamRule) []v1.ParamMultiplierItem {
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
items := make([]v1.ParamMultiplierItem, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
item := v1.ParamMultiplierItem{
|
||||
Multiplier: rule.Multiplier,
|
||||
Note: rule.Note,
|
||||
}
|
||||
for _, c := range rule.Conditions {
|
||||
item.Conditions = append(item.Conditions, v1.ParamConditionItem{
|
||||
Path: c.Path,
|
||||
Match: c.Match,
|
||||
Value: c.Value,
|
||||
})
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// pricingItemsToInput API 定价项 → billing 构造输入
|
||||
func pricingItemsToInput(items []v1.PricingItem) []billing.PricingItemInput {
|
||||
result := make([]billing.PricingItemInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, billing.PricingItemInput{
|
||||
BillingMode: item.BillingMode,
|
||||
MinTokens: item.MinTokens,
|
||||
MaxTokens: item.MaxTokens,
|
||||
InputPrice: item.InputPrice,
|
||||
OutputPrice: item.OutputPrice,
|
||||
PerRequestPrice: item.PerRequestPrice,
|
||||
CacheReadPrice: item.CacheReadPrice,
|
||||
CacheCreationPrice: item.CacheCreationPrice,
|
||||
PerSecondPrices: item.PerSecondPrices,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// writePricingForModel 全量替换模型定价:单行写 billing_mode + pricing JSONB(每模型一行,
|
||||
// uk_mdl_pricing_model 唯一)。SetModelPricing 与模型导入共用;时段定价随后经
|
||||
// writeTimeSegmentsForModel 的 jsonb_set 并入 JSON 顶层;参数倍率规则随 blob 顶层写入。
|
||||
// 调用方需保证事务 ctx 传播。
|
||||
func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule) error {
|
||||
blob, err := billing.BuildPricingBlob(pricingItemsToInput(items))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blob.ParamMultipliers = paramRules
|
||||
blobJSON, err := json.Marshal(blob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 全量替换语义:删旧行插新行(保留展示字段列随 insert 重置为默认,随后由调用方回写)
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", modelDBID).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: modelDBID,
|
||||
BillingMode: items[0].BillingMode,
|
||||
Pricing: string(blobJSON),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键),
|
||||
// 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。
|
||||
// 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。
|
||||
// 单引号需 SQL 转义(json.Marshal 不转义内容中的单引号)。调用方需保证事务 ctx 传播。
|
||||
func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.TimeSegmentItem) error {
|
||||
segs, err := timeSegmentsFromAPI(items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 空配置显式写 "[]"(nil 切片会序列化成 "null",语义不明确)
|
||||
segmentsJSON := "[]"
|
||||
if len(segs) > 0 {
|
||||
data, mErr := json.Marshal(segs)
|
||||
@@ -572,15 +702,18 @@ func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.
|
||||
}
|
||||
segmentsJSON = string(data)
|
||||
}
|
||||
escapedJSON := strings.ReplaceAll(segmentsJSON, "'", "''")
|
||||
_, err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", modelDBID).
|
||||
Where("min_tokens", 0).
|
||||
Data(g.Map{"time_segments": segmentsJSON}).
|
||||
Data(g.Map{
|
||||
"pricing": gdb.Raw(fmt.Sprintf(
|
||||
`jsonb_set(COALESCE(pricing, '{}'::jsonb), '{time_segments}', '%s'::jsonb)`, escapedJSON)),
|
||||
}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到定价锚点行(min_tokens=0)。
|
||||
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到模型定价行。
|
||||
// 全量替换语义:空串=清除(写 NULL)。与 writeTimeSegmentsForModel 同理用列名直写而非 do 字段,
|
||||
// 与 gf gen dao 生成物解耦。调用方需保证事务 ctx 传播。
|
||||
func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, priceNote, discountLabel, priceChangeNote string) error {
|
||||
@@ -593,7 +726,6 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri
|
||||
}
|
||||
_, err := dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", modelDBID).
|
||||
Where("min_tokens", 0).
|
||||
Data(g.Map{
|
||||
"price_note": trimOrNull(priceNote),
|
||||
"discount_label": trimOrNull(discountLabel),
|
||||
@@ -606,23 +738,14 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri
|
||||
// SetModelPricing 设置模型定价(全量替换)
|
||||
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
|
||||
// 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误
|
||||
segments, err := timeSegmentsFromAPI(req.TimeSegments)
|
||||
if err != nil {
|
||||
if _, err := timeSegmentsFromAPI(req.TimeSegments); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 时段配置挂在锚点行(min_tokens=0)上,列表中必须存在锚点行
|
||||
if len(segments) > 0 {
|
||||
hasAnchor := false
|
||||
for _, item := range req.Items {
|
||||
if item.MinTokens == 0 {
|
||||
hasAnchor = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAnchor {
|
||||
return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行")
|
||||
}
|
||||
// 参数倍率规则校验(路径/匹配方式/值形态/倍率区间),fail-fast
|
||||
paramRules, err := paramMultipliersFromAPI(req.ParamMultipliers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 先查模型编码,供事务提交后按模型清除所有租户的价格缓存
|
||||
@@ -635,42 +758,19 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
|
||||
}
|
||||
}
|
||||
|
||||
// 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败,
|
||||
// 该模型会残留「无定价」状态,导致计费失败或回退到默认价。
|
||||
// 全量替换:单行写 pricing JSONB(计费详情 + 参数倍率随 blob 顶层),同一事务。
|
||||
// 否则中途失败会残留「无定价」状态,导致计费失败或回退到默认价。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete(); err != nil {
|
||||
if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range req.Items {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if item.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*item.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: req.ModelID,
|
||||
BillingMode: item.BillingMode,
|
||||
MinTokens: item.MinTokens,
|
||||
MaxTokens: item.MaxTokens,
|
||||
InputPrice: billing.NewFromFloat(item.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(item.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(item.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(item.CacheCreationPrice),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
|
||||
// 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行)
|
||||
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 展示字段写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
|
||||
// 展示字段写定价行(空=清除,全量替换语义;需在定价行插入之后执行)
|
||||
if err := writePricingDisplayFieldsForModel(ctx, req.ModelID, req.PriceNote, req.DiscountLabel, req.PriceChangeNote); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
@@ -50,30 +49,32 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
}
|
||||
|
||||
type exportPricing struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"`
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 仅 per_second 模式(锚点行 pricing JSONB)
|
||||
}
|
||||
|
||||
type exportModel struct {
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
SunsetDate string `json:"sunset_date,omitempty"`
|
||||
ReplacementModel string `json:"replacement_model,omitempty"`
|
||||
Pricing []exportPricing `json:"pricing"`
|
||||
TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"`
|
||||
ModelId string `json:"model_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
Category string `json:"category"`
|
||||
Status string `json:"status"`
|
||||
MaxContextTokens int `json:"max_context_tokens"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Description string `json:"description"`
|
||||
Tags []string `json:"tags"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
SunsetDate string `json:"sunset_date,omitempty"`
|
||||
ReplacementModel string `json:"replacement_model,omitempty"`
|
||||
Pricing []exportPricing `json:"pricing"`
|
||||
TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"`
|
||||
ParamMultipliers []v1.ParamMultiplierItem `json:"param_multipliers,omitempty"`
|
||||
}
|
||||
|
||||
// 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。
|
||||
@@ -82,16 +83,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
modelIDs = append(modelIDs, m.ID)
|
||||
}
|
||||
type pricingRow struct {
|
||||
ModelId int64 `orm:"model_id" json:"model_id"`
|
||||
BillingMode string `orm:"billing_mode" json:"billing_mode"`
|
||||
MinTokens int64 `orm:"min_tokens" json:"min_tokens"`
|
||||
MaxTokens *int64 `orm:"max_tokens" json:"max_tokens"`
|
||||
InputPrice float64 `orm:"input_price" json:"input_price"`
|
||||
OutputPrice float64 `orm:"output_price" json:"output_price"`
|
||||
PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"`
|
||||
CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"`
|
||||
TimeSegments string `orm:"time_segments" json:"time_segments"`
|
||||
ModelId int64 `orm:"model_id" json:"model_id"`
|
||||
BillingMode string `orm:"billing_mode" json:"billing_mode"`
|
||||
Pricing string `orm:"pricing" json:"pricing"` // pricing JSONB(唯一真相)
|
||||
}
|
||||
var allPricingRows []pricingRow
|
||||
if len(modelIDs) > 0 {
|
||||
@@ -99,25 +93,52 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 按 model_id 分组,供后续组装;时段定价只认锚点行(min_tokens=0)
|
||||
// 按 model_id 组装导出数据:pricing JSONB 为唯一真相
|
||||
// (tiered 的 tiers 数组还原为多行 exportPricing,与导入提交的 items 结构互逆)
|
||||
pricingByModel := make(map[int64][]exportPricing, len(allPricingRows))
|
||||
segmentsByModel := make(map[int64][]v1.TimeSegmentItem, len(allPricingRows))
|
||||
paramRulesByModel := make(map[int64][]v1.ParamMultiplierItem, len(allPricingRows))
|
||||
for _, p := range allPricingRows {
|
||||
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: p.MinTokens,
|
||||
MaxTokens: p.MaxTokens,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
CacheCreationPrice: p.CacheCreationPrice,
|
||||
})
|
||||
if p.MinTokens == 0 && p.TimeSegments != "" && p.TimeSegments != "null" {
|
||||
var segs []billing.TimeSegment
|
||||
if err := json.Unmarshal([]byte(p.TimeSegments), &segs); err == nil {
|
||||
segmentsByModel[p.ModelId] = timeSegmentsToAPI(segs)
|
||||
blob := billing.ParsePricingBlob(p.Pricing)
|
||||
if blob == nil {
|
||||
blob = &billing.PricingBlob{}
|
||||
}
|
||||
// tiered:阶梯数组展开为多行;其余模式单行
|
||||
if len(blob.Tiers) > 0 {
|
||||
for _, tier := range blob.Tiers {
|
||||
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: tier.MinTokens,
|
||||
MaxTokens: tier.MaxTokens,
|
||||
InputPrice: tier.InputPrice,
|
||||
OutputPrice: tier.OutputPrice,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
ep := exportPricing{
|
||||
BillingMode: p.BillingMode,
|
||||
PerSecondPrices: blob.Prices,
|
||||
}
|
||||
if blob.InputPrice != nil {
|
||||
ep.InputPrice = *blob.InputPrice
|
||||
}
|
||||
if blob.OutputPrice != nil {
|
||||
ep.OutputPrice = *blob.OutputPrice
|
||||
}
|
||||
ep.PerRequestPrice = blob.Price
|
||||
if blob.CacheReadPrice != nil {
|
||||
ep.CacheReadPrice = *blob.CacheReadPrice
|
||||
}
|
||||
if blob.CacheCreationPrice != nil {
|
||||
ep.CacheCreationPrice = *blob.CacheCreationPrice
|
||||
}
|
||||
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], ep)
|
||||
}
|
||||
if len(blob.TimeSegments) > 0 {
|
||||
segmentsByModel[p.ModelId] = timeSegmentsToAPI(blob.TimeSegments)
|
||||
}
|
||||
if len(blob.ParamMultipliers) > 0 {
|
||||
paramRulesByModel[p.ModelId] = paramMultipliersToAPI(blob.ParamMultipliers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +156,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
ReplacementModel: m.ReplacementModel,
|
||||
Pricing: pricingByModel[m.ID],
|
||||
TimeSegments: segmentsByModel[m.ID],
|
||||
ParamMultipliers: paramRulesByModel[m.ID],
|
||||
}
|
||||
if em.Pricing == nil {
|
||||
em.Pricing = []exportPricing{}
|
||||
@@ -280,32 +302,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Where("model_id", existing.ID).Delete()
|
||||
if err != nil {
|
||||
return err
|
||||
paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers)
|
||||
if pErr != nil {
|
||||
return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId)
|
||||
}
|
||||
for _, p := range item.Pricing {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if p.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*p.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: existing.ID,
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: p.MinTokens,
|
||||
MaxTokens: p.MaxTokens,
|
||||
InputPrice: billing.NewFromFloat(p.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(p.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePricingForModel(ctx, existing.ID, item.Pricing, paramRules); err != nil {
|
||||
return gerror.Wrapf(err, "模型 %s 定价", item.ModelId)
|
||||
}
|
||||
|
||||
// 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除)
|
||||
@@ -342,28 +344,13 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range item.Pricing {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if p.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*p.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: id,
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: p.MinTokens,
|
||||
MaxTokens: p.MaxTokens,
|
||||
InputPrice: billing.NewFromFloat(p.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(p.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 定价写入(pricing JSONB,含参数倍率,与 SetModelPricing 口径一致)
|
||||
paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers)
|
||||
if pErr != nil {
|
||||
return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId)
|
||||
}
|
||||
if err := writePricingForModel(ctx, id, item.Pricing, paramRules); err != nil {
|
||||
return gerror.Wrapf(err, "模型 %s 定价", item.ModelId)
|
||||
}
|
||||
|
||||
// 时段定价透传(挂锚点行)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//go:build e2e
|
||||
|
||||
// 手动 E2E(需真实 DB/Redis;默认 go test 不编译本文件)。
|
||||
// 跑法:go test -tags e2e -run TestE2EParamMultiplier ./internal/logic/billing/
|
||||
// 使用独立的临时模型行 + 不存在的租户 ID,走纯基础定价路径,结束即清理,不影响现有数据。
|
||||
// 验证链路:pricing JSONB 配置 param_multipliers → TaskBillingProvider.EstimateTaskCost
|
||||
// 携归一化任务体求值 → ratios 注入 param_multiplier/param_matched → 费用连乘。
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
func TestE2EParamMultiplier(t *testing.T) {
|
||||
// 测试 CWD 是包目录,把配置搜索路径指回仓库根的 manifest/config
|
||||
adapterFile, err := gcfg.NewAdapterFile()
|
||||
if err != nil {
|
||||
t.Fatalf("new config adapter: %v", err)
|
||||
}
|
||||
if err := adapterFile.AddPath("../../../manifest/config"); err != nil {
|
||||
t.Fatalf("add config path: %v", err)
|
||||
}
|
||||
gcfg.Instance().SetAdapter(adapterFile)
|
||||
ctx := gctx.New()
|
||||
|
||||
const tmpModel = "tmp-e2e-param-multiplier"
|
||||
const tmpTenant = int64(999999998) // 不存在的租户:走纯基础定价路径
|
||||
|
||||
cleanup := func() {
|
||||
var id int64
|
||||
v, _ := dao.MdlModels.Ctx(ctx).Where("model_id", tmpModel).Fields("id").Value()
|
||||
fmt.Sscanf(v.String(), "%d", &id)
|
||||
if id > 0 {
|
||||
_, _ = dao.MdlPricing.Ctx(ctx).Where("model_id", id).Delete()
|
||||
_, _ = dao.MdlModels.Ctx(ctx).Where("id", id).Delete()
|
||||
}
|
||||
ClearModelPriceCache(ctx, tmpModel)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// 临时模型 + per_second 定价(含参数倍率规则:图生视频 ×1.5,≥10 秒 ×2.0,视频输入 ×0.609)
|
||||
modelID, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(do.MdlModels{
|
||||
ModelId: tmpModel,
|
||||
ModelName: "E2E参数倍率临时模型",
|
||||
Category: "video",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert tmp model: %v", err)
|
||||
}
|
||||
pricingJSON := `{
|
||||
"unit": "second",
|
||||
"prices": {"720p": 0.5, "1080p": 1.0, "*": 0.5},
|
||||
"param_multipliers": [
|
||||
{"conditions": [{"path": "metadata.image", "match": "has_value"}], "multiplier": 1.5, "note": "图生视频"},
|
||||
{"conditions": [{"path": "seconds", "match": "gte", "value": 10}], "multiplier": 2.0, "note": "长视频"},
|
||||
{"conditions": [{"path": "metadata.content[*].video_url", "match": "has_value"}], "multiplier": 0.609, "note": "视频输入折扣"}
|
||||
]
|
||||
}`
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: modelID,
|
||||
BillingMode: "per_second",
|
||||
Pricing: pricingJSON,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tmp pricing: %v", err)
|
||||
}
|
||||
|
||||
provider := NewTaskBillingProvider()
|
||||
|
||||
// 1. 命中「图生视频 ×1.5」+「长视频 ×2.0」(seconds 字符串 "12" 数字强转参与比较):
|
||||
// 费用 = 0.5(720p) × 12s × 1.5 × 2.0 = 18.0;ratios 注入 param_multiplier=3 / param_matched
|
||||
body := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`)
|
||||
ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
|
||||
cost, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios, body)
|
||||
if err != nil {
|
||||
t.Fatalf("estimate: %v", err)
|
||||
}
|
||||
assertDecimal(t, cost, 18.0, "e2e param multiplier cost (0.5×12×1.5×2)")
|
||||
if m, _ := ratios["param_multiplier"].(float64); m != 3.0 {
|
||||
t.Fatalf("param_multiplier injected = %v (type %T), want 3.0", ratios["param_multiplier"], ratios["param_multiplier"])
|
||||
}
|
||||
if matched, _ := ratios["param_matched"].(string); matched != "图生视频|长视频" {
|
||||
t.Fatalf("param_matched = %q, want 图生视频|长视频", matched)
|
||||
}
|
||||
|
||||
// 2. 纯文生视频(无图、5 秒):不命中任何规则,无注入;费用 = 0.5 × 5 = 2.5
|
||||
ratios2 := map[string]any{"spec.duration": 5.0}
|
||||
body2 := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"5"}`)
|
||||
cost2, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios2, body2)
|
||||
if err != nil {
|
||||
t.Fatalf("estimate2: %v", err)
|
||||
}
|
||||
assertDecimal(t, cost2, 2.5, "e2e no-match cost")
|
||||
if _, injected := ratios2["param_multiplier"]; injected {
|
||||
t.Fatalf("no-match should not inject, got %v", ratios2["param_multiplier"])
|
||||
}
|
||||
|
||||
// 3. 视频输入折扣([*] 路径)+ 1080p:费用 = 1.0 × 8 × 0.609 = 4.872
|
||||
ratios3 := map[string]any{"spec.duration": 8.0, "spec.resolution": "1080p"}
|
||||
body3 := []byte(`{"model":"` + tmpModel + `","prompt":"p","metadata":{"resolution":"1080p","duration":8,"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`)
|
||||
cost3, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios3, body3)
|
||||
if err != nil {
|
||||
t.Fatalf("estimate3: %v", err)
|
||||
}
|
||||
assertDecimal(t, cost3, 4.872, "e2e video-input discount cost (1.0×8×0.609)")
|
||||
|
||||
// 4. 定价缓存命中路径同样生效(第二次调用走 600s 缓存里的 ParamMultipliers)
|
||||
ratios4 := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
|
||||
cost4, _ := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios4, body)
|
||||
assertDecimal(t, cost4, 18.0, "e2e cached param multiplier cost")
|
||||
}
|
||||
@@ -62,13 +62,12 @@ func TestE2ETimeSegmentPricing(t *testing.T) {
|
||||
start := now.Add(-time.Hour).Format("15:04")
|
||||
end := now.Add(time.Hour).Format("15:04")
|
||||
segJSON := fmt.Sprintf(`[{"name":"闲时","start_time":%q,"end_time":%q,"multiplier":0.5}]`, start, end)
|
||||
// pricing JSONB 单模式存储:token 价 + 时段配置(时段并入顶层)
|
||||
pricingJSON := fmt.Sprintf(`{"input_price":3,"output_price":15,"time_segments":%s}`, segJSON)
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: modelID,
|
||||
BillingMode: "token",
|
||||
MinTokens: 0,
|
||||
InputPrice: NewFromFloat(3),
|
||||
OutputPrice: NewFromFloat(15),
|
||||
TimeSegments: segJSON,
|
||||
ModelId: modelID,
|
||||
BillingMode: "token",
|
||||
Pricing: pricingJSON,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tmp pricing: %v", err)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ const (
|
||||
|
||||
// 预定义常量(减少重复 NewFromInt 调用)
|
||||
var (
|
||||
Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算
|
||||
MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容)
|
||||
Zero = decimal.Zero // 常用零值
|
||||
One = decimal.NewFromInt(1) // 常用 1
|
||||
Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算
|
||||
MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容)
|
||||
Zero = decimal.Zero // 常用零值
|
||||
One = decimal.NewFromInt(1) // 常用 1
|
||||
)
|
||||
|
||||
// RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)。
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 参数倍率(param_multipliers):按「网关归一化后的任务体」中的请求参数匹配规则,
|
||||
// 对命中规则连乘倍率。规则配置在 mdl_pricing.pricing JSONB 顶层(横切所有计费模式,与 time_segments 同级)。
|
||||
//
|
||||
// 设计约束(docs/模型定价存储JSON化与按秒计费设计.md 3.2):
|
||||
// - 匹配源 = 转换后的通用任务体({model, prompt, seconds, metadata{...}}),规则跟语义走不跟入口协议走;
|
||||
// - 一条规则内扁平条件列表 = 隐式 AND(全部满足才命中),不做嵌套布尔组(已否决);
|
||||
// - 规则间连乘,互斥分支多配几条规则即 OR;
|
||||
// - 不做 NOT / 正则 / 范围外运算符;
|
||||
// - 数字强转:字符串可转数字则参与比较("/v1/videos 的 seconds 是字符串")。
|
||||
|
||||
// ParamCondition 单个匹配条件
|
||||
type ParamCondition struct {
|
||||
Path string `json:"path"` // 任务体 JSON 路径,支持 [*] 数组通配(任一元素命中即命中),如 metadata.content[*].video_url
|
||||
Match string `json:"match"` // has_value | equals | gt | gte | lt | lte | between
|
||||
Value any `json:"value,omitempty"` // equals/gt/gte/lt/lte 单值;between 为 [min,max] 双元素数组
|
||||
}
|
||||
|
||||
// ParamRule 参数倍率规则:conditions 全部满足时按 multiplier 计入连乘
|
||||
type ParamRule struct {
|
||||
Conditions []ParamCondition `json:"conditions"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
Note string `json:"note,omitempty"` // 命中说明,写入计费上下文供对账解释
|
||||
}
|
||||
|
||||
// 求值注入的 ratios 键(随任务 PrivateData.billing_context 持久化,
|
||||
// 预扣 / per_second 结算 / token 重算三口径经 applyRatioMultipliers 自动同源)
|
||||
const (
|
||||
ratioKeyParamMultiplier = "param_multiplier"
|
||||
ratioKeyParamMatched = "param_matched" // 命中规则 note 列表(| 分隔);string 值不参与乘法
|
||||
)
|
||||
|
||||
// validParamMatches 合法匹配运算符集合
|
||||
var validParamMatches = map[string]bool{
|
||||
"has_value": true, "equals": true,
|
||||
"gt": true, "gte": true, "lt": true, "lte": true, "between": true,
|
||||
}
|
||||
|
||||
// ValidateParamMultipliers 校验规则配置(SetModelPricing / 模型导入 fail-fast 用)
|
||||
func ValidateParamMultipliers(rules []ParamRule) error {
|
||||
for i, rule := range rules {
|
||||
if len(rule.Conditions) == 0 {
|
||||
return fmt.Errorf("参数倍率规则 %d 至少需要一个条件", i+1)
|
||||
}
|
||||
if rule.Multiplier <= 0 || rule.Multiplier > 100 {
|
||||
return fmt.Errorf("参数倍率规则 %d 倍率需在 (0, 100] 区间", i+1)
|
||||
}
|
||||
for j, cond := range rule.Conditions {
|
||||
if cond.Path == "" {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d 路径为空", i+1, j+1)
|
||||
}
|
||||
if !validParamMatches[cond.Match] {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d 匹配方式非法:%s", i+1, j+1, cond.Match)
|
||||
}
|
||||
switch cond.Match {
|
||||
case "between":
|
||||
arr, ok := cond.Value.([]any)
|
||||
if !ok || len(arr) != 2 {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d between 需要 [min,max] 双元素", i+1, j+1)
|
||||
}
|
||||
if _, ok1 := toNumber(arr[0]); !ok1 {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1)
|
||||
}
|
||||
if _, ok2 := toNumber(arr[1]); !ok2 {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1)
|
||||
}
|
||||
case "has_value":
|
||||
// 无需值
|
||||
default: // equals / gt / gte / lt / lte 需要比对值
|
||||
if cond.Value == nil {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d 需要指定比对值", i+1, j+1)
|
||||
}
|
||||
if cond.Match != "equals" {
|
||||
if _, ok := toNumber(cond.Value); !ok {
|
||||
return fmt.Errorf("参数倍率规则 %d 条件 %d %s 需要数字值", i+1, j+1, cond.Match)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvalParamMultipliers 对任务体求值参数倍率:命中规则连乘,返回 (总倍率, 命中说明列表)。
|
||||
// 无规则/无命中返回 (1.0, nil);解析失败的任务体安全返回未命中(不阻断计费)。
|
||||
func EvalParamMultipliers(rules []ParamRule, taskBody []byte) (float64, []string) {
|
||||
if len(rules) == 0 || len(taskBody) == 0 {
|
||||
return 1.0, nil
|
||||
}
|
||||
var root any
|
||||
if err := json.Unmarshal(taskBody, &root); err != nil {
|
||||
return 1.0, nil
|
||||
}
|
||||
|
||||
total := 1.0
|
||||
var matched []string
|
||||
for _, rule := range rules {
|
||||
if rule.Multiplier <= 0 || !ruleConditionsMatch(rule.Conditions, root) {
|
||||
continue
|
||||
}
|
||||
total *= rule.Multiplier
|
||||
if rule.Note != "" {
|
||||
matched = append(matched, rule.Note)
|
||||
} else {
|
||||
matched = append(matched, strings.Join(conditionPaths(rule.Conditions), " & "))
|
||||
}
|
||||
}
|
||||
if len(matched) == 0 {
|
||||
return 1.0, nil
|
||||
}
|
||||
return total, matched
|
||||
}
|
||||
|
||||
// ruleConditionsMatch 隐式 AND:全部条件满足才命中
|
||||
func ruleConditionsMatch(conds []ParamCondition, root any) bool {
|
||||
for _, cond := range conds {
|
||||
if !conditionMatch(cond, root) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// conditionMatch 单条件匹配:路径提取([*] 扇出)后按运算符判断,任一候选值满足即命中
|
||||
func conditionMatch(cond ParamCondition, root any) bool {
|
||||
values, found := lookupPath(root, cond.Path)
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
switch cond.Match {
|
||||
case "has_value":
|
||||
// 路径存在且任一候选值非 null(false/0/"" 均视为"有值"——与"未传"语义不同)
|
||||
for _, v := range values {
|
||||
if v != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "equals":
|
||||
for _, v := range values {
|
||||
if valuesEqual(v, cond.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "between":
|
||||
arr, ok := cond.Value.([]any)
|
||||
if !ok || len(arr) != 2 {
|
||||
return false
|
||||
}
|
||||
lo, ok1 := toNumber(arr[0])
|
||||
hi, ok2 := toNumber(arr[1])
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
}
|
||||
for _, v := range values {
|
||||
if n, ok := toNumber(v); ok && n >= lo && n <= hi {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default: // gt / gte / lt / lte
|
||||
target, ok := toNumber(cond.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, v := range values {
|
||||
n, ok := toNumber(v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch cond.Match {
|
||||
case "gt":
|
||||
if n > target {
|
||||
return true
|
||||
}
|
||||
case "gte":
|
||||
if n >= target {
|
||||
return true
|
||||
}
|
||||
case "lt":
|
||||
if n < target {
|
||||
return true
|
||||
}
|
||||
case "lte":
|
||||
if n <= target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// lookupPath 按点分路径提取候选值([*] 数组通配扇出)。
|
||||
// found=false 表示路径不存在(键缺失或类型不符);[*] 命中空数组时 found=false。
|
||||
func lookupPath(root any, path string) (values []any, found bool) {
|
||||
segments := strings.Split(path, ".")
|
||||
current := []any{root}
|
||||
for _, seg := range segments {
|
||||
if seg == "" {
|
||||
return nil, false
|
||||
}
|
||||
wildcard := strings.HasSuffix(seg, "[*]")
|
||||
key := seg
|
||||
if wildcard {
|
||||
key = strings.TrimSuffix(seg, "[*]")
|
||||
}
|
||||
var next []any
|
||||
for _, cur := range current {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v, exists := m[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if wildcard {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
next = append(next, arr...)
|
||||
} else {
|
||||
next = append(next, v)
|
||||
}
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
// valuesEqual 等值比较:数字强转后比较("8" == 8),string/bool 严格相等
|
||||
func valuesEqual(v, target any) bool {
|
||||
if target == nil {
|
||||
return false
|
||||
}
|
||||
// 数字优先:两侧都能转数字则按数值比
|
||||
if vn, ok1 := toNumber(v); ok1 {
|
||||
if tn, ok2 := toNumber(target); ok2 {
|
||||
return vn == tn
|
||||
}
|
||||
}
|
||||
vs, ok1 := v.(string)
|
||||
ts, ok2 := target.(string)
|
||||
if ok1 && ok2 {
|
||||
return vs == ts
|
||||
}
|
||||
vb, ok1 := v.(bool)
|
||||
tb, ok2 := target.(bool)
|
||||
if ok1 && ok2 {
|
||||
return vb == tb
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toNumber 数字强转:JSON number(float64)/ 可解析数字字符串 → float64;bool/对象/数组不可转
|
||||
func toNumber(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, true
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
|
||||
if err == nil {
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// conditionPaths 条件路径列表(规则无 note 时作命中说明兜底)
|
||||
func conditionPaths(conds []ParamCondition) []string {
|
||||
paths := make([]string, 0, len(conds))
|
||||
for _, c := range conds {
|
||||
paths = append(paths, c.Path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── lookupPath 路径提取 ──
|
||||
|
||||
func TestLookupPath(t *testing.T) {
|
||||
var root any
|
||||
json.Unmarshal([]byte(`{
|
||||
"model": "m",
|
||||
"seconds": "8",
|
||||
"metadata": {
|
||||
"image": "https://x",
|
||||
"duration": 8,
|
||||
"resolution": null,
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"},
|
||||
{"type": "image_url", "image_url": {"url": "a"}, "role": "first_frame"},
|
||||
{"type": "video_url", "video_url": {"url": "v"}}
|
||||
]
|
||||
}
|
||||
}`), &root)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
found bool
|
||||
count int
|
||||
}{
|
||||
{"model", true, 1},
|
||||
{"seconds", true, 1},
|
||||
{"metadata.image", true, 1},
|
||||
{"metadata.duration", true, 1},
|
||||
{"metadata.resolution", true, 1}, // 存在但值为 null
|
||||
{"metadata.missing", false, 0},
|
||||
{"metadata", true, 1},
|
||||
{"metadata.content", true, 1},
|
||||
{"metadata.content[*].type", true, 3},
|
||||
{"metadata.content[*].video_url.url", true, 1},
|
||||
{"metadata.content[*].role", true, 1},
|
||||
{"metadata.content[*].missing", false, 0},
|
||||
{"", false, 0},
|
||||
{"metadata..image", false, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
values, found := lookupPath(root, c.path)
|
||||
if found != c.found {
|
||||
t.Errorf("lookupPath(%q) found=%v want %v", c.path, found, c.found)
|
||||
continue
|
||||
}
|
||||
if found && len(values) != c.count {
|
||||
t.Errorf("lookupPath(%q) values=%d want %d", c.path, len(values), c.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 匹配语义 ──
|
||||
|
||||
func evalOne(t *testing.T, body string, conds ...ParamCondition) bool {
|
||||
t.Helper()
|
||||
return ruleConditionsMatch(conds, jsonMustParse(t, body))
|
||||
}
|
||||
|
||||
func jsonMustParse(t *testing.T, s string) any {
|
||||
t.Helper()
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(s), &root); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestMatchHasValue(t *testing.T) {
|
||||
// false/0/空串均为"有值"(与未传语义不同);null 不算
|
||||
if !evalOne(t, `{"watermark": false}`, ParamCondition{Path: "watermark", Match: "has_value"}) {
|
||||
t.Error("false should count as has_value")
|
||||
}
|
||||
if !evalOne(t, `{"x": ""}`, ParamCondition{Path: "x", Match: "has_value"}) {
|
||||
t.Error("empty string should count as has_value")
|
||||
}
|
||||
if !evalOne(t, `{"x": 0}`, ParamCondition{Path: "x", Match: "has_value"}) {
|
||||
t.Error("zero should count as has_value")
|
||||
}
|
||||
if evalOne(t, `{"x": null}`, ParamCondition{Path: "x", Match: "has_value"}) {
|
||||
t.Error("null should NOT count as has_value")
|
||||
}
|
||||
if evalOne(t, `{}`, ParamCondition{Path: "x", Match: "has_value"}) {
|
||||
t.Error("missing key should not match")
|
||||
}
|
||||
// 数组:任一元素非 null
|
||||
if !evalOne(t, `{"content":[{"video_url":null},{"video_url":{"url":"v"}}]}`,
|
||||
ParamCondition{Path: "content[*].video_url", Match: "has_value"}) {
|
||||
t.Error("any non-null element should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchEquals(t *testing.T) {
|
||||
// 数字强转:"8" == 8
|
||||
if !evalOne(t, `{"seconds": "8"}`, ParamCondition{Path: "seconds", Match: "equals", Value: float64(8)}) {
|
||||
t.Error(`"8" should equal 8`)
|
||||
}
|
||||
if !evalOne(t, `{"mode": "pro"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) {
|
||||
t.Error("string equals")
|
||||
}
|
||||
if !evalOne(t, `{"flag": true}`, ParamCondition{Path: "flag", Match: "equals", Value: true}) {
|
||||
t.Error("bool equals")
|
||||
}
|
||||
if evalOne(t, `{"mode": "std"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) {
|
||||
t.Error("different string should not equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchComparisons(t *testing.T) {
|
||||
cond := func(match string, val float64) ParamCondition {
|
||||
return ParamCondition{Path: "metadata.duration", Match: match, Value: val}
|
||||
}
|
||||
body := `{"metadata":{"duration":8}}`
|
||||
for _, m := range []string{"gt", "gte", "lt", "lte"} {
|
||||
var want bool
|
||||
switch m {
|
||||
case "gt":
|
||||
want = false
|
||||
case "gte":
|
||||
want = true
|
||||
case "lt":
|
||||
want = false
|
||||
case "lte":
|
||||
want = true
|
||||
}
|
||||
if got := evalOne(t, body, cond(m, 8)); got != want {
|
||||
t.Errorf("%s 8 vs 8 = %v want %v", m, got, want)
|
||||
}
|
||||
}
|
||||
// 字符串数字强转参与比较
|
||||
if !evalOne(t, `{"seconds":"12"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) {
|
||||
t.Error(`"12" gte 10 should match`)
|
||||
}
|
||||
// 非数字字符串不参与比较
|
||||
if evalOne(t, `{"seconds":"abc"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) {
|
||||
t.Error("non-numeric string should not match comparison")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchBetween(t *testing.T) {
|
||||
cond := ParamCondition{Path: "metadata.duration", Match: "between", Value: []any{float64(8), float64(12)}}
|
||||
if !evalOne(t, `{"metadata":{"duration":8}}`, cond) {
|
||||
t.Error("between should be inclusive of min")
|
||||
}
|
||||
if !evalOne(t, `{"metadata":{"duration":12}}`, cond) {
|
||||
t.Error("between should be inclusive of max")
|
||||
}
|
||||
if evalOne(t, `{"metadata":{"duration":7}}`, cond) {
|
||||
t.Error("below min should not match")
|
||||
}
|
||||
// 数组任一元素落入区间
|
||||
if !evalOne(t, `{"arr":[{"d":3},{"d":9}]}`, ParamCondition{Path: "arr[*].d", Match: "between", Value: []any{float64(8), float64(12)}}) {
|
||||
t.Error("any element in range should match")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 规则求值 ──
|
||||
|
||||
func TestEvalParamMultipliers(t *testing.T) {
|
||||
rules := []ParamRule{
|
||||
{
|
||||
// 图生视频:metadata.image 有值 → 1.5
|
||||
Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}},
|
||||
Multiplier: 1.5,
|
||||
Note: "图生视频",
|
||||
},
|
||||
{
|
||||
// 隐式 AND:1080p 且 ≥10 秒 → 2.0
|
||||
Conditions: []ParamCondition{
|
||||
{Path: "metadata.resolution", Match: "equals", Value: "1080p"},
|
||||
{Path: "metadata.duration", Match: "gte", Value: float64(10)},
|
||||
},
|
||||
Multiplier: 2.0,
|
||||
Note: "1080p 长视频",
|
||||
},
|
||||
{
|
||||
// 视频输入折扣
|
||||
Conditions: []ParamCondition{{Path: "metadata.content[*].video_url", Match: "has_value"}},
|
||||
Multiplier: 0.609,
|
||||
Note: "视频输入折扣",
|
||||
},
|
||||
}
|
||||
|
||||
// 命中规则 1(×1.5),规则 2 分辨率不满足
|
||||
m, matched := EvalParamMultipliers(rules, []byte(`{"model":"m","metadata":{"image":"https://x","resolution":"720p","duration":5}}`))
|
||||
if m != 1.5 || len(matched) != 1 || matched[0] != "图生视频" {
|
||||
t.Fatalf("case1: m=%v matched=%v", m, matched)
|
||||
}
|
||||
|
||||
// 命中规则 1 + 规则 2(连乘 3.0)
|
||||
m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":12}}`))
|
||||
if m != 3.0 || len(matched) != 2 {
|
||||
t.Fatalf("case2: m=%v matched=%v", m, matched)
|
||||
}
|
||||
|
||||
// 全不命中 → 1.0 / nil
|
||||
m, matched = EvalParamMultipliers(rules, []byte(`{"model":"m"}`))
|
||||
if m != 1.0 || matched != nil {
|
||||
t.Fatalf("case3: m=%v matched=%v", m, matched)
|
||||
}
|
||||
|
||||
// AND 语义:一个条件不满足整条不命中
|
||||
m, _ = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":5}}`))
|
||||
if m != 1.5 {
|
||||
t.Fatalf("case4: partial-AND should only match rule1, m=%v", m)
|
||||
}
|
||||
|
||||
// 视频输入折扣([*] 路径)
|
||||
m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`))
|
||||
if m != 0.609 || len(matched) != 1 {
|
||||
t.Fatalf("case5: m=%v matched=%v", m, matched)
|
||||
}
|
||||
|
||||
// 无规则 / 空 body / 非法 JSON → 1.0
|
||||
if m, _ := EvalParamMultipliers(nil, []byte(`{}`)); m != 1.0 {
|
||||
t.Error("no rules should be 1.0")
|
||||
}
|
||||
if m, _ := EvalParamMultipliers(rules, nil); m != 1.0 {
|
||||
t.Error("nil body should be 1.0")
|
||||
}
|
||||
if m, _ := EvalParamMultipliers(rules, []byte(`not-json`)); m != 1.0 {
|
||||
t.Error("invalid json should be 1.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalParamMultipliers_MultiplierProductOrder(t *testing.T) {
|
||||
// 无 note 时命中说明回退为路径拼接
|
||||
rules := []ParamRule{
|
||||
{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 2.0},
|
||||
{Conditions: []ParamCondition{{Path: "b", Match: "has_value"}}, Multiplier: 3.0},
|
||||
}
|
||||
m, matched := EvalParamMultipliers(rules, []byte(`{"a":1,"b":1}`))
|
||||
if m != 6.0 || len(matched) != 2 || matched[0] != "a" {
|
||||
t.Fatalf("m=%v matched=%v", m, matched)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 校验 ──
|
||||
|
||||
func TestValidateParamMultipliers(t *testing.T) {
|
||||
ok := []ParamRule{{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: float64(1)}}, Multiplier: 1.5}}
|
||||
if err := ValidateParamMultipliers(ok); err != nil {
|
||||
t.Fatalf("valid rules rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
rule ParamRule
|
||||
}{
|
||||
{"空条件", ParamRule{Conditions: nil, Multiplier: 1.5}},
|
||||
{"倍率为零", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 0}},
|
||||
{"倍率超上限", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 101}},
|
||||
{"路径为空", ParamRule{Conditions: []ParamCondition{{Path: "", Match: "has_value"}}, Multiplier: 1.5}},
|
||||
{"匹配方式非法", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "regex"}}, Multiplier: 1.5}},
|
||||
{"gte 缺值", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte"}}, Multiplier: 1.5}},
|
||||
{"gte 非数字", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: "abc"}}, Multiplier: 1.5}},
|
||||
{"between 非数组", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: float64(1)}}, Multiplier: 1.5}},
|
||||
{"between 单元素", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: []any{float64(1)}}}, Multiplier: 1.5}},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if err := ValidateParamMultipliers([]ParamRule{c.rule}); err == nil {
|
||||
t.Errorf("%s: should be rejected", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── EstimateTaskCost 注入集成 ──
|
||||
|
||||
func TestEstimateTaskCost_ParamMultiplierInjection(t *testing.T) {
|
||||
// per_second 模型 + 参数倍率规则:命中注入 ratios 且费用连乘
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "per_second",
|
||||
TenantMultiplier: 1.0,
|
||||
PerSecondPrices: map[string]float64{"720p": 0.5, "*": 0.5},
|
||||
ParamMultipliers: []ParamRule{
|
||||
{Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}}, Multiplier: 1.5, Note: "图生视频"},
|
||||
{Conditions: []ParamCondition{{Path: "seconds", Match: "gte", Value: float64(10)}}, Multiplier: 2.0, Note: "长视频"},
|
||||
},
|
||||
}
|
||||
// 直接调纯函数路径(estimateTaskCost 只吃 ratios)——先验证求值注入逻辑的等价小函数
|
||||
ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
|
||||
body := []byte(`{"model":"m","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`)
|
||||
|
||||
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); m != 3.0 || len(matched) != 2 {
|
||||
t.Fatalf("eval: m=%v matched=%v", m, matched)
|
||||
}
|
||||
// 模拟 EstimateTaskCost 内部注入(保持与实现一致的键名)
|
||||
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); len(matched) > 0 && m > 0 {
|
||||
ratios[ratioKeyParamMultiplier] = m
|
||||
ratios[ratioKeyParamMatched] = matched[0]
|
||||
}
|
||||
// 注入后费用 = 0.5 × 12 × 1.5 × 2.0 = 18
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios), 18.0, "param multiplier cost")
|
||||
|
||||
// 未命中的任务体不注入(保持 0.5 × 5 缺省 = 2.5)
|
||||
ratios2 := map[string]any{}
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios2), 2.5, "no param match cost")
|
||||
}
|
||||
@@ -57,15 +57,58 @@ type PricingResult struct {
|
||||
MaxOutputTokens int
|
||||
|
||||
// 租户自定义阶梯定价(JSONB 解析后的原始数据,供 CalculateCost 使用)
|
||||
// 租户未自定义时回落为平台阶梯(mdl_pricing 的 min_tokens 档位行)
|
||||
// 租户未自定义时回落为平台阶梯(pricing JSONB 的 tiers 数组)
|
||||
CustomTiers []pricingTierRow `json:"CustomTiers"`
|
||||
|
||||
// 时段定价配置(mdl_pricing 锚点行 time_segments,随定价缓存存储)
|
||||
// 时段定价配置(pricing JSONB 顶层 time_segments,随定价缓存存储)
|
||||
TimeSegments []TimeSegment `json:"time_segments,omitempty"`
|
||||
// 时段乘数(读取时按定价时刻评估;缓存条目中的值是写入时刻的结果,仅作参考)
|
||||
TimeMultiplier float64 `json:"time_multiplier"`
|
||||
// 命中的时段名(写入计费快照,供账单解释;未命中为空)
|
||||
TimeRuleName string `json:"time_rule_name"`
|
||||
|
||||
// 按秒计费矩阵(billing_mode=per_second,mdl_pricing.pricing JSONB 的 prices 键):
|
||||
// 分辨率/规格 → 每秒单价(本位币)。"*" 为兜底价,规格未命中时使用。
|
||||
// 旧缓存条目无此字段(zero value = 空 map),token/per_request 模式不受影响。
|
||||
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"`
|
||||
|
||||
// 参数倍率规则(pricing JSONB 顶层 param_multipliers,横切所有模式):
|
||||
// 按归一化任务体参数匹配,命中规则连乘(EvalParamMultipliers)。
|
||||
// 旧缓存条目无此字段(zero value),不受影响。
|
||||
ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"`
|
||||
}
|
||||
|
||||
// perSecondWildcard 矩阵兜底价键:规格未命中时使用
|
||||
const perSecondWildcard = "*"
|
||||
|
||||
// maxTaskDurationSeconds per_second 计费的时长上限(秒)。
|
||||
// 时长来自用户请求(spec.duration 经 ratios 流入,metadata 路径绕过请求层校验),
|
||||
// 钳制防「天价 duration 刷预扣漏洞/恶意配错」;正常视频模型上限远低于此值。
|
||||
const maxTaskDurationSeconds = 120.0
|
||||
|
||||
// defaultTaskDurationSeconds per_second 计费缺省时长(秒):请求未携带时长信号时的估算基准
|
||||
const defaultTaskDurationSeconds = 5.0
|
||||
|
||||
// LookupPerSecondPrice 按秒计费矩阵查价:spec 未命中依次回退 "*" 兜底价、矩阵最低价。
|
||||
// 返回 0 表示矩阵为空/全零(调用方按未配价兜底处理)。
|
||||
func LookupPerSecondPrice(prices map[string]float64, spec string) float64 {
|
||||
if len(prices) == 0 {
|
||||
return 0
|
||||
}
|
||||
if p, ok := prices[spec]; ok && p > 0 {
|
||||
return p
|
||||
}
|
||||
if p, ok := prices[perSecondWildcard]; ok && p > 0 {
|
||||
return p
|
||||
}
|
||||
// 无 "*" 且未命中:取矩阵最低价兜底,保证新规格上线未配价不 fail
|
||||
min := 0.0
|
||||
for _, p := range prices {
|
||||
if p > 0 && (min == 0 || p < min) {
|
||||
min = p
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// ClearTenantPriceCache 清除租户的所有模型价格缓存
|
||||
@@ -138,45 +181,20 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
return nil, gerror.Newf("model not found: %s", modelName)
|
||||
}
|
||||
|
||||
// 2. 从 mdl_pricing 获取定价(全行读取:锚点行 min_tokens=0 是默认价与时段配置载体,
|
||||
// 其余行是平台阶梯档位。此前只读锚点行导致平台阶梯不参与计费,退化为第一档平价)
|
||||
// 2. 从 mdl_pricing 获取定价:每模型一行(uk_mdl_pricing_model),pricing JSONB 为唯一真相
|
||||
type pricingRow struct {
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
MaxTokens *int64 `json:"max_tokens"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"`
|
||||
TimeSegments string `json:"time_segments"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
Pricing string `json:"pricing"` // JSONB 计费详情(按 billing_mode 单模式存储)
|
||||
}
|
||||
|
||||
var pricingRows []pricingRow
|
||||
var pricing *pricingRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", model.ID).
|
||||
OrderAsc("min_tokens").
|
||||
Scan(&pricingRows)
|
||||
Scan(&pricing)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "query model pricing")
|
||||
}
|
||||
|
||||
// 锚点行(默认价)与平台阶梯(含锚点行作为第一档,与租户端 buildTiers 口径一致)
|
||||
var pricing *pricingRow
|
||||
var platformTiers []pricingTierRow
|
||||
for i := range pricingRows {
|
||||
row := &pricingRows[i]
|
||||
if row.MinTokens == 0 {
|
||||
pricing = row
|
||||
}
|
||||
platformTiers = append(platformTiers, pricingTierRow{
|
||||
MinTokens: row.MinTokens,
|
||||
MaxTokens: row.MaxTokens,
|
||||
InputPrice: row.InputPrice,
|
||||
OutputPrice: row.OutputPrice,
|
||||
})
|
||||
}
|
||||
|
||||
billingMode := "token"
|
||||
inputPrice := 0.0
|
||||
outputPrice := 0.0
|
||||
@@ -185,20 +203,44 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
var perRequestPrice float64
|
||||
cacheReadPrice := 0.0
|
||||
cacheCreationPrice := 0.0
|
||||
var platformTiers []pricingTierRow
|
||||
var perSecondPrices map[string]float64
|
||||
var timeSegments []TimeSegment
|
||||
var paramMultipliers []ParamRule
|
||||
|
||||
if pricing != nil {
|
||||
if pricing.BillingMode != "" {
|
||||
billingMode = pricing.BillingMode
|
||||
}
|
||||
inputPrice = pricing.InputPrice
|
||||
outputPrice = pricing.OutputPrice
|
||||
baseInputPrice = pricing.InputPrice
|
||||
baseOutputPrice = pricing.OutputPrice
|
||||
if pricing.PerRequestPrice != nil {
|
||||
perRequestPrice = *pricing.PerRequestPrice
|
||||
// pricing JSONB 解析(唯一真相;空/解析失败按未配价处理,validatePricingConfigured fail-closed 拦截)
|
||||
if pricing.Pricing != "" && pricing.Pricing != "null" {
|
||||
blob := &PricingBlob{}
|
||||
if err := json.Unmarshal([]byte(pricing.Pricing), blob); err != nil {
|
||||
g.Log().Warningf(ctx, "billing: 模型 %s 定价 JSON 解析失败,按未配价处理: %v", modelName, err)
|
||||
} else {
|
||||
if blob.InputPrice != nil {
|
||||
inputPrice = *blob.InputPrice
|
||||
baseInputPrice = *blob.InputPrice
|
||||
}
|
||||
if blob.OutputPrice != nil {
|
||||
outputPrice = *blob.OutputPrice
|
||||
baseOutputPrice = *blob.OutputPrice
|
||||
}
|
||||
if blob.CacheReadPrice != nil {
|
||||
cacheReadPrice = *blob.CacheReadPrice
|
||||
}
|
||||
if blob.CacheCreationPrice != nil {
|
||||
cacheCreationPrice = *blob.CacheCreationPrice
|
||||
}
|
||||
if blob.Price != nil {
|
||||
perRequestPrice = *blob.Price
|
||||
}
|
||||
platformTiers = blob.Tiers
|
||||
perSecondPrices = blob.Prices
|
||||
timeSegments = blob.TimeSegments
|
||||
paramMultipliers = blob.ParamMultipliers
|
||||
}
|
||||
}
|
||||
cacheReadPrice = pricing.CacheReadPrice
|
||||
cacheCreationPrice = pricing.CacheCreationPrice
|
||||
}
|
||||
|
||||
// 3. 查租户独立价格(mdl_tenant_models)
|
||||
@@ -272,21 +314,12 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(mdl_pricing 档位行)。
|
||||
// 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(pricing JSONB tiers 数组)。
|
||||
// 计费模式非 tiered 时 computeCost 不会读取 CustomTiers,无副作用
|
||||
if len(customTiers) == 0 {
|
||||
customTiers = platformTiers
|
||||
}
|
||||
|
||||
// 2.6 时段定价(锚点行 JSONB;解析失败按默认价处理并告警,不阻断计费)
|
||||
var timeSegments []TimeSegment
|
||||
if pricing != nil && pricing.TimeSegments != "" && pricing.TimeSegments != "null" {
|
||||
if err := json.Unmarshal([]byte(pricing.TimeSegments), &timeSegments); err != nil {
|
||||
g.Log().Warningf(ctx, "billing: 模型 %s 时段定价配置解析失败,按默认价处理: %v", modelName, err)
|
||||
timeSegments = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 级别折扣 fallback:当租户×模型维度未设置倍率时,使用租户级别的 price_multiplier
|
||||
if tenantMultiplier == 1.0 {
|
||||
levelMultiplier := GetLevelPriceMultiplier(ctx, tenantID)
|
||||
@@ -330,6 +363,8 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill
|
||||
MaxOutputTokens: model.MaxOutputTokens,
|
||||
CustomTiers: customTiers,
|
||||
TimeSegments: timeSegments,
|
||||
PerSecondPrices: perSecondPrices,
|
||||
ParamMultipliers: paramMultipliers,
|
||||
}
|
||||
|
||||
// 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考)
|
||||
@@ -588,6 +623,10 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error {
|
||||
if pricing.PerRequestPrice <= 0 {
|
||||
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (per_request price not set)", modelName)
|
||||
}
|
||||
case "per_second":
|
||||
if LookupPerSecondPrice(pricing.PerSecondPrices, perSecondWildcard) <= 0 {
|
||||
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (per_second prices not set)", modelName)
|
||||
}
|
||||
default: // token / tiered
|
||||
if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && !tiersHavePrice(pricing.CustomTiers) {
|
||||
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s", modelName)
|
||||
@@ -596,6 +635,136 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PricingItemInput BuildPricingBlob 的输入项(管理端 SetModelPricing / 模型导入共用,
|
||||
// 与 api/admin/v1.PricingItem 字段一一对应但避免 billing 反向依赖 api 层)
|
||||
type PricingItemInput struct {
|
||||
BillingMode string
|
||||
MinTokens int64
|
||||
MaxTokens *int64
|
||||
InputPrice float64
|
||||
OutputPrice float64
|
||||
PerRequestPrice *float64
|
||||
CacheReadPrice float64
|
||||
CacheCreationPrice float64
|
||||
PerSecondPrices map[string]float64
|
||||
}
|
||||
|
||||
// BuildPricingBlob 从全量替换语义的定价项列表构造 pricing JSONB(按 billing_mode 单模式存储)。
|
||||
// 校验规则(设计文档 3.4):金额非负;per_second 矩阵非空且至少一档正价(建议配 "*" 兜底),
|
||||
// 仅允许单锚点行;tiered 阶梯按 min_tokens 升序整理。
|
||||
func BuildPricingBlob(items []PricingItemInput) (*PricingBlob, error) {
|
||||
if len(items) == 0 {
|
||||
return &PricingBlob{}, nil
|
||||
}
|
||||
mode := items[0].BillingMode
|
||||
anchor := items[0]
|
||||
|
||||
switch mode {
|
||||
case "per_second":
|
||||
if len(items) > 1 {
|
||||
return nil, gerror.New("per_second 计费模式只允许一行定价(分辨率矩阵)")
|
||||
}
|
||||
if anchor.MinTokens != 0 {
|
||||
return nil, gerror.New("per_second 计费模式要求 min_tokens=0 的定价行")
|
||||
}
|
||||
if len(anchor.PerSecondPrices) == 0 {
|
||||
return nil, gerror.New("per_second 计费模式要求配置分辨率单价矩阵(per_second_prices)")
|
||||
}
|
||||
hasPositive := false
|
||||
for spec, price := range anchor.PerSecondPrices {
|
||||
if spec == "" {
|
||||
return nil, gerror.New("per_second_prices 存在空规格键")
|
||||
}
|
||||
if price < 0 {
|
||||
return nil, gerror.Newf("per_second_prices[%s] 单价为负数", spec)
|
||||
}
|
||||
if price > 0 {
|
||||
hasPositive = true
|
||||
}
|
||||
}
|
||||
if !hasPositive {
|
||||
return nil, gerror.New("per_second_prices 至少需要一档正价(建议额外配置 \"*\" 兜底价)")
|
||||
}
|
||||
return &PricingBlob{Unit: "second", Prices: anchor.PerSecondPrices}, nil
|
||||
|
||||
case "per_request":
|
||||
blob := &PricingBlob{}
|
||||
if anchor.PerRequestPrice != nil {
|
||||
blob.Price = anchor.PerRequestPrice
|
||||
}
|
||||
return blob, nil
|
||||
|
||||
case "tiered":
|
||||
blob := &PricingBlob{
|
||||
Tiers: make([]pricingTierRow, 0, len(items)),
|
||||
CacheReadPrice: positivePtr(anchor.CacheReadPrice),
|
||||
CacheCreationPrice: positivePtr(anchor.CacheCreationPrice),
|
||||
}
|
||||
for _, item := range items {
|
||||
blob.Tiers = append(blob.Tiers, pricingTierRow{
|
||||
MinTokens: item.MinTokens,
|
||||
MaxTokens: item.MaxTokens,
|
||||
InputPrice: item.InputPrice,
|
||||
OutputPrice: item.OutputPrice,
|
||||
})
|
||||
}
|
||||
return blob, nil
|
||||
|
||||
default: // token
|
||||
return &PricingBlob{
|
||||
InputPrice: positivePtr(anchor.InputPrice),
|
||||
OutputPrice: positivePtr(anchor.OutputPrice),
|
||||
CacheReadPrice: positivePtr(anchor.CacheReadPrice),
|
||||
CacheCreationPrice: positivePtr(anchor.CacheCreationPrice),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// positivePtr 正值取指针(0/负值返回 nil,JSONB 中省略该键由旧列兜底语义对齐)
|
||||
func positivePtr(v float64) *float64 {
|
||||
if v > 0 {
|
||||
return &v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParsePricingBlob 解析 mdl_pricing.pricing JSONB(管理端/租户端展示路径复用)。
|
||||
// 空串/null/解析失败返回 nil,调用方按未配价处理。
|
||||
func ParsePricingBlob(raw string) *PricingBlob {
|
||||
if raw == "" || raw == "null" {
|
||||
return nil
|
||||
}
|
||||
blob := &PricingBlob{}
|
||||
if err := json.Unmarshal([]byte(raw), blob); err != nil {
|
||||
return nil
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
// PricingBlob mdl_pricing.pricing JSONB 的内存形态(按 billing_mode 单模式存储,单一真相)。
|
||||
// 设计约束(docs/模型定价存储JSON化与按秒计费设计.md):
|
||||
// - 数值一律 float64 number(禁止 decimal 直接 marshal 进 JSONB——会变带引号字符串);
|
||||
// - time_segments 并入顶层(横切所有模式);
|
||||
// - 租户覆盖不逐格配:mdl_tenant_models.multiplier 作用于整个矩阵。
|
||||
type PricingBlob struct {
|
||||
// token 模式
|
||||
InputPrice *float64 `json:"input_price,omitempty"`
|
||||
OutputPrice *float64 `json:"output_price,omitempty"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price,omitempty"`
|
||||
CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"`
|
||||
// tiered 模式:档位数组(min_tokens 升序,最后一档 max_tokens 可为 null)
|
||||
Tiers []pricingTierRow `json:"tiers,omitempty"`
|
||||
// per_request 模式
|
||||
Price *float64 `json:"price,omitempty"`
|
||||
// per_second 模式:规格 → 每秒单价矩阵,"*" 为兜底价
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Prices map[string]float64 `json:"prices,omitempty"`
|
||||
// 横切:时段定价
|
||||
TimeSegments []TimeSegment `json:"time_segments,omitempty"`
|
||||
// 横切:参数倍率(按归一化任务体参数匹配,命中规则连乘;所有计费模式共享)
|
||||
ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"`
|
||||
}
|
||||
|
||||
// tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。
|
||||
// 平台阶梯兜底后 CustomTiers 可能恒非空,全 0 价阶梯不得视为「已配置定价」放行
|
||||
func tiersHavePrice(tiers []pricingTierRow) bool {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildPricingBlob_PerSecond 按秒模式:矩阵校验 + JSON 往返(数值必须是 number 而非带引号字符串)
|
||||
func TestBuildPricingBlob_PerSecond(t *testing.T) {
|
||||
blob, err := BuildPricingBlob([]PricingItemInput{{
|
||||
BillingMode: "per_second",
|
||||
PerSecondPrices: map[string]float64{"480p": 0.25, "720p": 0.5, "*": 0.5},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if blob.Unit != "second" || blob.Prices["720p"] != 0.5 {
|
||||
t.Fatalf("unexpected blob: %+v", blob)
|
||||
}
|
||||
|
||||
// JSONB 契约:数值序列化为 number(decimal 直接 marshal 会变字符串,此处为 float64)
|
||||
raw, _ := json.Marshal(blob)
|
||||
if !json.Valid(raw) {
|
||||
t.Fatalf("invalid json: %s", raw)
|
||||
}
|
||||
var back struct {
|
||||
Unit string `json:"unit"`
|
||||
Prices map[string]float64 `json:"prices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &back); err != nil || back.Prices["720p"] != 0.5 {
|
||||
t.Fatalf("json roundtrip failed: %v %v", err, back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPricingBlob_PerSecondValidation(t *testing.T) {
|
||||
// 多行拒绝
|
||||
if _, err := BuildPricingBlob([]PricingItemInput{
|
||||
{BillingMode: "per_second", PerSecondPrices: map[string]float64{"*": 0.5}},
|
||||
{BillingMode: "per_second", MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}},
|
||||
}); err == nil {
|
||||
t.Fatal("expected multi-row rejection")
|
||||
}
|
||||
// 空矩阵拒绝
|
||||
if _, err := BuildPricingBlob([]PricingItemInput{{BillingMode: "per_second"}}); err == nil {
|
||||
t.Fatal("expected empty matrix rejection")
|
||||
}
|
||||
// 全零矩阵拒绝
|
||||
if _, err := BuildPricingBlob([]PricingItemInput{
|
||||
{BillingMode: "per_second", PerSecondPrices: map[string]float64{"720p": 0}},
|
||||
}); err == nil {
|
||||
t.Fatal("expected all-zero matrix rejection")
|
||||
}
|
||||
// 负价拒绝
|
||||
if _, err := BuildPricingBlob([]PricingItemInput{
|
||||
{BillingMode: "per_second", PerSecondPrices: map[string]float64{"720p": -0.1, "*": 0.5}},
|
||||
}); err == nil {
|
||||
t.Fatal("expected negative price rejection")
|
||||
}
|
||||
// 空规格键拒绝
|
||||
if _, err := BuildPricingBlob([]PricingItemInput{
|
||||
{BillingMode: "per_second", PerSecondPrices: map[string]float64{"": 0.5}},
|
||||
}); err == nil {
|
||||
t.Fatal("expected empty spec key rejection")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPricingBlob_TokenAndTiered token/tiered 模式构造与阶梯数组
|
||||
func TestBuildPricingBlob_TokenAndTiered(t *testing.T) {
|
||||
blob, err := BuildPricingBlob([]PricingItemInput{{
|
||||
BillingMode: "token",
|
||||
InputPrice: 15,
|
||||
OutputPrice: 75,
|
||||
CacheReadPrice: 1.5,
|
||||
}})
|
||||
if err != nil || blob.InputPrice == nil || *blob.InputPrice != 15 {
|
||||
t.Fatalf("token blob: %+v err=%v", blob, err)
|
||||
}
|
||||
|
||||
max := int64(100000)
|
||||
blob, err = BuildPricingBlob([]PricingItemInput{
|
||||
{BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5},
|
||||
{BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60},
|
||||
})
|
||||
if err != nil || len(blob.Tiers) != 2 {
|
||||
t.Fatalf("tiered blob: %+v err=%v", blob, err)
|
||||
}
|
||||
if blob.Tiers[1].MaxTokens != nil {
|
||||
t.Fatalf("last tier max_tokens should be null: %+v", blob.Tiers[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPricingBlob_ZeroPricesOmitted 0 值字段省略(omitempty):JSON 中无该键,
|
||||
// 加载侧旧列兜底语义对齐(0 价模型读 JSON 得 0,与旧行为一致)
|
||||
func TestBuildPricingBlob_ZeroPricesOmitted(t *testing.T) {
|
||||
blob, err := BuildPricingBlob([]PricingItemInput{{BillingMode: "token"}})
|
||||
if err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(blob)
|
||||
if string(raw) != "{}" {
|
||||
t.Fatalf("expected empty blob json, got %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//go:build e2e
|
||||
|
||||
// 定价 JSON 化完整性校验(需真实 DB)。默认 go test 不编译本文件(与 e2e_time_segment_test 同策略:
|
||||
// 无条件引入 pgsql 驱动会让默认单测连上真实库,billing_currency 等部署配置泄漏进单元测试)。
|
||||
// 跑法:go test -tags e2e -run TestPricingJSONComplete ./internal/logic/billing/
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// TestPricingJSONComplete contract 迁移(000023)后完整性校验:
|
||||
// 每模型一行、pricing JSONB 非空且可解析、tiered 模式含阶梯数组。
|
||||
func TestPricingJSONComplete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 每模型恰好一行(uk 约束之外的双重确认)
|
||||
dupVal, err := g.DB().GetValue(ctx,
|
||||
`SELECT COUNT(*) FROM (SELECT model_id FROM mdl_pricing GROUP BY model_id HAVING count(*) > 1) d`)
|
||||
if err != nil {
|
||||
t.Fatalf("query dup rows: %v", err)
|
||||
}
|
||||
if n := dupVal.Int(); n > 0 {
|
||||
t.Errorf("%d 个模型存在多行定价(contract 未收敛)", n)
|
||||
}
|
||||
|
||||
// 2. pricing JSONB 全部非空
|
||||
nullVal, err := g.DB().GetValue(ctx,
|
||||
`SELECT COUNT(*) FROM mdl_pricing WHERE pricing IS NULL`)
|
||||
if err != nil {
|
||||
t.Fatalf("query null pricing: %v", err)
|
||||
}
|
||||
if n := nullVal.Int(); n > 0 {
|
||||
t.Errorf("%d 行 pricing JSON 为空(唯一真相缺失,计费将按未配价 fail-closed)", n)
|
||||
}
|
||||
|
||||
// 3. JSON 可解析 + 模式字段完整性(tiered 必含 tiers 数组、per_second 必含矩阵)
|
||||
rows, err := g.DB().GetAll(ctx,
|
||||
`SELECT model_id, billing_mode, pricing FROM mdl_pricing WHERE pricing IS NOT NULL`)
|
||||
if err != nil {
|
||||
t.Fatalf("query rows: %v", err)
|
||||
}
|
||||
bad := 0
|
||||
for _, row := range rows {
|
||||
blob := &PricingBlob{}
|
||||
if err := json.Unmarshal([]byte(row["pricing"].String()), blob); err != nil {
|
||||
t.Errorf("model_id=%v pricing JSON 解析失败: %v", row["model_id"], err)
|
||||
bad++
|
||||
continue
|
||||
}
|
||||
switch row["billing_mode"].String() {
|
||||
case "tiered":
|
||||
if len(blob.Tiers) == 0 {
|
||||
t.Errorf("model_id=%v tiered 模式 tiers 为空", row["model_id"])
|
||||
bad++
|
||||
}
|
||||
case "per_second":
|
||||
if len(blob.Prices) == 0 {
|
||||
t.Errorf("model_id=%v per_second 模式 prices 矩阵为空", row["model_id"])
|
||||
bad++
|
||||
}
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
t.Fatalf("%d 行 pricing JSON 内容异常", bad)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -24,12 +25,20 @@ func NewTaskBillingProvider() common.TaskBillingProvider {
|
||||
}
|
||||
|
||||
// EstimateTaskCost 估算任务费用
|
||||
// ratios 包含计费比率(如 video_input 折扣)和预估参数(如 duration 秒数、resolution 乘数)
|
||||
func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error) {
|
||||
// ratios 包含计费上下文(float64 乘数如 video_input 折扣 + spec.* 规格事实值,见 TaskAdaptor.EstimateBilling);
|
||||
// taskBody 为归一化任务体,参数倍率(param_multipliers)命中时就地注入 ratios 随任务持久化
|
||||
func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error) {
|
||||
pricing, err := GetModelPrice(ctx, tenantID, modelName)
|
||||
if err != nil {
|
||||
return NewFromFloat(0.01), nil
|
||||
}
|
||||
// 参数倍率求值:命中注入 ratios(预扣、per_second 结算、token 重算经 applyRatioMultipliers 自动同口径)
|
||||
if len(pricing.ParamMultipliers) > 0 && len(taskBody) > 0 && ratios != nil {
|
||||
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, taskBody); len(matched) > 0 && m > 0 {
|
||||
ratios[ratioKeyParamMultiplier] = m
|
||||
ratios[ratioKeyParamMatched] = strings.Join(matched, "|")
|
||||
}
|
||||
}
|
||||
return estimateTaskCost(pricing, ratios), nil
|
||||
}
|
||||
|
||||
@@ -38,32 +47,55 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID
|
||||
// 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。
|
||||
const imagePlaceholderPreDeduct = 0.1
|
||||
|
||||
// estimateTaskCost 纯函数:根据定价与计费比率估算任务预扣费用,不依赖数据库/缓存,便于单测。
|
||||
// estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。
|
||||
//
|
||||
// 计费口径按任务类型分流:
|
||||
// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")×
|
||||
// spec.duration 秒 × 租户乘数 × 时段乘数 × 附加乘数(video_input 折扣等);
|
||||
// - per_request(按次计费,图片/音乐等):直接取按次单价;
|
||||
// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按
|
||||
// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价;
|
||||
// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径);
|
||||
// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价,
|
||||
// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出
|
||||
// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。
|
||||
func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) decimal.Decimal {
|
||||
func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Decimal {
|
||||
if pricing == nil {
|
||||
return NewFromFloat(0.01)
|
||||
}
|
||||
|
||||
var costD decimal.Decimal
|
||||
switch {
|
||||
case pricing.BillingMode == "per_second":
|
||||
// 按秒计费:矩阵查价 × 时长 × 租户乘数 × 时段乘数
|
||||
duration := defaultTaskDurationSeconds
|
||||
if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 {
|
||||
duration = d
|
||||
}
|
||||
// 时长来自用户请求,钳制上限防天价预扣(正常视频模型远低于该上限)
|
||||
if duration > maxTaskDurationSeconds {
|
||||
duration = maxTaskDurationSeconds
|
||||
}
|
||||
spec, _ := ratioString(ratios, "spec.resolution")
|
||||
price := LookupPerSecondPrice(pricing.PerSecondPrices, spec)
|
||||
if price <= 0 {
|
||||
// 矩阵全零/为空:按未配价占位预扣,结算多退少补
|
||||
costD = NewFromFloat(imagePlaceholderPreDeduct)
|
||||
break
|
||||
}
|
||||
costD = NewFromFloat(price).
|
||||
Mul(NewFromFloat(duration)).
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier)).
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
case pricing.BillingMode == "per_request":
|
||||
// 按次计费:直接用单价
|
||||
costD = NewFromFloat(pricing.PerRequestPrice)
|
||||
case pricing.OutputPrice > 0 && hasDurationSignal(ratios):
|
||||
duration := 5.0 // 默认 5 秒
|
||||
if d, ok := ratios["duration"]; ok && d > 0 {
|
||||
if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 {
|
||||
duration = d
|
||||
}
|
||||
resolutionMul := 2.25 // 默认 720p
|
||||
if r, ok := ratios["resolution"]; ok && r > 0 {
|
||||
if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 {
|
||||
resolutionMul = r
|
||||
}
|
||||
|
||||
@@ -90,13 +122,9 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) decimal
|
||||
}
|
||||
}
|
||||
|
||||
// 应用附加比率(video_input 折扣等)
|
||||
for k, ratio := range ratios {
|
||||
if k == "duration" || k == "resolution" {
|
||||
continue
|
||||
}
|
||||
costD = costD.Mul(NewFromFloat(ratio))
|
||||
}
|
||||
// 应用附加比率(video_input 折扣等):只乘 float 乘数值,
|
||||
// 跳过 duration/resolution(已在时长类分支消费)与 spec.*(规格事实值非乘数)
|
||||
costD = applyRatioMultipliers(costD, ratios, "duration", "resolution")
|
||||
|
||||
minCost := NewFromFloat(0.01)
|
||||
if costD.LessThan(minCost) {
|
||||
@@ -105,10 +133,59 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) decimal
|
||||
return RoundMoney(costD)
|
||||
}
|
||||
|
||||
// hasDurationSignal 判断计费比率里是否携带时长类任务(视频)的 duration/resolution 信号。
|
||||
// ratioFloat 取计费上下文中的 float 值(JSONB 往返后数字均为 float64)
|
||||
func ratioFloat(ratios map[string]any, key string) (float64, bool) {
|
||||
v, ok := ratios[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// ratioString 取计费上下文中的 string 值(spec.* 规格原值)
|
||||
func ratioString(ratios map[string]any, key string) (string, bool) {
|
||||
v, ok := ratios[key]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// applyRatioMultipliers 把计费上下文中的 float 乘数按序连乘到 cost 上。
|
||||
// spec.*(规格事实值)与 string 值自动跳过;skip 显式追加需跳过的键。
|
||||
func applyRatioMultipliers(cost decimal.Decimal, ratios map[string]any, skip ...string) decimal.Decimal {
|
||||
if len(ratios) == 0 {
|
||||
return cost
|
||||
}
|
||||
for k, v := range ratios {
|
||||
if strings.HasPrefix(k, "spec.") {
|
||||
continue
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok || f <= 0 {
|
||||
continue
|
||||
}
|
||||
skipped := false
|
||||
for _, s := range skip {
|
||||
if k == s {
|
||||
skipped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if skipped {
|
||||
continue
|
||||
}
|
||||
cost = cost.Mul(NewFromFloat(f))
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
// hasDurationSignal 判断计费上下文里是否携带时长类任务(视频)的 duration/resolution 信号。
|
||||
// 视频提交管线(VolcengineVideoAdaptor.EstimateBilling)必然写入这两个键;图片等同步/异步
|
||||
// 任务传 nil ratios,据此区分「该走视频 token 估算」还是「按次计费」。
|
||||
func hasDurationSignal(ratios map[string]float64) bool {
|
||||
func hasDurationSignal(ratios map[string]any) bool {
|
||||
if ratios == nil {
|
||||
return false
|
||||
}
|
||||
@@ -152,9 +229,9 @@ func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID
|
||||
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)
|
||||
// totalTokens/completionTokens: 上游返回的 token 用量
|
||||
// ratios: 提交时保存的计费比率(如 video_input 折扣)
|
||||
// ratios: 提交时保存的计费上下文(如 video_input 折扣)
|
||||
// billAt: 任务受理时刻(时段定价按该时刻评估;零值按当前时刻兜底)
|
||||
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*common.SettlementResult, error) {
|
||||
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*common.SettlementResult, error) {
|
||||
diff := SubtractMoney(actualCost, preDeductAmount)
|
||||
|
||||
// 1. 获取钱包
|
||||
@@ -334,6 +411,14 @@ func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualC
|
||||
return bd
|
||||
}
|
||||
|
||||
// per_second 模式:无 token 语义,费用整体记 BaseCost(折扣前成本),与 per_request 快照口径一致。
|
||||
// 命中档的每秒价在预扣时已消费,此处不重复记录(CostBreakdown 无单价字段先例)
|
||||
if pricing.BillingMode == "per_second" {
|
||||
bd.BaseCost = preMultiplierCost(actualCost, pricing)
|
||||
bd.TotalCost = actualCost
|
||||
return bd
|
||||
}
|
||||
|
||||
// token 模式但没有真实 token 用量(图片等扁平计费任务):把费用整体记为 BaseCost,
|
||||
// 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行
|
||||
// (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。
|
||||
@@ -404,7 +489,7 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
|
||||
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 时段乘数 × 附加比率
|
||||
// billAt 为任务受理时刻(时段定价按该时刻评估);如果模型没有配置 token 单价(纯按次计费),
|
||||
// 返回 0 表示不做 token 重算。
|
||||
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error) {
|
||||
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error) {
|
||||
if totalTokens <= 0 {
|
||||
return Zero, nil
|
||||
}
|
||||
@@ -414,6 +499,12 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
// per_second 模式按秒计价,token 数与费用无关:返回 0 让结算保持预扣金额,
|
||||
// 防止上游附带返回 token 用量时把按秒任务重算成 token 价
|
||||
if pricing.BillingMode == "per_second" {
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
// 需要 output_price > 0 才能做 token 重算
|
||||
if pricing.OutputPrice <= 0 {
|
||||
return Zero, nil
|
||||
@@ -427,13 +518,8 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
|
||||
// 应用附加比率(视频输入折扣等)
|
||||
// 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘
|
||||
for k, ratio := range ratios {
|
||||
if k == "duration" || k == "resolution" {
|
||||
continue
|
||||
}
|
||||
costD = costD.Mul(NewFromFloat(ratio))
|
||||
}
|
||||
// 注意:跳过 duration/resolution 与 spec.*——时长/规格已体现在上游返回的 token 数中,不应再乘
|
||||
costD = applyRatioMultipliers(costD, ratios, "duration", "resolution")
|
||||
|
||||
costD = RoundMoney(costD)
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) {
|
||||
OutputPrice: 30.0,
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
ratios := map[string]float64{"duration": 5, "resolution": 2.25}
|
||||
ratios := map[string]any{"duration": 5.0, "resolution": 2.25}
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios), 3.375, "video cost")
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ func TestEstimateTaskCost_VideoInputDiscount(t *testing.T) {
|
||||
OutputPrice: 30.0,
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
ratios := map[string]float64{"duration": 5, "resolution": 2.25, "video_input": 0.5}
|
||||
ratios := map[string]any{"duration": 5.0, "resolution": 2.25, "video_input": 0.5}
|
||||
// 3.375 × 0.5 = 1.6875
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost")
|
||||
}
|
||||
@@ -238,13 +238,86 @@ func TestHasDurationSignal(t *testing.T) {
|
||||
if hasDurationSignal(nil) {
|
||||
t.Error("nil ratios should not signal duration")
|
||||
}
|
||||
if hasDurationSignal(map[string]float64{"video_input": 0.5}) {
|
||||
if hasDurationSignal(map[string]any{"video_input": 0.5}) {
|
||||
t.Error("video_input alone should not signal duration")
|
||||
}
|
||||
if !hasDurationSignal(map[string]float64{"duration": 5}) {
|
||||
if !hasDurationSignal(map[string]any{"duration": 5.0}) {
|
||||
t.Error("duration key should signal")
|
||||
}
|
||||
if !hasDurationSignal(map[string]float64{"resolution": 2.25}) {
|
||||
if !hasDurationSignal(map[string]any{"resolution": 2.25}) {
|
||||
t.Error("resolution key should signal")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_PerSecond 按秒计费:矩阵查价 × 秒数 × 租户乘数。
|
||||
// spec.resolution 命中矩阵档位;未命中回退 "*";无 "*" 回退矩阵最低价。
|
||||
func TestEstimateTaskCost_PerSecond(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "per_second",
|
||||
TenantMultiplier: 1.0,
|
||||
PerSecondPrices: map[string]float64{"480p": 0.25, "720p": 0.5, "1080p": 1.0, "*": 0.5},
|
||||
}
|
||||
|
||||
// 720p × 8s × 1.0 = 4.0
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 8.0, "spec.resolution": "720p",
|
||||
}), 4.0, "per_second 720p")
|
||||
|
||||
// 未配规格(2k)→ "*" 兜底 0.5 × 8s = 4.0
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 8.0, "spec.resolution": "2k",
|
||||
}), 4.0, "per_second wildcard fallback")
|
||||
|
||||
// 无时长信号 → 缺省 5s × "*" 0.5 = 2.5
|
||||
assertDecimal(t, estimateTaskCost(pricing, nil), 2.5, "per_second default duration")
|
||||
|
||||
// 租户乘数与附加折扣叠加:1080p × 10s × 0.8(租户) × 0.6(video_input) = 4.8
|
||||
p2 := &PricingResult{
|
||||
BillingMode: "per_second",
|
||||
TenantMultiplier: 0.8,
|
||||
PerSecondPrices: map[string]float64{"1080p": 1.0, "*": 1.0},
|
||||
}
|
||||
assertDecimal(t, estimateTaskCost(p2, map[string]any{
|
||||
"spec.duration": 10.0, "spec.resolution": "1080p", "video_input": 0.6,
|
||||
}), 4.8, "per_second with multipliers")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_PerSecondDurationClamp 天价时长钳制到上限,防 metadata 伪造刷预扣漏洞。
|
||||
func TestEstimateTaskCost_PerSecondDurationClamp(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "per_second",
|
||||
TenantMultiplier: 1.0,
|
||||
PerSecondPrices: map[string]float64{"*": 0.5},
|
||||
}
|
||||
// 999999s 钳到 120s × 0.5 = 60
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 999999.0,
|
||||
}), 60.0, "per_second duration clamp")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_PerSecondEmptyMatrix 矩阵为空/全零:占位预扣 $0.1 兜底(结算多退少补)。
|
||||
func TestEstimateTaskCost_PerSecondEmptyMatrix(t *testing.T) {
|
||||
pricing := &PricingResult{BillingMode: "per_second", TenantMultiplier: 1.0}
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.duration": 8.0}), 0.1, "per_second empty matrix placeholder")
|
||||
}
|
||||
|
||||
// TestLookupPerSecondPrice 矩阵查价回退链:命中档 → "*" → 矩阵最低价 → 0。
|
||||
func TestLookupPerSecondPrice(t *testing.T) {
|
||||
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.3}, "720p"); p != 0.5 {
|
||||
t.Errorf("exact hit = %v", p)
|
||||
}
|
||||
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "*": 0.3}, "4k"); p != 0.3 {
|
||||
t.Errorf("wildcard = %v", p)
|
||||
}
|
||||
// 无 "*":回退最低价
|
||||
if p := LookupPerSecondPrice(map[string]float64{"720p": 0.5, "1080p": 1.0}, "4k"); p != 0.5 {
|
||||
t.Errorf("min fallback = %v", p)
|
||||
}
|
||||
if p := LookupPerSecondPrice(nil, "720p"); p != 0 {
|
||||
t.Errorf("empty matrix = %v", p)
|
||||
}
|
||||
// 零价档位不参与回退
|
||||
if p := LookupPerSecondPrice(map[string]float64{"720p": 0}, "720p"); p != 0 {
|
||||
t.Errorf("zero price entry = %v", p)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// TimeSegment 时段定价规则(mdl_pricing.time_segments JSONB 数组元素)。
|
||||
// TimeSegment 时段定价规则(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。
|
||||
// 语义:按数组顺序先命中先生效,可用来表达「促销时段压在常驻时段之上」;
|
||||
// 未命中任何时段 → 乘数 1.0(默认价)。最终费用 = 各项小计 × 租户乘数 × 时段乘数。
|
||||
type TimeSegment struct {
|
||||
|
||||
@@ -25,25 +25,25 @@ import (
|
||||
|
||||
// RelayConverterMetrics 是单个转换器的累计指标快照(读取时计算派生字段)。
|
||||
type RelayConverterMetrics struct {
|
||||
ConverterID string `json:"converter_id"` // 如 "openai_to_claude"
|
||||
From string `json:"from"` // 源协议格式
|
||||
To string `json:"to"` // 目标协议格式
|
||||
Success int64 `json:"success"` // 累计成功次数
|
||||
Failed int64 `json:"failed"` // 累计失败次数
|
||||
TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值
|
||||
LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断)
|
||||
ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed)
|
||||
AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒)
|
||||
ConverterID string `json:"converter_id"` // 如 "openai_to_claude"
|
||||
From string `json:"from"` // 源协议格式
|
||||
To string `json:"to"` // 目标协议格式
|
||||
Success int64 `json:"success"` // 累计成功次数
|
||||
Failed int64 `json:"failed"` // 累计失败次数
|
||||
TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值
|
||||
LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断)
|
||||
ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed)
|
||||
AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒)
|
||||
}
|
||||
|
||||
// converterCounter 是单个转换器的并发安全计数器。
|
||||
// from/to 在创建时写入后只读(无需加锁);热路径字段用 atomic;lastErr 失败稀疏,用自带互斥锁。
|
||||
type converterCounter struct {
|
||||
from string
|
||||
to string
|
||||
success atomic.Int64
|
||||
failed atomic.Int64
|
||||
totalMs atomic.Int64
|
||||
from string
|
||||
to string
|
||||
success atomic.Int64
|
||||
failed atomic.Int64
|
||||
totalMs atomic.Int64
|
||||
lastErrMu sync.Mutex
|
||||
lastErr string
|
||||
}
|
||||
|
||||
@@ -404,6 +404,7 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs {
|
||||
AudioOutputTokens: record.AudioOutputTokens,
|
||||
ImageOutputTokens: record.ImageOutputTokens,
|
||||
ReasoningTokens: record.ReasoningTokens,
|
||||
DurationSeconds: record.DurationSeconds,
|
||||
|
||||
RequestedModel: sanitizeUTF8(record.RequestedModel),
|
||||
UpstreamModel: sanitizeUTF8(record.UpstreamModel),
|
||||
|
||||
@@ -329,9 +329,9 @@ type privateData struct {
|
||||
UpstreamTaskID string `json:"upstream_task_id"`
|
||||
TaskType string `json:"task_type"`
|
||||
BillingContext struct {
|
||||
Ratios map[string]float64 `json:"ratios"`
|
||||
ModelName string `json:"model_name"`
|
||||
PreDeduct float64 `json:"pre_deduct"`
|
||||
Ratios map[string]any `json:"ratios"`
|
||||
ModelName string `json:"model_name"`
|
||||
PreDeduct float64 `json:"pre_deduct"`
|
||||
} `json:"billing_context"`
|
||||
}
|
||||
|
||||
@@ -492,6 +492,22 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
status = "error"
|
||||
}
|
||||
|
||||
// 按秒计费任务的视频时长:取提交时计费上下文里的 spec.duration(真实秒数)。
|
||||
// 只认 spec.* 事实键——旧 duration 键在部分 adaptor 是乘数语义(如 gemini 1.3),不可作为时长
|
||||
durationSeconds := 0
|
||||
if len(task.PrivateData) > 0 {
|
||||
var pdDur struct {
|
||||
BillingContext struct {
|
||||
Ratios map[string]any `json:"ratios"`
|
||||
} `json:"billing_context"`
|
||||
}
|
||||
if json.Unmarshal(task.PrivateData, &pdDur) == nil {
|
||||
if v, ok := pdDur.BillingContext.Ratios["spec.duration"].(float64); ok && v > 0 {
|
||||
durationSeconds = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record := &common.UsageRecord{
|
||||
TenantID: task.TenantID,
|
||||
UserID: task.UserID,
|
||||
@@ -517,6 +533,8 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
PreDeductAmount: billing.InexactFloat64(task.PreDeductAmount),
|
||||
BillingSource: "task",
|
||||
TaskID: task.PublicTaskID,
|
||||
|
||||
DurationSeconds: durationSeconds,
|
||||
}
|
||||
|
||||
// 从结算结果填充计费快照
|
||||
|
||||
@@ -122,7 +122,7 @@ type SyncImageJob struct {
|
||||
Model string
|
||||
RequestBody []byte
|
||||
PreDeductAmount decimal.Decimal
|
||||
Ratios map[string]float64
|
||||
Ratios map[string]any
|
||||
SubmitTime time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -14,20 +13,14 @@ import (
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
)
|
||||
|
||||
// marketplacePricingRow 定价锚点行(min_tokens=0):全部计费模式的基础价载体
|
||||
// (token 默认价 / tiered 首档价 / per_request 单价),另带时段定价与对外展示字段。
|
||||
// 显式列名扫描进独立结构体,与 do/entity 生成物解耦(新展示列需迁移落库 + gf gen dao 后才进生成物)。
|
||||
// marketplacePricingRow 模型定价行(每模型一行):billing_mode + pricing JSONB 为计费真相,
|
||||
// 另带对外展示字段。显式列名扫描进独立结构体,与 do/entity 生成物解耦。
|
||||
type marketplacePricingRow struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"`
|
||||
TimeSegments string `json:"time_segments"`
|
||||
DiscountLabel *string `json:"discount_label"`
|
||||
PriceChangeNote *string `json:"price_change_note"`
|
||||
ModelID int64 `json:"model_id"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
Pricing string `json:"pricing"` // pricing JSONB(唯一真相)
|
||||
DiscountLabel *string `json:"discount_label"`
|
||||
PriceChangeNote *string `json:"price_change_note"`
|
||||
}
|
||||
|
||||
// GetModelList 获取模型广场列表(从默认模型分组加载)
|
||||
@@ -104,7 +97,6 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq)
|
||||
var pricings []marketplacePricingRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", modelIds).
|
||||
Where("min_tokens", 0). // 锚点行承载全部计费模式的基础价与展示字段
|
||||
Scan(&pricings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -162,11 +154,10 @@ func (s *sTenant) GetModelDetail(ctx context.Context, req *v1.MarketplaceDetailR
|
||||
return nil, fmt.Errorf("模型不存在或未公开")
|
||||
}
|
||||
|
||||
// 3. 查询价格(锚点行:全部计费模式的基础价 + 时段/展示字段)
|
||||
// 3. 查询价格(每模型一行:billing_mode + pricing JSONB + 展示字段)
|
||||
var pricing *marketplacePricingRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", model.Id).
|
||||
Where("min_tokens", 0).
|
||||
Scan(&pricing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -215,14 +206,20 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md
|
||||
billingMode = "token"
|
||||
}
|
||||
item.BillingMode = billingMode
|
||||
// 锚点行价即对外展示价(数据库存的就是每 1M token 美元价,直接返回):
|
||||
// token=默认价,tiered=首档价(前端标「起」),per_request=按次单价
|
||||
item.InputPrice = pricing.InputPrice
|
||||
item.OutputPrice = pricing.OutputPrice
|
||||
item.CacheReadPrice = pricing.CacheReadPrice
|
||||
item.CacheCreationPrice = pricing.CacheCreationPrice
|
||||
if pricing.PerRequestPrice != nil {
|
||||
item.PerRequestPrice = *pricing.PerRequestPrice
|
||||
|
||||
// pricing JSONB 解析(唯一真相):token=默认价,tiered=首档价(前端标「起」),per_request=按次单价
|
||||
blob := billing.ParsePricingBlob(pricing.Pricing)
|
||||
baseInput, baseOutput, baseCacheRead, baseCacheCreation, basePerRequest := fillFromBlob(blob)
|
||||
item.InputPrice = baseInput
|
||||
item.OutputPrice = baseOutput
|
||||
item.CacheReadPrice = baseCacheRead
|
||||
item.CacheCreationPrice = baseCacheCreation
|
||||
if basePerRequest != nil {
|
||||
item.PerRequestPrice = *basePerRequest
|
||||
}
|
||||
// 按秒计费:矩阵来自平台 pricing JSONB(per_second 模式展示用)
|
||||
if billingMode == "per_second" && blob != nil {
|
||||
item.PerSecondPrices = blob.Prices
|
||||
}
|
||||
if pricing.DiscountLabel != nil {
|
||||
item.DiscountLabel = *pricing.DiscountLabel
|
||||
@@ -231,16 +228,14 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md
|
||||
item.PriceChangeNote = *pricing.PriceChangeNote
|
||||
}
|
||||
|
||||
// 时段价目:平台基础价 × 时段乘数换算(tiered 用锚点首档价换算,与 buildTiers 首档口径一致)
|
||||
if pricing.TimeSegments != "" && pricing.TimeSegments != "null" {
|
||||
var segments []billing.TimeSegment
|
||||
if err := json.Unmarshal([]byte(pricing.TimeSegments), &segments); err == nil && len(segments) > 0 {
|
||||
var tiers []v1.PricingTierItem
|
||||
if billingMode == "tiered" {
|
||||
tiers = append(tiers, v1.PricingTierItem{InputPrice: pricing.InputPrice, OutputPrice: pricing.OutputPrice})
|
||||
}
|
||||
item.TimePrices = buildTimePrices(segments, billingMode, &pricing.InputPrice, &pricing.OutputPrice, pricing.PerRequestPrice, tiers)
|
||||
// 时段价目:平台基础价 × 时段乘数换算(tiered 用首档价换算,与 buildTiers 首档口径一致)
|
||||
if blob != nil && len(blob.TimeSegments) > 0 {
|
||||
var tiers []v1.PricingTierItem
|
||||
if billingMode == "tiered" && len(blob.Tiers) > 0 {
|
||||
tiers = append(tiers, v1.PricingTierItem{InputPrice: blob.Tiers[0].InputPrice, OutputPrice: blob.Tiers[0].OutputPrice})
|
||||
}
|
||||
in, out := baseInput, baseOutput
|
||||
item.TimePrices = buildTimePrices(blob.TimeSegments, billingMode, &in, &out, basePerRequest, tiers, item.PerSecondPrices)
|
||||
}
|
||||
|
||||
return item
|
||||
|
||||
+113
-116
@@ -23,7 +23,7 @@ type pricingTierRow struct {
|
||||
// memberModelScopeNoAccess 表示成员无权访问任何模型的哨兵值
|
||||
const memberModelScopeNoAccess = -1
|
||||
|
||||
// tenantModelPriceRow 显式分配模型的价格查询结果
|
||||
// tenantModelPriceRow 显式分配模型的价格查询结果(平台基础价统一从 pricing JSONB 解析)
|
||||
type tenantModelPriceRow struct {
|
||||
ModelDBID int64 `json:"model_db_id"`
|
||||
ID int64 `json:"id"`
|
||||
@@ -31,12 +31,7 @@ type tenantModelPriceRow struct {
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
DiscountRatio *float64 `json:"discount_ratio"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
|
||||
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BasePerRequestPrice *float64 `json:"base_per_request_price"`
|
||||
BaseDiscountLabel *string `json:"base_discount_label"`
|
||||
BasePriceChangeNote *string `json:"base_price_change_note"`
|
||||
CustomInputPrice *float64 `json:"custom_input_price"`
|
||||
@@ -44,19 +39,16 @@ type tenantModelPriceRow struct {
|
||||
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
|
||||
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
|
||||
CustomPricingTiers string `json:"custom_pricing_tiers"`
|
||||
BasePricing string `json:"base_pricing"` // pricing JSONB(唯一真相)
|
||||
}
|
||||
|
||||
// groupPriceRow 分组模型的 base 价格查询结果
|
||||
type groupPriceRow struct {
|
||||
ModelID int64 `json:"model_id"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BaseInputPrice float64 `json:"base_input_price"`
|
||||
BaseOutputPrice float64 `json:"base_output_price"`
|
||||
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
|
||||
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
|
||||
BasePerRequestPrice *float64 `json:"base_per_request_price"`
|
||||
BaseDiscountLabel *string `json:"base_discount_label"`
|
||||
BasePriceChangeNote *string `json:"base_price_change_note"`
|
||||
ModelID int64 `json:"model_id"`
|
||||
BaseBillingMode string `json:"base_billing_mode"`
|
||||
BaseDiscountLabel *string `json:"base_discount_label"`
|
||||
BasePriceChangeNote *string `json:"base_price_change_note"`
|
||||
BasePricing string `json:"base_pricing"`
|
||||
}
|
||||
|
||||
// baseTierRow 阶梯定价查询结果
|
||||
@@ -74,7 +66,7 @@ type memberScopeRow struct {
|
||||
ModelName string `json:"model_name"`
|
||||
}
|
||||
|
||||
// priceInfo 显式模型的完整价格信息
|
||||
// priceInfo 显式模型的完整价格信息(基础价字段从平台 pricing JSONB 解析而来)
|
||||
type priceInfo struct {
|
||||
ID int64
|
||||
BillingMode *string
|
||||
@@ -94,6 +86,15 @@ type priceInfo struct {
|
||||
CustomCacheReadPrice *float64
|
||||
CustomCacheCreationPrice *float64
|
||||
CustomPricingTiers string
|
||||
BaseBlob *billing.PricingBlob // 平台 pricing JSONB 解析结果(tiered 阶梯/时段/per_second 矩阵)
|
||||
}
|
||||
|
||||
// BasePerSecondPrices per_second 矩阵(无 blob 时为 nil)
|
||||
func (p *priceInfo) BasePerSecondPrices() map[string]float64 {
|
||||
if p.BaseBlob == nil {
|
||||
return nil
|
||||
}
|
||||
return p.BaseBlob.Prices
|
||||
}
|
||||
|
||||
// groupPriceInfo 分组模型的价格信息
|
||||
@@ -106,6 +107,32 @@ type groupPriceInfo struct {
|
||||
BasePerRequestPrice *float64
|
||||
BaseDiscountLabel *string
|
||||
BasePriceChangeNote *string
|
||||
BaseBlob *billing.PricingBlob
|
||||
}
|
||||
|
||||
// fillFromBlob 从平台 pricing JSONB 提取展示用基础价(tiered 取首档价,与列表口径一致)
|
||||
func fillFromBlob(blob *billing.PricingBlob) (input, output, cacheRead, cacheCreation float64, perRequest *float64) {
|
||||
if blob == nil {
|
||||
return
|
||||
}
|
||||
if len(blob.Tiers) > 0 {
|
||||
input = blob.Tiers[0].InputPrice
|
||||
output = blob.Tiers[0].OutputPrice
|
||||
}
|
||||
if blob.InputPrice != nil {
|
||||
input = *blob.InputPrice
|
||||
}
|
||||
if blob.OutputPrice != nil {
|
||||
output = *blob.OutputPrice
|
||||
}
|
||||
if blob.CacheReadPrice != nil {
|
||||
cacheRead = *blob.CacheReadPrice
|
||||
}
|
||||
if blob.CacheCreationPrice != nil {
|
||||
cacheCreation = *blob.CacheCreationPrice
|
||||
}
|
||||
perRequest = blob.Price
|
||||
return
|
||||
}
|
||||
|
||||
// ListAvailableModels 获取租户可用的模型列表
|
||||
@@ -133,31 +160,26 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
|
||||
if len(explicitDBIDs) > 0 {
|
||||
err = dao.MdlTenantModels.Ctx(ctx).
|
||||
LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id AND p.min_tokens = 0").
|
||||
LeftJoin("mdl_pricing p ON p.model_id = mdl_tenant_models.model_id").
|
||||
Where("mdl_tenant_models.tenant_id", tenantID).
|
||||
WhereIn("mdl_tenant_models.model_id", explicitDBIDs).
|
||||
Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.input_price AS base_input_price, p.output_price AS base_output_price, p.cache_read_price AS base_cache_read_price, p.cache_creation_price AS base_cache_creation_price, p.billing_mode AS base_billing_mode, p.per_request_price AS base_per_request_price, p.discount_label AS base_discount_label, p.price_change_note AS base_price_change_note, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
|
||||
Fields("mdl_tenant_models.model_id AS model_db_id, mdl_tenant_models.id, mdl_tenant_models.billing_mode, mdl_tenant_models.per_request_price, mdl_tenant_models.discount_ratio, mdl_tenant_models.max_concurrency, p.billing_mode AS base_billing_mode, p.pricing AS base_pricing, p.discount_label AS base_discount_label, p.price_change_note AS base_price_change_note, mdl_tenant_models.custom_input_price, mdl_tenant_models.custom_output_price, mdl_tenant_models.custom_cache_read_price, mdl_tenant_models.custom_cache_creation_price, mdl_tenant_models.custom_pricing_tiers").
|
||||
Scan(&priceResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 构建显式模型价格映射
|
||||
// 构建显式模型价格映射(基础价从 pricing JSONB 解析)
|
||||
priceMap := make(map[int64]*priceInfo, len(priceResults))
|
||||
for _, r := range priceResults {
|
||||
priceMap[r.ModelDBID] = &priceInfo{
|
||||
pi := &priceInfo{
|
||||
ID: r.ID,
|
||||
BillingMode: r.BillingMode,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
DiscountRatio: r.DiscountRatio,
|
||||
MaxConcurrency: r.MaxConcurrency,
|
||||
BaseInputPrice: r.BaseInputPrice,
|
||||
BaseOutputPrice: r.BaseOutputPrice,
|
||||
BaseCacheReadPrice: r.BaseCacheReadPrice,
|
||||
BaseCacheCreationPrice: r.BaseCacheCreationPrice,
|
||||
BaseBillingMode: r.BaseBillingMode,
|
||||
BasePerRequestPrice: r.BasePerRequestPrice,
|
||||
BaseDiscountLabel: r.BaseDiscountLabel,
|
||||
BasePriceChangeNote: r.BasePriceChangeNote,
|
||||
CustomInputPrice: r.CustomInputPrice,
|
||||
@@ -165,7 +187,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
CustomCacheReadPrice: r.CustomCacheReadPrice,
|
||||
CustomCacheCreationPrice: r.CustomCacheCreationPrice,
|
||||
CustomPricingTiers: r.CustomPricingTiers,
|
||||
BaseBlob: billing.ParsePricingBlob(r.BasePricing),
|
||||
}
|
||||
pi.BaseInputPrice, pi.BaseOutputPrice, pi.BaseCacheReadPrice, pi.BaseCacheCreationPrice, pi.BasePerRequestPrice =
|
||||
fillFromBlob(pi.BaseBlob)
|
||||
priceMap[r.ModelDBID] = pi
|
||||
}
|
||||
|
||||
// 批量查询分组模型的 base 价格
|
||||
@@ -181,97 +207,53 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
var groupPrices []groupPriceRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", groupDBIDs).
|
||||
Where("min_tokens", 0).
|
||||
Fields("model_id, billing_mode AS base_billing_mode, input_price AS base_input_price, output_price AS base_output_price, cache_read_price AS base_cache_read_price, cache_creation_price AS base_cache_creation_price, per_request_price AS base_per_request_price, discount_label AS base_discount_label, price_change_note AS base_price_change_note").
|
||||
Fields("model_id, billing_mode AS base_billing_mode, pricing AS base_pricing, discount_label AS base_discount_label, price_change_note AS base_price_change_note").
|
||||
Scan(&groupPrices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, gp := range groupPrices {
|
||||
groupPriceMap[gp.ModelID] = &groupPriceInfo{
|
||||
BaseBillingMode: gp.BaseBillingMode,
|
||||
BaseInputPrice: gp.BaseInputPrice,
|
||||
BaseOutputPrice: gp.BaseOutputPrice,
|
||||
BaseCacheReadPrice: gp.BaseCacheReadPrice,
|
||||
BaseCacheCreationPrice: gp.BaseCacheCreationPrice,
|
||||
BasePerRequestPrice: gp.BasePerRequestPrice,
|
||||
BaseDiscountLabel: gp.BaseDiscountLabel,
|
||||
BasePriceChangeNote: gp.BasePriceChangeNote,
|
||||
gpi := &groupPriceInfo{
|
||||
BaseBillingMode: gp.BaseBillingMode,
|
||||
BaseDiscountLabel: gp.BaseDiscountLabel,
|
||||
BasePriceChangeNote: gp.BasePriceChangeNote,
|
||||
BaseBlob: billing.ParsePricingBlob(gp.BasePricing),
|
||||
}
|
||||
gpi.BaseInputPrice, gpi.BaseOutputPrice, gpi.BaseCacheReadPrice, gpi.BaseCacheCreationPrice, gpi.BasePerRequestPrice =
|
||||
fillFromBlob(gpi.BaseBlob)
|
||||
groupPriceMap[gp.ModelID] = gpi
|
||||
}
|
||||
}
|
||||
|
||||
// 收集需要查询阶梯定价的模型:显式模型(无自定义阶梯)与分组模型(走 base 阶梯)都纳入
|
||||
tieredModelDBIDs := make([]int64, 0)
|
||||
// 平台阶梯与时段定价:从已解析的 pricing JSONB 提取(tiered 阶梯随 blob.Tiers,
|
||||
// 时段随 blob.TimeSegments;显式与分组来源统一处理)
|
||||
baseTiersMap := make(map[int64][]v1.PricingTierItem)
|
||||
timeSegmentsMap := make(map[int64][]billing.TimeSegment)
|
||||
for _, m := range models {
|
||||
var blob *billing.PricingBlob
|
||||
if m.Source == "explicit" {
|
||||
if pi, ok := priceMap[m.ModelDBID]; ok {
|
||||
effectiveBillingMode := resolveBillingMode(pi.BillingMode, pi.BaseBillingMode)
|
||||
if effectiveBillingMode == "tiered" && pi.CustomPricingTiers == "" {
|
||||
tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID)
|
||||
}
|
||||
blob = pi.BaseBlob
|
||||
}
|
||||
} else {
|
||||
// 分组模型使用 base 定价,tiered 时同样需要返回阶梯明细
|
||||
if gp, ok := groupPriceMap[m.ModelDBID]; ok && gp.BaseBillingMode == "tiered" {
|
||||
tieredModelDBIDs = append(tieredModelDBIDs, m.ModelDBID)
|
||||
} else if gp, ok := groupPriceMap[m.ModelDBID]; ok {
|
||||
blob = gp.BaseBlob
|
||||
}
|
||||
if blob == nil {
|
||||
continue
|
||||
}
|
||||
if len(blob.Tiers) > 0 {
|
||||
for _, tier := range blob.Tiers {
|
||||
baseTiersMap[m.ModelDBID] = append(baseTiersMap[m.ModelDBID], v1.PricingTierItem{
|
||||
MinTokens: tier.MinTokens,
|
||||
MaxTokens: tier.MaxTokens,
|
||||
InputPrice: tier.InputPrice,
|
||||
OutputPrice: tier.OutputPrice,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseTiersMap := make(map[int64][]v1.PricingTierItem)
|
||||
if len(tieredModelDBIDs) > 0 {
|
||||
var baseTiers []baseTierRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", tieredModelDBIDs).
|
||||
Where("billing_mode", "tiered").
|
||||
Where("min_tokens > 0").
|
||||
Fields("model_id, min_tokens, max_tokens, input_price, output_price").
|
||||
OrderAsc("model_id").
|
||||
OrderAsc("min_tokens").
|
||||
Scan(&baseTiers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, t := range baseTiers {
|
||||
baseTiersMap[t.ModelId] = append(baseTiersMap[t.ModelId], v1.PricingTierItem{
|
||||
MinTokens: t.MinTokens,
|
||||
MaxTokens: t.MaxTokens,
|
||||
InputPrice: t.InputPrice,
|
||||
OutputPrice: t.OutputPrice,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价批量查询(锚点行 JSONB;显式与分组来源模型统一处理,展示换算价目用)
|
||||
allDBIDs := make([]int64, 0, len(models))
|
||||
for _, m := range models {
|
||||
allDBIDs = append(allDBIDs, m.ModelDBID)
|
||||
}
|
||||
timeSegmentsMap := make(map[int64][]billing.TimeSegment)
|
||||
if len(allDBIDs) > 0 {
|
||||
var segRows []struct {
|
||||
ModelId int64 `json:"model_id"`
|
||||
TimeSegments string `json:"time_segments"`
|
||||
}
|
||||
if err := dao.MdlPricing.Ctx(ctx).
|
||||
WhereIn("model_id", allDBIDs).
|
||||
Where("min_tokens", 0).
|
||||
WhereNotNull("time_segments").
|
||||
Fields("model_id, time_segments").
|
||||
Scan(&segRows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range segRows {
|
||||
if r.TimeSegments == "" || r.TimeSegments == "null" {
|
||||
continue
|
||||
}
|
||||
var segs []billing.TimeSegment
|
||||
if err := json.Unmarshal([]byte(r.TimeSegments), &segs); err == nil && len(segs) > 0 {
|
||||
timeSegmentsMap[r.ModelId] = segs
|
||||
}
|
||||
if len(blob.TimeSegments) > 0 {
|
||||
timeSegmentsMap[m.ModelDBID] = blob.TimeSegments
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,9 +302,13 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
if effectiveBillingMode == "tiered" {
|
||||
item.PricingTiers = buildTiers(pi.CustomPricingTiers, pi.BaseInputPrice, pi.BaseOutputPrice, baseTiersMap[m.ModelDBID])
|
||||
}
|
||||
// 按秒计费:矩阵来自平台定价(租户不逐格覆盖,倍率在计费时作用)
|
||||
if effectiveBillingMode == "per_second" {
|
||||
item.PerSecondPrices = pi.BasePerSecondPrices()
|
||||
}
|
||||
|
||||
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], effectiveBillingMode,
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers, item.PerSecondPrices)
|
||||
|
||||
list = append(list, item)
|
||||
} else {
|
||||
@@ -370,8 +356,11 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
if billingMode == "tiered" && ok {
|
||||
item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID])
|
||||
}
|
||||
if billingMode == "per_second" && ok && gp.BaseBlob != nil {
|
||||
item.PerSecondPrices = gp.BaseBlob.Prices
|
||||
}
|
||||
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], billingMode,
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers, item.PerSecondPrices)
|
||||
list = append(list, item)
|
||||
}
|
||||
}
|
||||
@@ -506,8 +495,8 @@ func effectivePrice(custom *float64, base float64) *float64 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTiers 组装阶梯定价明细:自定义阶梯优先,否则用 base 首档价格(min_tokens=0)+ 基础阶梯其余档。
|
||||
// 显式模型与分组模型共用;baseTiers 为 min_tokens>0 的其余档,为空时仅返回首档。
|
||||
// buildTiers 组装阶梯定价明细:自定义阶梯优先,否则用平台阶梯(pricing JSONB tiers 数组,含首档)。
|
||||
// 显式模型与分组模型共用;baseTiers 为空时按锚点价合成单档兜底。
|
||||
func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64, baseTiers []v1.PricingTierItem) []v1.PricingTierItem {
|
||||
if customTiersJSON != "" && customTiersJSON != "null" && customTiersJSON != "[]" {
|
||||
var raw []pricingTierRow
|
||||
@@ -525,29 +514,26 @@ func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64,
|
||||
}
|
||||
}
|
||||
|
||||
tiers := make([]v1.PricingTierItem, 0, len(baseTiers)+1)
|
||||
// 平台阶梯已含首档(min_tokens=0):直接返回
|
||||
if len(baseTiers) > 0 {
|
||||
return baseTiers
|
||||
}
|
||||
// 无阶梯数据:按锚点价合成单档
|
||||
if baseInputPrice > 0 || baseOutputPrice > 0 {
|
||||
tiers = append(tiers, v1.PricingTierItem{
|
||||
return []v1.PricingTierItem{{
|
||||
MinTokens: 0,
|
||||
MaxTokens: nil,
|
||||
InputPrice: baseInputPrice,
|
||||
OutputPrice: baseOutputPrice,
|
||||
})
|
||||
}}
|
||||
}
|
||||
if len(baseTiers) > 0 {
|
||||
if len(tiers) > 0 {
|
||||
// 首档与第二档衔接:首档 max_tokens = 第二档 min_tokens
|
||||
tiers[0].MaxTokens = &baseTiers[0].MinTokens
|
||||
}
|
||||
tiers = append(tiers, baseTiers...)
|
||||
}
|
||||
return tiers
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTimePrices 构建时段展示价目:每个时段 = 模型当前有效价 × 时段乘数(后端换算,前端直接渲染)。
|
||||
// token 模式换算输入/输出价;per_request 换算按次价;tiered 用首档价换算(起价,前端标注「起」)。
|
||||
func buildTimePrices(segments []billing.TimeSegment, billingMode string,
|
||||
inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem) []v1.TimePriceItem {
|
||||
inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem, perSecondPrices map[string]float64) []v1.TimePriceItem {
|
||||
if len(segments) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -561,6 +547,13 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string,
|
||||
tierInput = &tiers[0].InputPrice
|
||||
tierOutput = &tiers[0].OutputPrice
|
||||
}
|
||||
// per_second 模式换算基准:兜底档 "*" 单价,无 "*" 取矩阵最低正价
|
||||
var perSecondBase *float64
|
||||
if billingMode == "per_second" {
|
||||
if base := billing.LookupPerSecondPrice(perSecondPrices, "*"); base > 0 {
|
||||
perSecondBase = &base
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]v1.TimePriceItem, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
@@ -578,6 +571,10 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string,
|
||||
if perRequestPrice != nil {
|
||||
tp.PerRequestPrice = mulDisplayPrice(*perRequestPrice, seg.Multiplier)
|
||||
}
|
||||
case "per_second":
|
||||
if perSecondBase != nil {
|
||||
tp.PerSecondPrice = mulDisplayPrice(*perSecondBase, seg.Multiplier)
|
||||
}
|
||||
case "tiered":
|
||||
if tierInput != nil {
|
||||
tp.InputPrice = mulDisplayPrice(*tierInput, seg.Multiplier)
|
||||
|
||||
@@ -69,4 +69,5 @@ type BilUsageLogs struct {
|
||||
BillingSnapshot any // 完整计费计算过程快照(JSONB)
|
||||
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
DurationSeconds any // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
|
||||
}
|
||||
@@ -7,26 +7,18 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlPricing is the golang structure of table mdl_pricing for DAO operations like Where/Data.
|
||||
type MdlPricing struct {
|
||||
g.Meta `orm:"table:mdl_pricing, do:true"`
|
||||
Id any //
|
||||
ModelId any // 关联模型ID
|
||||
BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens any // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens any // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice any // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice any // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice *decimal.Decimal // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
CacheReadPrice any // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice any // 缓存创建每 1M token 价格(直接定价)
|
||||
TimeSegments any // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
|
||||
PriceNote any // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
g.Meta `orm:"table:mdl_pricing, do:true"`
|
||||
Id any //
|
||||
ModelId any // 关联模型ID
|
||||
BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
PriceNote any // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing any // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
}
|
||||
@@ -68,4 +68,5 @@ type BilUsageLogs struct {
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
DurationSeconds int `json:"duration_seconds" orm:"duration_seconds" description:"按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL"` // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
|
||||
}
|
||||
@@ -6,25 +6,17 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlPricing is the golang structure for table mdl_pricing.
|
||||
type MdlPricing struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
CacheReadPrice decimal.Decimal `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice decimal.Decimal `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
|
||||
TimeSegments string `json:"time_segments" orm:"time_segments" description:"时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{\"name\":\"闲时\",\"days\":[1,2,3,4,5],\"start_time\":\"00:00\",\"end_time\":\"08:00\",\"valid_from\":\"\",\"valid_to\":\"\",\"multiplier\":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点"` // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
|
||||
PriceNote string `json:"price_note" orm:"price_note" description:"价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无"` // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
PriceNote string `json:"price_note" orm:"price_note" description:"价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无"` // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
|
||||
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
|
||||
Pricing string `json:"pricing" orm:"pricing" description:"计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:\"second\",prices:{分辨率规格:每秒单价,\"*\":兜底价}};顶层可含 time_segments(时段定价)"` // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
// 验证器 App 生成的 SHA-256 码在服务端永远校验失败。要安全切换到 SHA-256 必须先:
|
||||
// 1. 在用户表持久化每条 secret 对应的算法;
|
||||
// 2. ValidateCode 改用 totp.ValidateCustom 并按存储的算法校验。
|
||||
//
|
||||
// 这属于涉及 schema 与 2FA 安全路径的较大改动,超出 P3 细节优化范围,故暂保留 SHA-1。
|
||||
func GenerateSecret(accountName string) (secret string, uri string, err error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
|
||||
@@ -170,6 +170,9 @@ type UsageRecord struct {
|
||||
// 其他 token
|
||||
ImageOutputTokens int // 图像输出 token(DALL-E)
|
||||
|
||||
// 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 0
|
||||
DurationSeconds int
|
||||
|
||||
// 请求元数据
|
||||
RequestedModel string // 用户请求的模型名
|
||||
UpstreamModel string // 上游实际模型名
|
||||
|
||||
@@ -15,12 +15,16 @@ type TaskAdaptor interface {
|
||||
// ValidateRequest 校验请求参数
|
||||
ValidateRequest(ctx context.Context, info *RelayInfo, body []byte) *TaskError
|
||||
|
||||
// EstimateBilling 估算任务费用(提交前)
|
||||
// 返回计费比率 map,如 {"duration_ratio": 1.5, "resolution_ratio": 2.0}
|
||||
EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]float64
|
||||
// EstimateBilling 估算任务费用(提交前)。
|
||||
// 返回计费上下文 map,两类键:
|
||||
// - float64 乘数值:如 {"video_input": 0.6, "quality": 3.5},引擎按序连乘;
|
||||
// - string 规格事实值:如 {"spec.duration": 8, "spec.resolution": "720p"},
|
||||
// per_second 计费模式据此查定价矩阵(spec.duration 为 float64 秒数,spec.resolution 为规格原值)。
|
||||
// 旧键 duration/resolution 保留 token 伪装估算路径使用,语义随各 adaptor 存量口径,勿混用。
|
||||
EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]any
|
||||
|
||||
// AdjustBillingOnSubmit 提交后根据上游确认参数调整计费
|
||||
AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]float64
|
||||
AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]any
|
||||
|
||||
// BuildRequestURL 构建上游请求 URL
|
||||
BuildRequestURL(info *RelayInfo) (string, error)
|
||||
|
||||
@@ -8,10 +8,14 @@ import (
|
||||
)
|
||||
|
||||
// TaskBillingProvider 异步任务计费接口
|
||||
// 异步任务计费基于时长/分辨率等比率,非 token 数
|
||||
// 异步任务计费基于时长/分辨率等比率,非 token 数。
|
||||
// ratios 键约定见 TaskAdaptor.EstimateBilling(float64 乘数 + spec.* 规格事实值)
|
||||
type TaskBillingProvider interface {
|
||||
// EstimateTaskCost 估算任务费用
|
||||
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error)
|
||||
// EstimateTaskCost 估算任务费用。
|
||||
// taskBody 为归一化后的任务体(转换后通用格式):定价配置了参数倍率(param_multipliers)时,
|
||||
// 实现方在函数内求值并把命中倍率就地注入 ratios(param_multiplier / param_matched 键,
|
||||
// 随任务持久化保证预扣/结算/重算三口径同源);nil body = 不求值。
|
||||
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error)
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error)
|
||||
@@ -30,7 +34,7 @@ type TaskBillingProvider interface {
|
||||
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)。billAt 为任务受理时刻(时段定价按该时刻评估,
|
||||
// 异步任务结算滞后数分钟~小时,按结算时刻会丢失受理时的时段价)
|
||||
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*SettlementResult, error)
|
||||
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*SettlementResult, error)
|
||||
|
||||
// SettleTaskFailed 任务失败退还预扣
|
||||
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error
|
||||
@@ -43,6 +47,6 @@ type TaskBillingProvider interface {
|
||||
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用(billAt 为任务受理时刻,时段定价用)
|
||||
// totalTokens: 上游返回的 token 计费单位
|
||||
// ratios: 提交时保存的计费比率(如 video_input 折扣)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error)
|
||||
// ratios: 提交时保存的计费上下文(如 video_input 折扣)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error)
|
||||
}
|
||||
@@ -190,9 +190,10 @@ func HandleTaskSubmit(
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 估算计费 + 预扣
|
||||
// 6. 估算计费 + 预扣(ratios 为计费上下文:float64 乘数 + spec.* 规格事实值;
|
||||
// body 同传给 EstimateTaskCost 供参数倍率按归一化任务体匹配,命中注入 ratios)
|
||||
ratios := adaptor.EstimateBilling(ctx, info, body)
|
||||
estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios)
|
||||
estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios, body)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: estimate cost failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "")
|
||||
@@ -252,7 +253,7 @@ func HandleTaskSubmit(
|
||||
// 9. 调整计费
|
||||
adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData)
|
||||
if adjustedRatios != nil {
|
||||
newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios)
|
||||
newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios, body)
|
||||
preDeductAmount, _ = billingProvider.AdjustTaskBilling(ctx, rc.TenantID, rc.RequestID, preDeductAmount, newCost)
|
||||
}
|
||||
|
||||
@@ -262,7 +263,7 @@ func HandleTaskSubmit(
|
||||
if finalRatios == nil {
|
||||
finalRatios = adjustedRatios
|
||||
} else {
|
||||
merged := make(map[string]float64, len(finalRatios)+len(adjustedRatios))
|
||||
merged := make(map[string]any, len(finalRatios)+len(adjustedRatios))
|
||||
for k, v := range finalRatios {
|
||||
merged[k] = v
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type stubVideosBillingProvider struct {
|
||||
common.TaskBillingProvider
|
||||
}
|
||||
|
||||
func (s *stubVideosBillingProvider) EstimateTaskCost(context.Context, int64, string, map[string]float64) (decimal.Decimal, error) {
|
||||
func (s *stubVideosBillingProvider) EstimateTaskCost(context.Context, int64, string, map[string]any, []byte) (decimal.Decimal, error) {
|
||||
return decimal.NewFromFloat(0.1), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -140,25 +140,27 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
}
|
||||
|
||||
if a.isVideo {
|
||||
// duration 影响计费
|
||||
// duration 影响计费:spec.duration 为真实秒数(per_second 用),duration 为存量信号
|
||||
metadata := taskchannel.ExtractMetadata(req)
|
||||
var meta struct {
|
||||
Duration *int `json:"duration"`
|
||||
}
|
||||
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 {
|
||||
ratios["duration"] = float64(*meta.Duration)
|
||||
ratios["spec.duration"] = float64(*meta.Duration)
|
||||
}
|
||||
if seconds, ok := req["seconds"].(string); ok {
|
||||
if d, err := parseInt(seconds); err == nil && d > 0 {
|
||||
ratios["duration"] = float64(d)
|
||||
ratios["spec.duration"] = float64(d)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -171,7 +173,7 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,20 @@ func (a *GeminiAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
var req openAIVideoRequest
|
||||
if json.Unmarshal(body, &req) == nil {
|
||||
resolution := parseResolution(req.Metadata.Resolution)
|
||||
duration := req.Metadata.Duration
|
||||
// per_second 事实键:真实秒数 + 规格原值(矩阵查价键)
|
||||
if duration > 0 {
|
||||
ratios["spec.duration"] = float64(duration)
|
||||
}
|
||||
if resolution != "" {
|
||||
ratios["spec.resolution"] = resolution
|
||||
}
|
||||
// 存量 token 伪装路径乘数(语义为乘数非秒数,保持原行为)
|
||||
if resolution == "1080p" {
|
||||
ratios["resolution"] = 1.5
|
||||
} else if resolution == "4k" {
|
||||
@@ -60,7 +68,7 @@ func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo,
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +116,8 @@ func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, b
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
var req map[string]any
|
||||
if json.Unmarshal(body, &req) != nil {
|
||||
return ratios
|
||||
@@ -145,7 +145,7 @@ func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, b
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ func (a *MjAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _ []
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
action := constant.TaskAction(a.extractAction())
|
||||
switch action {
|
||||
case constant.TaskActionUpscale:
|
||||
@@ -70,7 +70,7 @@ func (a *MjAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *MjAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *MjAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,16 @@ func (a *SoraAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
|
||||
ratios := map[string]float64{"base": 1.0}
|
||||
func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
|
||||
ratios := map[string]any{"base": 1.0}
|
||||
var req map[string]json.RawMessage
|
||||
if json.Unmarshal(body, &req) == nil {
|
||||
// 按秒数加价
|
||||
// per_second 事实键:真实秒数与规格原值(size 为 OpenAI 四档 "宽x高" 格式,
|
||||
// 矩阵键按同格式配置即可命中)
|
||||
if secs, ok := req["seconds"]; ok {
|
||||
var s string
|
||||
if json.Unmarshal(secs, &s) == nil && s != "" {
|
||||
ratios["spec.duration"] = parseFloatOrZero(s)
|
||||
if s == "10" || s == "15" || s == "20" {
|
||||
ratios["duration"] = 1.5
|
||||
}
|
||||
@@ -48,15 +50,31 @@ func (a *SoraAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bo
|
||||
}
|
||||
if dur, ok := req["duration"]; ok {
|
||||
var d float64
|
||||
if json.Unmarshal(dur, &d) == nil && d > 10 {
|
||||
ratios["duration"] = 1.5
|
||||
if json.Unmarshal(dur, &d) == nil && d > 0 {
|
||||
ratios["spec.duration"] = d
|
||||
if d > 10 {
|
||||
ratios["duration"] = 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
if size, ok := req["size"]; ok {
|
||||
var s string
|
||||
if json.Unmarshal(size, &s) == nil && s != "" {
|
||||
ratios["spec.resolution"] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *SoraAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
// parseFloatOrZero 秒数字符串转 float(解析失败返回 0,由引擎按缺省时长兜底)
|
||||
func parseFloatOrZero(s string) float64 {
|
||||
var f float64
|
||||
fmt.Sscanf(s, "%g", &f)
|
||||
return f
|
||||
}
|
||||
|
||||
func (a *SoraAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,11 @@ func (a *SunoAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, _
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *SunoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
return map[string]float64{"base": 1.0}
|
||||
func (a *SunoAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return map[string]any{"base": 1.0}
|
||||
}
|
||||
|
||||
func (a *SunoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *SunoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -111,22 +111,26 @@ func (a *VolcengineVideoAdaptor) ValidateRequest(_ context.Context, _ *common.Re
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common.RelayInfo, body []byte) map[string]float64 {
|
||||
func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common.RelayInfo, body []byte) map[string]any {
|
||||
var req map[string]any
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return map[string]float64{}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
ratios := make(map[string]float64)
|
||||
ratios := make(map[string]any)
|
||||
|
||||
// 提取 duration 用于 token 预估(默认 5s)
|
||||
// 提取 duration:spec.duration 为真实秒数(per_second 矩阵计费用),
|
||||
// duration 为存量 token 伪装路径信号(语义同为秒,行为不变)
|
||||
duration := extractDuration(req)
|
||||
if duration > 0 {
|
||||
ratios["spec.duration"] = float64(duration)
|
||||
ratios["duration"] = float64(duration)
|
||||
}
|
||||
|
||||
// 提取 resolution 用于 token 预估
|
||||
// 提取 resolution:spec.resolution 为规格原值(per_second 矩阵查价键,如 "720p"),
|
||||
// resolution 为存量硬编码乘数(token 伪装路径用,待模型迁移 per_second 后废弃)
|
||||
if resolution := extractResolution(req); resolution != "" {
|
||||
ratios["spec.resolution"] = resolution
|
||||
ratios["resolution"] = resolutionMultiplier(resolution)
|
||||
}
|
||||
|
||||
@@ -140,7 +144,7 @@ func (a *VolcengineVideoAdaptor) EstimateBilling(_ context.Context, info *common
|
||||
return ratios
|
||||
}
|
||||
|
||||
func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
|
||||
func (a *VolcengineVideoAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
|
||||
import { IconCloudDownload, IconRefresh, IconDelete } from '@arco-design/web-vue/es/icon'
|
||||
import request from '@/utils/request'
|
||||
import { displayCurrency, currencySymbol, formatBilling, cnyToUsd } from '@/composables/useCurrency'
|
||||
|
||||
@@ -35,6 +35,7 @@ const billingModeOptions = [
|
||||
{ label: '按量计费 (Token)', value: 'token' },
|
||||
{ label: '按次计费', value: 'per_request' },
|
||||
{ label: '阶梯计费', value: 'tiered' },
|
||||
{ label: '按秒计费 (视频)', value: 'per_second' },
|
||||
]
|
||||
|
||||
function createEmptyItem(mode: string) {
|
||||
@@ -47,13 +48,79 @@ function createEmptyItem(mode: string) {
|
||||
per_request_price: null,
|
||||
cache_read_price: 0,
|
||||
cache_creation_price: 0,
|
||||
per_second_prices: null as Record<string, number> | null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Per-Second Mode(按秒计费:分辨率规格 × 每秒单价矩阵)
|
||||
// ============================================================
|
||||
interface PerSecondRow {
|
||||
spec: string // 分辨率/规格键,如 480p / 720p / 1080p / *(兜底)
|
||||
price: number // 每秒单价(本位币)
|
||||
}
|
||||
const perSecondRows = reactive<PerSecondRow[]>([])
|
||||
|
||||
// 常用规格快捷键(点击追加行)
|
||||
const perSecondSpecPresets = ['480p', '720p', '1080p', '*']
|
||||
|
||||
function addPerSecondRow(spec = '') {
|
||||
perSecondRows.push({ spec, price: 0 })
|
||||
}
|
||||
|
||||
function removePerSecondRow(index: number) {
|
||||
perSecondRows.splice(index, 1)
|
||||
}
|
||||
|
||||
// 回显:后端 map → 行编辑器
|
||||
function loadPerSecondPrices(prices: Record<string, number> | null | undefined) {
|
||||
perSecondRows.length = 0
|
||||
if (!prices) return
|
||||
for (const [spec, price] of Object.entries(prices)) {
|
||||
perSecondRows.push({ spec, price: Number(price) || 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// 行 → 提交 map;返回 null 表示校验失败
|
||||
function buildPerSecondPrices(): Record<string, number> | null {
|
||||
const map: Record<string, number> = {}
|
||||
let hasPositive = false
|
||||
for (const row of perSecondRows) {
|
||||
const spec = row.spec.trim()
|
||||
if (!spec) continue
|
||||
if (map[spec] !== undefined) {
|
||||
Message.warning(`按秒计费规格「${spec}」重复`)
|
||||
return null
|
||||
}
|
||||
if (row.price < 0) {
|
||||
Message.warning(`按秒计费规格「${spec}」单价不能为负`)
|
||||
return null
|
||||
}
|
||||
map[spec] = row.price
|
||||
if (row.price > 0) hasPositive = true
|
||||
}
|
||||
if (!hasPositive) {
|
||||
Message.warning('按秒计费至少配置一档正价(建议额外配置 * 兜底价)')
|
||||
return null
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// 时段预览基准价:优先 * 兜底价,否则矩阵最低正价
|
||||
function perSecondBasePrice(): number {
|
||||
const prices = perSecondRows.map((r) => Number(r.price) || 0).filter((p) => p > 0)
|
||||
if (prices.length === 0) return 0
|
||||
const wildcard = perSecondRows.find((r) => r.spec.trim() === '*')
|
||||
return wildcard && wildcard.price > 0 ? wildcard.price : Math.min(...prices)
|
||||
}
|
||||
|
||||
function resetEditorDefaults() {
|
||||
editorBillingMode.value = 'token'
|
||||
editorItems.length = 0
|
||||
editorItems.push(createEmptyItem('token'))
|
||||
perSecondRows.length = 0
|
||||
paramRules.length = 0
|
||||
tryMatchInput.value = ''
|
||||
editorPriceNote.value = ''
|
||||
editorDiscountLabel.value = ''
|
||||
editorPriceChangeNote.value = ''
|
||||
@@ -144,6 +211,215 @@ function applyOfficialPricing(pricing: any) {
|
||||
Message.success('已填入官方参考定价')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Param Multipliers(参数倍率:按归一化任务体参数匹配,命中规则连乘)
|
||||
// ============================================================
|
||||
interface ParamConditionRow {
|
||||
path: string
|
||||
match: string // has_value | equals | gt | gte | lt | lte | between
|
||||
value: string // 输入态字符串;between 逗号分隔;提交时按匹配方式转换
|
||||
}
|
||||
interface ParamRuleRow {
|
||||
conditions: ParamConditionRow[]
|
||||
multiplier: number
|
||||
note: string
|
||||
}
|
||||
|
||||
const paramRules = reactive<ParamRuleRow[]>([])
|
||||
|
||||
const paramMatchOptions = [
|
||||
{ label: '有值', value: 'has_value' },
|
||||
{ label: '等于', value: 'equals' },
|
||||
{ label: '大于', value: 'gt' },
|
||||
{ label: '大于等于', value: 'gte' },
|
||||
{ label: '小于', value: 'lt' },
|
||||
{ label: '小于等于', value: 'lte' },
|
||||
{ label: '区间', value: 'between' },
|
||||
]
|
||||
|
||||
// 常用路径预设(归一化任务体,规则跟语义走不跟入口协议走)
|
||||
const paramPathPresets = [
|
||||
'metadata.image',
|
||||
'metadata.duration',
|
||||
'metadata.resolution',
|
||||
'metadata.aspect_ratio',
|
||||
'seconds',
|
||||
'metadata.content[*].video_url',
|
||||
'metadata.content[*].role',
|
||||
]
|
||||
|
||||
function addParamRule() {
|
||||
paramRules.push({ conditions: [{ path: '', match: 'has_value', value: '' }], multiplier: 1, note: '' })
|
||||
}
|
||||
|
||||
function removeParamRule(index: number) {
|
||||
paramRules.splice(index, 1)
|
||||
}
|
||||
|
||||
function addParamCondition(ruleIndex: number) {
|
||||
paramRules[ruleIndex].conditions.push({ path: '', match: 'has_value', value: '' })
|
||||
}
|
||||
|
||||
function removeParamCondition(ruleIndex: number, condIndex: number) {
|
||||
paramRules[ruleIndex].conditions.splice(condIndex, 1)
|
||||
}
|
||||
|
||||
function conditionNeedsValue(match: string): boolean {
|
||||
return match !== 'has_value'
|
||||
}
|
||||
|
||||
// 条件输入态 → 提交值(数字类强转、between 转双元素数组、equals 保原样)
|
||||
function conditionValuePayload(cond: ParamConditionRow): any {
|
||||
const raw = cond.value.trim()
|
||||
if (cond.match === 'between') {
|
||||
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
return parts.length === 2 ? parts.map(Number) : null
|
||||
}
|
||||
if (['gt', 'gte', 'lt', 'lte'].includes(cond.match)) {
|
||||
const n = Number(raw)
|
||||
return raw !== '' && !Number.isNaN(n) ? n : null
|
||||
}
|
||||
return raw // equals:字符串原样(后端数字强转比较)
|
||||
}
|
||||
|
||||
// 行编辑态 → 提交载荷;校验失败返回 null
|
||||
function buildParamMultipliersPayload(): any[] | null {
|
||||
const payload: any[] = []
|
||||
for (const [i, rule] of paramRules.entries()) {
|
||||
if (rule.multiplier <= 0 || rule.multiplier > 100) {
|
||||
Message.warning(`参数倍率规则 ${i + 1} 倍率需在 (0, 100] 区间`)
|
||||
return null
|
||||
}
|
||||
if (rule.conditions.length === 0) {
|
||||
Message.warning(`参数倍率规则 ${i + 1} 至少需要一个条件`)
|
||||
return null
|
||||
}
|
||||
const conditions: any[] = []
|
||||
for (const [j, cond] of rule.conditions.entries()) {
|
||||
const path = cond.path.trim()
|
||||
if (!path) {
|
||||
Message.warning(`参数倍率规则 ${i + 1} 条件 ${j + 1} 路径为空`)
|
||||
return null
|
||||
}
|
||||
const value = conditionValuePayload(cond)
|
||||
if (conditionNeedsValue(cond.match) && (value === null || value === '')) {
|
||||
Message.warning(`参数倍率规则 ${i + 1} 条件 ${j + 1} 需要填写比对值`)
|
||||
return null
|
||||
}
|
||||
conditions.push(conditionNeedsValue(cond.match) ? { path, match: cond.match, value } : { path, match: cond.match })
|
||||
}
|
||||
// 命中说明:空 = 默认「规则N」(随序号,删除上方规则后自动顺延)
|
||||
payload.push({ conditions, multiplier: rule.multiplier, note: rule.note.trim() || `规则 ${i + 1}` })
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 回显:后端规则 → 行编辑态
|
||||
function loadParamMultipliers(list: any[] | null | undefined) {
|
||||
paramRules.length = 0
|
||||
if (!Array.isArray(list)) return
|
||||
for (const rule of list) {
|
||||
const row: ParamRuleRow = {
|
||||
conditions: (rule.conditions || []).map((c: any) => ({
|
||||
path: c.path || '',
|
||||
match: c.match || 'has_value',
|
||||
value: Array.isArray(c.value) ? c.value.join(',') : c.value != null ? String(c.value) : '',
|
||||
})),
|
||||
multiplier: Number(rule.multiplier) || 1,
|
||||
note: rule.note || '',
|
||||
}
|
||||
if (row.conditions.length > 0) paramRules.push(row)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 试匹配预览(前端复刻后端求值语义:路径提取 + [*] 扇出 + 数字强转 + 隐式与 + 连乘) ──
|
||||
const tryMatchInput = ref('')
|
||||
|
||||
function lookupPath(root: any, path: string): { values: any[]; found: boolean } {
|
||||
const segments = path.split('.').filter(Boolean)
|
||||
let current = [root]
|
||||
for (const seg of segments) {
|
||||
const wildcard = seg.endsWith('[*]')
|
||||
const key = wildcard ? seg.slice(0, -3) : seg
|
||||
const next: any[] = []
|
||||
for (const cur of current) {
|
||||
if (cur == null || typeof cur !== 'object' || Array.isArray(cur)) continue
|
||||
const v = (cur as Record<string, any>)[key]
|
||||
if (v === undefined) continue
|
||||
if (wildcard) {
|
||||
if (Array.isArray(v)) next.push(...v)
|
||||
} else {
|
||||
next.push(v)
|
||||
}
|
||||
}
|
||||
if (next.length === 0) return { values: [], found: false }
|
||||
current = next
|
||||
}
|
||||
return { values: current, found: true }
|
||||
}
|
||||
|
||||
function toNum(v: any): number | null {
|
||||
if (typeof v === 'number') return v
|
||||
if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v)
|
||||
return null
|
||||
}
|
||||
|
||||
function conditionHit(cond: { path: string; match: string; value?: any }, root: any): boolean {
|
||||
const { values, found } = lookupPath(root, cond.path)
|
||||
if (!found) return false
|
||||
if (cond.match === 'has_value') return values.some((v) => v !== null && v !== undefined)
|
||||
const target = Array.isArray(cond.value) ? cond.value.map(Number) : cond.value
|
||||
if (cond.match === 'equals') {
|
||||
return values.some((v) => {
|
||||
const vn = toNum(v)
|
||||
const tn = toNum(target)
|
||||
if (vn != null && tn != null) return vn === tn
|
||||
return v === target
|
||||
})
|
||||
}
|
||||
if (cond.match === 'between') {
|
||||
if (!Array.isArray(target) || target.length !== 2) return false
|
||||
return values.some((v) => {
|
||||
const n = toNum(v)
|
||||
return n != null && n >= target[0] && n <= target[1]
|
||||
})
|
||||
}
|
||||
const tn = toNum(target)
|
||||
if (tn == null) return false
|
||||
return values.some((v) => {
|
||||
const n = toNum(v)
|
||||
if (n == null) return false
|
||||
if (cond.match === 'gt') return n > tn
|
||||
if (cond.match === 'gte') return n >= tn
|
||||
if (cond.match === 'lt') return n < tn
|
||||
return n <= tn // lte
|
||||
})
|
||||
}
|
||||
|
||||
// 试匹配结果:命中规则列表 + 总倍率
|
||||
function tryMatchResult(): { hits: Array<{ label: string; multiplier: number }>; total: number } | null {
|
||||
const raw = tryMatchInput.value.trim()
|
||||
if (!raw) return null
|
||||
let root: any
|
||||
try {
|
||||
root = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const hits: Array<{ label: string; multiplier: number }> = []
|
||||
let total = 1
|
||||
for (const rule of paramRules) {
|
||||
const allHit = rule.conditions.every((c) =>
|
||||
conditionHit({ path: c.path, match: c.match, value: conditionValuePayload(c) }, root),
|
||||
)
|
||||
if (allHit) {
|
||||
total *= rule.multiplier || 1
|
||||
hits.push({ label: rule.note || rule.conditions.map((c) => c.path).join(' & '), multiplier: rule.multiplier })
|
||||
}
|
||||
}
|
||||
return { hits, total }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Load Pricing Data
|
||||
// ============================================================
|
||||
@@ -151,6 +427,8 @@ async function loadPricing() {
|
||||
if (!props.modelId) return
|
||||
editorItems.length = 0
|
||||
editorBillingMode.value = 'token'
|
||||
paramRules.length = 0
|
||||
tryMatchInput.value = ''
|
||||
resetOfficialData()
|
||||
editorLoading.value = true
|
||||
|
||||
@@ -173,12 +451,15 @@ async function loadPricing() {
|
||||
per_request_price: item.per_request_price ?? null,
|
||||
cache_read_price: item.cache_read_price ?? 0,
|
||||
cache_creation_price: item.cache_creation_price ?? 0,
|
||||
per_second_prices: item.per_second_prices ?? null,
|
||||
})
|
||||
}
|
||||
loadPerSecondPrices(list[0].per_second_prices)
|
||||
} else {
|
||||
resetEditorDefaults()
|
||||
}
|
||||
loadTimeSegments(res.data?.data?.time_segments)
|
||||
loadParamMultipliers(res.data?.data?.param_multipliers)
|
||||
} catch {
|
||||
resetEditorDefaults()
|
||||
} finally {
|
||||
@@ -258,6 +539,9 @@ function moveTimeSegment(index: number, offset: number) {
|
||||
// 组合预览基础价:随计费模式取当前编辑中的价格(tiered 用首档价)
|
||||
const previewBase = computed(() => {
|
||||
const first: any = editorItems[0] || {}
|
||||
if (editorBillingMode.value === 'per_second') {
|
||||
return { kind: 'per_second' as const, perSecond: perSecondBasePrice() }
|
||||
}
|
||||
if (editorBillingMode.value === 'per_request') {
|
||||
return { kind: 'per_request' as const, perRequest: Number(first.per_request_price) || 0 }
|
||||
}
|
||||
@@ -267,6 +551,9 @@ const previewBase = computed(() => {
|
||||
|
||||
function segmentPreview(seg: TimeSegmentRow): string {
|
||||
const m = (seg.percent || 0) / 100
|
||||
if (previewBase.value.kind === 'per_second') {
|
||||
return `${formatBilling(previewBase.value.perSecond * m, 4)} /秒`
|
||||
}
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `${formatBilling(previewBase.value.perRequest * m, 4)} /次`
|
||||
}
|
||||
@@ -275,6 +562,9 @@ function segmentPreview(seg: TimeSegmentRow): string {
|
||||
}
|
||||
|
||||
function defaultPreviewText(): string {
|
||||
if (previewBase.value.kind === 'per_second') {
|
||||
return `${formatBilling(previewBase.value.perSecond, 4)} /秒`
|
||||
}
|
||||
if (previewBase.value.kind === 'per_request') {
|
||||
return `${formatBilling(previewBase.value.perRequest, 4)} /次`
|
||||
}
|
||||
@@ -353,6 +643,11 @@ watch(editorBillingMode, (mode) => {
|
||||
editorItems[0].billing_mode = mode
|
||||
}
|
||||
}
|
||||
// 切到按秒模式时给一个常见起始模板(已手动编辑过则不覆盖)
|
||||
if (mode === 'per_second' && perSecondRows.length === 0) {
|
||||
addPerSecondRow('720p')
|
||||
addPerSecondRow('*')
|
||||
}
|
||||
})
|
||||
|
||||
function addTier() {
|
||||
@@ -405,6 +700,21 @@ async function savePricing() {
|
||||
}
|
||||
editorSaving.value = true
|
||||
try {
|
||||
// 按秒模式:行编辑器 → 矩阵(校验失败中止保存)
|
||||
let perSecondPrices: Record<string, number> | null = null
|
||||
if (editorBillingMode.value === 'per_second') {
|
||||
perSecondPrices = buildPerSecondPrices()
|
||||
if (!perSecondPrices) {
|
||||
editorSaving.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
// 参数倍率:行编辑器 → 提交载荷(校验失败中止保存)
|
||||
const paramMultipliers = buildParamMultipliersPayload()
|
||||
if (paramMultipliers === null) {
|
||||
editorSaving.value = false
|
||||
return
|
||||
}
|
||||
const items = editorItems.map((item, index) => ({
|
||||
billing_mode: editorBillingMode.value,
|
||||
min_tokens: editorBillingMode.value === 'tiered' ? item.min_tokens : 0,
|
||||
@@ -414,10 +724,12 @@ async function savePricing() {
|
||||
per_request_price: editorBillingMode.value === 'per_request' ? (item.per_request_price ?? null) : null,
|
||||
cache_read_price: item.cache_read_price,
|
||||
cache_creation_price: item.cache_creation_price,
|
||||
per_second_prices: editorBillingMode.value === 'per_second' ? perSecondPrices : null,
|
||||
}))
|
||||
await request.put(`/admin/models/${props.modelId}/pricing`, {
|
||||
items,
|
||||
time_segments: segmentsPayload(),
|
||||
param_multipliers: paramMultipliers,
|
||||
price_note: editorPriceNote.value.trim(),
|
||||
discount_label: editorDiscountLabel.value.trim(),
|
||||
price_change_note: editorPriceChangeNote.value.trim(),
|
||||
@@ -580,6 +892,58 @@ watch(() => props.visible, (val) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Per-second Mode(视频按秒计费:分辨率 × 每秒单价矩阵,单卡片行式紧凑布局) -->
|
||||
<template v-else-if="editorBillingMode === 'per_second'">
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>按秒定价</h3>
|
||||
<span class="section-hint">按视频时长(秒)计费,费用 = 命中规格单价 × 秒数;<code>*</code> 为兜底价(请求规格未命中时使用)</span>
|
||||
</div>
|
||||
<div class="per-second-card">
|
||||
<div v-if="perSecondRows.length" class="per-second-col-head">
|
||||
<span>规格</span>
|
||||
<span>每秒单价</span>
|
||||
<span class="per-second-op-col"></span>
|
||||
</div>
|
||||
<div v-for="(row, index) in perSecondRows" :key="index" class="per-second-row">
|
||||
<AInput
|
||||
v-model="row.spec"
|
||||
placeholder="如 720p / 1080p / *(兜底)"
|
||||
:max-length="32"
|
||||
class="per-second-spec"
|
||||
/>
|
||||
<AInputNumber
|
||||
v-model="row.price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="0"
|
||||
class="per-second-price"
|
||||
>
|
||||
<template #suffix>{{ currencySymbol }} / 秒</template>
|
||||
</AInputNumber>
|
||||
<AButton
|
||||
size="mini"
|
||||
status="danger"
|
||||
title="删除该规格"
|
||||
@click="removePerSecondRow(index)"
|
||||
>✕</AButton>
|
||||
</div>
|
||||
<div v-if="!perSecondRows.length" class="per-second-empty">
|
||||
暂无规格,点击下方按钮或预设快捷添加
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<AButton size="small" type="outline" @click="addPerSecondRow()">+ 添加规格</AButton>
|
||||
<AButton
|
||||
v-for="preset in perSecondSpecPresets"
|
||||
:key="preset"
|
||||
size="mini"
|
||||
@click="addPerSecondRow(preset)"
|
||||
>{{ preset }}</AButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Token Mode -->
|
||||
<template v-else-if="editorBillingMode === 'token' && editorItems.length > 0">
|
||||
<div class="editor-section">
|
||||
@@ -795,6 +1159,125 @@ watch(() => props.visible, (val) => {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Param Multipliers(参数倍率:按请求参数匹配加/折扣,所有计费模式共享) -->
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>参数倍率</h3>
|
||||
<span class="section-hint">按归一化任务体参数匹配(如含参考图 / 时长档位 / 视频输入),命中规则连乘;所有计费模式共享</span>
|
||||
</div>
|
||||
<div v-for="(rule, ruleIndex) in paramRules" :key="ruleIndex" class="tier-card">
|
||||
<!-- 头行:命中说明(默认占位"规则N")跨前两列 · 命中倍率对齐「值」列 · 删除列 -->
|
||||
<div class="param-rule-head">
|
||||
<div class="param-field param-field--note">
|
||||
<span class="field-label field-label--required">说明</span>
|
||||
<AInput
|
||||
v-model="rule.note"
|
||||
:placeholder="`规则 ${ruleIndex + 1}`"
|
||||
:max-length="64"
|
||||
allow-clear
|
||||
class="param-note-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-field param-field--mult">
|
||||
<span class="field-label field-label--required">倍率</span>
|
||||
<AInputNumber
|
||||
v-model="rule.multiplier"
|
||||
:min="0.0001"
|
||||
:max="100"
|
||||
:precision="4"
|
||||
title="命中倍率"
|
||||
class="param-multiplier-input"
|
||||
>
|
||||
<template #suffix>×</template>
|
||||
</AInputNumber>
|
||||
</div>
|
||||
<!-- 删除整条规则:垃圾桶图标,与下方条件行的「−」同宽同列 -->
|
||||
<AButton
|
||||
class="param-head-remove"
|
||||
size="mini"
|
||||
status="danger"
|
||||
title="删除该规则"
|
||||
@click="removeParamRule(ruleIndex)"
|
||||
>
|
||||
<IconDelete />
|
||||
</AButton>
|
||||
</div>
|
||||
<!-- 条件行:路径 / 匹配 / 值 / 删除 四列固定网格;「值」列恒占位(无需比对值时留空),
|
||||
各条件行纵向对齐,不随匹配方式增减列而跳动 -->
|
||||
<div
|
||||
v-for="(cond, condIndex) in rule.conditions"
|
||||
:key="condIndex"
|
||||
class="param-condition-grid"
|
||||
>
|
||||
<div class="param-field param-field--path">
|
||||
<span class="field-label field-label--required">路径</span>
|
||||
<ASelect
|
||||
v-model="cond.path"
|
||||
:options="paramPathPresets.map((p) => ({ label: p, value: p }))"
|
||||
allow-create
|
||||
allow-search
|
||||
placeholder="如 metadata.image"
|
||||
class="param-path-select"
|
||||
/>
|
||||
</div>
|
||||
<div class="param-field param-field--match">
|
||||
<span class="field-label field-label--required">匹配</span>
|
||||
<ASelect v-model="cond.match" :options="paramMatchOptions" class="param-match-select" />
|
||||
</div>
|
||||
<div
|
||||
class="param-field param-field--value"
|
||||
:class="{ 'is-empty': !conditionNeedsValue(cond.match) }"
|
||||
>
|
||||
<span class="field-label field-label--required">值</span>
|
||||
<AInput
|
||||
v-model="cond.value"
|
||||
:placeholder="cond.match === 'between' ? 'min,max(如 8,12)' : '比对值'"
|
||||
class="param-value-input"
|
||||
/>
|
||||
</div>
|
||||
<!-- 行尾「−」= 移除该条件(仅剩一条时禁用);不用 ✕ 避免被当成关闭弹窗 -->
|
||||
<AButton
|
||||
class="param-cond-remove"
|
||||
size="mini"
|
||||
status="danger"
|
||||
title="移除该条件"
|
||||
:disabled="rule.conditions.length <= 1"
|
||||
@click="removeParamCondition(ruleIndex, condIndex)"
|
||||
>−</AButton>
|
||||
</div>
|
||||
<AButton size="mini" type="text" @click="addParamCondition(ruleIndex)">+ 添加条件(全部满足才命中)</AButton>
|
||||
</div>
|
||||
<AButton type="dashed" long class="mt-3" @click="addParamRule">+ 添加规则</AButton>
|
||||
|
||||
<!-- 试匹配:粘贴任务体 JSON 即时验证规则命中 -->
|
||||
<div v-if="paramRules.length" class="param-try-match">
|
||||
<div class="combo-preview-title">试匹配(粘贴归一化任务体 JSON 验证命中)</div>
|
||||
<ATextarea
|
||||
v-model="tryMatchInput"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder='如 {"model":"...","prompt":"...","seconds":"8","metadata":{"image":"https://...","duration":8}}'
|
||||
/>
|
||||
<template v-if="tryMatchResult()">
|
||||
<div class="combo-row">
|
||||
<span class="combo-name">命中规则</span>
|
||||
<span class="combo-value">
|
||||
<template v-if="tryMatchResult()!.hits.length">
|
||||
<ATag v-for="(h, i) in tryMatchResult()!.hits" :key="i" size="small" color="arcoblue">
|
||||
{{ h.label }} ×{{ h.multiplier }}
|
||||
</ATag>
|
||||
</template>
|
||||
<template v-else>无(按原价)</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="combo-row">
|
||||
<span class="combo-name">总倍率</span>
|
||||
<span class="combo-value">×{{ tryMatchResult()!.total }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="tryMatchInput.trim()" class="combo-preview-hint">JSON 解析失败或为空</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展示信息(折扣标签 / 价格调整说明对外展示,价格说明仅内部可见) -->
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
@@ -934,6 +1417,184 @@ watch(() => props.visible, (val) => {
|
||||
color: var(--ta-text-tertiary);
|
||||
}
|
||||
|
||||
/* 按秒定价:单卡片行式紧凑布局(规格 + 单价 + 删除一行排布) */
|
||||
.per-second-card {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--ta-border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.per-second-col-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-tertiary);
|
||||
}
|
||||
|
||||
.per-second-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.per-second-row + .per-second-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.per-second-spec {
|
||||
flex: 1 1 40%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.per-second-price {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.per-second-op-col {
|
||||
flex: 0 0 28px;
|
||||
}
|
||||
|
||||
.per-second-empty {
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-tertiary);
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* 行内字段标签:灰色小字前置,紧凑布局下的字段语义说明 */
|
||||
.field-label {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 必填标记:星号决定位在标签左侧(不占文本宽度),标签整体多留 8px,
|
||||
各行控件左边缘仍对齐;星号色对齐 Arco 表单必填符号 */
|
||||
.field-label--required {
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.field-label--required::before {
|
||||
content: '*';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
|
||||
/* 参数倍率:规则头行与条件行共用同一套列轨道(路径 · 匹配 · 值 · 删除)。
|
||||
头行「说明」占「路径」列、「倍率」落在「值」列,中间隔开「匹配」列,
|
||||
两个输入框既与下方条件行纵向对齐,又不会挤在一起 */
|
||||
.param-rule-head,
|
||||
.param-condition-grid {
|
||||
/* 匹配列 152px = 32px 必填标签 + 8px 间隙 + 112px 下拉框:
|
||||
下拉框内部固定吃掉 50px(边框 2 + 左右内边距 24 + 箭头区 24),剩 62px 文本区,
|
||||
刚好容纳最长的「大于等于」四字(14px 字号 × 4 = 56px)不被省略号截断 */
|
||||
--param-cols: minmax(0, 1.2fr) 152px minmax(0, 1fr) auto;
|
||||
display: grid;
|
||||
grid-template-columns: var(--param-cols);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.param-rule-head {
|
||||
grid-template-areas: 'note . mult remove';
|
||||
}
|
||||
|
||||
.param-condition-grid {
|
||||
grid-template-areas: 'path match value remove';
|
||||
}
|
||||
|
||||
/* 行内字段:标签 + 控件(标签定宽,各行控件左边缘对齐) */
|
||||
.param-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.param-field .field-label {
|
||||
min-width: 32px; /* 8px 必填星号 + 24px 两字标签,保证控件左边缘对齐 */
|
||||
}
|
||||
|
||||
.param-field--note {
|
||||
grid-area: note;
|
||||
}
|
||||
|
||||
.param-field--mult {
|
||||
grid-area: mult;
|
||||
}
|
||||
|
||||
.param-field--path {
|
||||
grid-area: path;
|
||||
}
|
||||
|
||||
.param-field--match {
|
||||
grid-area: match;
|
||||
}
|
||||
|
||||
.param-field--value {
|
||||
grid-area: value;
|
||||
}
|
||||
|
||||
/* 控件撑满所属列 */
|
||||
.param-field .param-note-input,
|
||||
.param-field .param-multiplier-input,
|
||||
.param-field .param-path-select,
|
||||
.param-field .param-match-select,
|
||||
.param-field .param-value-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 匹配方式无需比对值(如「有值」):保留列宽占位,仅隐藏内容 */
|
||||
.param-field--value.is-empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* 头行删除与条件行移除都是图标按钮:定宽 + 去内边距,两者同宽、右边缘对齐 */
|
||||
.param-head-remove,
|
||||
.param-cond-remove {
|
||||
grid-area: remove;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 窄屏:头行说明独占首行、倍率下移;条件行路径独占首行,匹配 / 值下移,避免列被压扁 */
|
||||
@media (max-width: 640px) {
|
||||
.param-rule-head,
|
||||
.param-condition-grid {
|
||||
--param-cols: 152px minmax(0, 1fr) auto;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.param-rule-head {
|
||||
grid-template-areas:
|
||||
'note note remove'
|
||||
'mult mult mult';
|
||||
}
|
||||
|
||||
.param-condition-grid {
|
||||
grid-template-areas:
|
||||
'path path remove'
|
||||
'match value value';
|
||||
}
|
||||
}
|
||||
|
||||
.param-try-match {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-fill-1);
|
||||
border: 1px solid var(--ta-border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.combo-preview {
|
||||
margin-top: 10px;
|
||||
padding: 10px 14px;
|
||||
|
||||
@@ -30,6 +30,7 @@ interface TimePriceItem {
|
||||
input_price?: number | null
|
||||
output_price?: number | null
|
||||
per_request_price?: number | null
|
||||
per_second_price?: number | null
|
||||
}
|
||||
|
||||
interface ModelItem {
|
||||
@@ -44,6 +45,7 @@ interface ModelItem {
|
||||
capabilities: string
|
||||
billing_mode: string | null
|
||||
per_request_price: number | null
|
||||
per_second_prices?: Record<string, number> | null
|
||||
discount_ratio: number | null
|
||||
max_concurrency: number | null
|
||||
input_price: number | null
|
||||
@@ -185,11 +187,24 @@ function formatTokenRange(min: number, max: number | null): string {
|
||||
|
||||
function hasPricing(m: ModelItem): boolean {
|
||||
if (m.billing_mode === 'per_request' && m.per_request_price != null) return true
|
||||
if (m.billing_mode === 'per_second' && m.per_second_prices && Object.keys(m.per_second_prices).length > 0) return true
|
||||
if (m.billing_mode === 'token' && (m.input_price != null || m.output_price != null)) return true
|
||||
if (m.billing_mode === 'tiered' && m.pricing_tiers?.length > 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 按秒计费矩阵 → 排序后的规格列表(480p → 720p → 1080p → 数字序,"*" 兜底档排最后)
|
||||
function perSecondEntries(m: ModelItem): Array<[string, number]> {
|
||||
if (!m.per_second_prices) return []
|
||||
return Object.entries(m.per_second_prices)
|
||||
.filter(([, v]) => Number(v) > 0)
|
||||
.sort(([a], [b]) => {
|
||||
if (a === '*') return 1
|
||||
if (b === '*') return -1
|
||||
return a.localeCompare(b, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
function getTieredStartPrice(m: ModelItem): string {
|
||||
if (!m.pricing_tiers?.length) return '-'
|
||||
const first = m.pricing_tiers[0]
|
||||
@@ -220,6 +235,7 @@ function timeWindow(tp: TimePriceItem): string {
|
||||
}
|
||||
|
||||
function timePriceText(m: ModelItem, tp: TimePriceItem): string {
|
||||
if (m.billing_mode === 'per_second') return `${formatPrice(tp.per_second_price ?? null)} /秒`
|
||||
if (m.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price ?? null)} /次`
|
||||
if (m.billing_mode === 'tiered') return `输入 ${formatPrice(tp.input_price ?? null)} · 输出 ${formatPrice(tp.output_price ?? null)} 起`
|
||||
return `输入 ${formatPrice(tp.input_price ?? null)} · 输出 ${formatPrice(tp.output_price ?? null)}`
|
||||
@@ -429,6 +445,20 @@ onMounted(fetchModels)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 按秒计费:分辨率 × 每秒单价矩阵 -->
|
||||
<template v-else-if="m.billing_mode === 'per_second'">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span
|
||||
v-for="([spec, price], idx) in perSecondEntries(m)"
|
||||
:key="idx"
|
||||
>
|
||||
<span class="text-gray-400">{{ spec === '*' ? '其他' : spec }}</span>
|
||||
<span class="ml-1 font-semibold text-gray-800">{{ formatPrice(price) }}</span>
|
||||
<span class="text-gray-400">/秒</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="m.billing_mode === 'tiered'">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs">
|
||||
|
||||
Reference in new issue
Block a user