feat(model):模型计费功能改造,支持按秒计费的方式

This commit is contained in:
qian committed 2026-09-12 20:42:26 +08:00
1 parent 97da0a70aa
commit e6293c4c7c
44 files changed
+2695 -640

No files matched your search

+59 -38
View File
@@ -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 导入结果响应
+18 -17
View File
@@ -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 {
+5
View File
@@ -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=不展示)
+2
View File
@@ -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.
+18 -32
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
}
+81 -94
View File
@@ -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)
}
+4 -4
View File
@@ -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) 对齐)。
+292
View File
@@ -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")
}
+219 -50
View File
@@ -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 {
+103
View File
@@ -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)
}
}
+112 -26
View File
@@ -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)
+78 -5
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 {
+14 -14
View File
@@ -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
}
+1
View File
@@ -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),
+21 -3
View File
@@ -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,
}
// 从结算结果填充计费快照
+1 -1
View File
@@ -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
}
+29 -34
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
}
+10 -18
View File
@@ -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(时段定价)
}
+1
View File
@@ -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
}
+9 -17
View File
@@ -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(时段定价)
}
+1
View File
@@ -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{
+3
View File
@@ -170,6 +170,9 @@ type UsageRecord struct {
// 其他 token
ImageOutputTokens int // 图像输出 token(DALL-E)
// 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 0
DurationSeconds int
// 请求元数据
RequestedModel string // 用户请求的模型名
UpstreamModel string // 上游实际模型名
+8 -4
View File
@@ -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)
+10 -6
View File
@@ -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)
}
+5 -4
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+6 -4
View File
@@ -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
}
+11 -3
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+24 -6
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+10 -6
View File
@@ -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
}
+662 -1
View File
@@ -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">