diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 2a945ea..036fdda 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -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 导入结果响应 diff --git a/api/tenant/v1/marketplace.go b/api/tenant/v1/marketplace.go index a03dd74..40dc531 100644 --- a/api/tenant/v1/marketplace.go +++ b/api/tenant/v1/marketplace.go @@ -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 { diff --git a/api/tenant/v1/models.go b/api/tenant/v1/models.go index 4226778..55eb3e6 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -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=不展示) diff --git a/internal/dao/internal/bil_usage_logs.go b/internal/dao/internal/bil_usage_logs.go index 8b5b155..53a2d6b 100644 --- a/internal/dao/internal/bil_usage_logs.go +++ b/internal/dao/internal/bil_usage_logs.go @@ -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. diff --git a/internal/dao/internal/mdl_pricing.go b/internal/dao/internal/mdl_pricing.go index dcdf6d2..b207f94 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -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=周日 空=每天,end0 / 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 } diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 0f95085..a16149f 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -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) } // 时段定价透传(挂锚点行) diff --git a/internal/logic/billing/e2e_param_multiplier_test.go b/internal/logic/billing/e2e_param_multiplier_test.go new file mode 100644 index 0000000..e7b512d --- /dev/null +++ b/internal/logic/billing/e2e_param_multiplier_test.go @@ -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") +} diff --git a/internal/logic/billing/e2e_time_segment_test.go b/internal/logic/billing/e2e_time_segment_test.go index a0b3138..7f606b3 100644 --- a/internal/logic/billing/e2e_time_segment_test.go +++ b/internal/logic/billing/e2e_time_segment_test.go @@ -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) } diff --git a/internal/logic/billing/money.go b/internal/logic/billing/money.go index fe8fda0..c845bde 100644 --- a/internal/logic/billing/money.go +++ b/internal/logic/billing/money.go @@ -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) 对齐)。 diff --git a/internal/logic/billing/param_multiplier.go b/internal/logic/billing/param_multiplier.go new file mode 100644 index 0000000..b60d249 --- /dev/null +++ b/internal/logic/billing/param_multiplier.go @@ -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 +} diff --git a/internal/logic/billing/param_multiplier_test.go b/internal/logic/billing/param_multiplier_test.go new file mode 100644 index 0000000..e4d5443 --- /dev/null +++ b/internal/logic/billing/param_multiplier_test.go @@ -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") +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 5130b0e..8ca65e4 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -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 { diff --git a/internal/logic/billing/pricing_blob_test.go b/internal/logic/billing/pricing_blob_test.go new file mode 100644 index 0000000..35fda75 --- /dev/null +++ b/internal/logic/billing/pricing_blob_test.go @@ -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) + } +} diff --git a/internal/logic/billing/pricing_migration_verify_test.go b/internal/logic/billing/pricing_migration_verify_test.go new file mode 100644 index 0000000..df5ed2f --- /dev/null +++ b/internal/logic/billing/pricing_migration_verify_test.go @@ -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) + } +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 67dd1cf..7a86b30 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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) diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index ca2724c..757d8aa 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -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) + } +} diff --git a/internal/logic/billing/time_pricing.go b/internal/logic/billing/time_pricing.go index 6572df3..46890c8 100644 --- a/internal/logic/billing/time_pricing.go +++ b/internal/logic/billing/time_pricing.go @@ -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 { diff --git a/internal/logic/monitor/relaykit_metrics.go b/internal/logic/monitor/relaykit_metrics.go index 166f975..12eb433 100644 --- a/internal/logic/monitor/relaykit_metrics.go +++ b/internal/logic/monitor/relaykit_metrics.go @@ -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 } diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 6f0485b..81280b9 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -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), diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index c2a0e53..6f04c39 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -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, } // 从结算结果填充计费快照 diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index 155e7de..4f3a407 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -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 } diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 5052c0c..5cbfee5 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -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 diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index d896b4e..06f802f 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -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) diff --git a/internal/model/do/bil_usage_logs.go b/internal/model/do/bil_usage_logs.go index 84240bc..3ec185c 100644 --- a/internal/model/do/bil_usage_logs.go +++ b/internal/model/do/bil_usage_logs.go @@ -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 } diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 608b74b..4fcec57 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -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 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 } diff --git a/relay/taskchannel/gemini/adaptor.go b/relay/taskchannel/gemini/adaptor.go index 85b324f..f243b34 100644 --- a/relay/taskchannel/gemini/adaptor.go +++ b/relay/taskchannel/gemini/adaptor.go @@ -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 } diff --git a/relay/taskchannel/kling/adaptor.go b/relay/taskchannel/kling/adaptor.go index 1f86878..20683c2 100644 --- a/relay/taskchannel/kling/adaptor.go +++ b/relay/taskchannel/kling/adaptor.go @@ -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 } diff --git a/relay/taskchannel/midjourney/adaptor.go b/relay/taskchannel/midjourney/adaptor.go index 1c382c2..1ab9daa 100644 --- a/relay/taskchannel/midjourney/adaptor.go +++ b/relay/taskchannel/midjourney/adaptor.go @@ -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 } diff --git a/relay/taskchannel/sora/adaptor.go b/relay/taskchannel/sora/adaptor.go index 8a43b30..9f9b921 100644 --- a/relay/taskchannel/sora/adaptor.go +++ b/relay/taskchannel/sora/adaptor.go @@ -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 } diff --git a/relay/taskchannel/suno/adaptor.go b/relay/taskchannel/suno/adaptor.go index f04803b..9f00ad7 100644 --- a/relay/taskchannel/suno/adaptor.go +++ b/relay/taskchannel/suno/adaptor.go @@ -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 } diff --git a/relay/taskchannel/volcengine/adaptor.go b/relay/taskchannel/volcengine/adaptor.go index d26772b..053c631 100644 --- a/relay/taskchannel/volcengine/adaptor.go +++ b/relay/taskchannel/volcengine/adaptor.go @@ -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 } diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index 8887117..81b09c4 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -1,7 +1,7 @@