diff --git a/CLAUDE.md b/CLAUDE.md index 79ff840..8573818 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -269,8 +269,9 @@ cmd(路由注册) 钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)): -- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。 -- **DB 物化**:`bil_wallets.balance / frozen_balance` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。 +- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。 +- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。 +- **DB 物化**:`bil_wallets.balance / frozen_balance / total_consumed` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。 - **预扣记录纯 Redis**:`bil_prededuct_tracks` 表已废弃(预扣明细 = `prededuct:v2:{request_id}` hash + `prededuct_active:{tenant}` 集合,TTL 2h),孤儿清扫由 cron `prededuct_sweep` 每 2 分钟按「重算 frozen = Σ 幸存预扣」自愈,不再依赖 DB 表。 - **可用余额** = balance - frozen_balance,预扣/扣款/退款门槛都在 Redis Lua 内原子判断。 - **幂等闸门**:结算幂等靠 `bil_records.request_id` 唯一约束(DB,先行于任何资金变动);预扣/解冻幂等靠 Redis hash 认领即删。 diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 2a945ea..50c349b 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // ModelListReq 模型列表请求 type ModelListReq struct { @@ -8,6 +12,7 @@ type ModelListReq struct { Page int `json:"page" d:"1" v:"min:1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"` Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"` + Vendor string `json:"vendor" dc:"研发厂商筛选:openai/anthropic/google/alibaba/deepseek等,空=不过滤"` Status string `json:"status" dc:"状态筛选:active/deprecated/offline"` Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` PricingStatus string `json:"pricing_status" dc:"定价状态筛选:priced/unpriced"` @@ -34,6 +39,7 @@ type ModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) Status string `json:"status"` MaxContext int `json:"max_context_tokens"` MaxOutput int `json:"max_output_tokens"` @@ -45,11 +51,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"` } @@ -60,6 +67,7 @@ type ModelCreateReq struct { ModelId string `json:"model_id" v:"required|length:1,100#请输入模型标识|模型标识长度1-100" dc:"模型唯一标识"` ModelName string `json:"model_name" dc:"模型显示名称"` Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类必须是 chat/embedding/image/audio/rerank/video" dc:"模型分类"` + Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"` MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"` MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"` Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` @@ -74,10 +82,12 @@ type ModelCreateRes struct { // ModelUpdateReq 更新模型请求 type ModelUpdateReq struct { - g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"` - ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"` - ModelName string `json:"model_name" dc:"模型显示名称"` - Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"` + g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"` + ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"` + ModelName string `json:"model_name" dc:"模型显示名称"` + Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"` + // Vendor 研发厂商;指针语义:nil=不更新,空串=清空回未分类,非空=设置(枚举校验在 logic 层做) + Vendor *string `json:"vendor" dc:"研发厂商"` MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"` MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"` Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"` @@ -94,19 +104,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,special" dc:"计费模式(special 仅由特殊计费方案编辑器提交,不进通用模式下拉)"` + 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 +130,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,24 +153,45 @@ 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 顶层,横切所有计费模式)"` + // 官方参考定价(official_pricing JSONB 还原,结构与计费定价完全一致;OfficialItems nil=未配置)。 + // 非计费依据,管理端按官方价 × 折扣快速定价 / 计算现价几折以此为准 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(tiered 展开为多行;nil=未配置)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则"` + // 以下为定价行展示字段 PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"` + // 计费方案:空 = 通用引擎(前端渲染内置通用表单);非空时前端分发到方案专属编辑器 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎)"` + // 方案私有配置(不透明容器):schema 由方案自定义,通用表单不理解其内容 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(随方案编辑器解析)"` } // 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:"参数倍率规则(可选,全量替换;空数组清除)"` + // 官方参考定价(可选,结构与计费定价完全一致,支持全部四种计费模式 + 时段/倍率): + // OfficialItems nil=本次不动库内官方定价;非 nil=全量替换(三个数组全空=清除)。金额本位币,非计费依据 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(nil=不修改;非 nil=全量替换,与另两个官方数组全空=清除)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价(随 official_items 一并全量替换)"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则(随 official_items 一并全量替换)"` + // 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端) PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"` + // 计费方案(全量替换语义:空串=回落通用引擎并清除 scheme_config)。 + // 必须是已注册方案(后端校验),自定义方案建议 custom: 前缀命名 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎并清除私有配置)"` + // 方案私有配置:仅 scheme 非空时允许携带,由方案实现的校验规则把关 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(方案自定义 schema)"` } type PricingSetRes struct{} @@ -179,6 +227,7 @@ type ModelExportReq struct { g.Meta `path:"/models/export" method:"get" mime:"json" tags:"管理后台-模型" summary:"导出模型列表"` Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` Category string `json:"category" in:"query" dc:"模型分类筛选:chat/embedding/image/audio/rerank"` + Vendor string `json:"vendor" in:"query" dc:"研发厂商筛选,空=不过滤"` Status string `json:"status" in:"query" dc:"状态筛选:active/deprecated/offline"` Search string `json:"search" in:"query" dc:"搜索关键词(模型名或显示名)"` } @@ -253,22 +302,28 @@ type ModelImportPreviewRes struct { Models []ModelImportPreviewItem `json:"models"` } -// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记) +// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)。 +// 前端确认导入时把 preview 返回的 models 原样回传,因此该结构必须携带 +// 全量可还原字段(含参数倍率与计费方案声明),缺字段会在回传链路被静默丢弃 type ModelImportPreviewItem 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"` - Capabilities map[string]bool `json:"capabilities"` - Description string `json:"description"` - Tags []string `json:"tags"` - SunsetDate string `json:"sunset_date"` - ReplacementModel string `json:"replacement_model"` - Pricing []PricingItem `json:"pricing"` - TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"` - Conflict string `json:"conflict"` // "" 或 "exists" + ModelId string `json:"model_id"` + ModelName string `json:"model_name"` + Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) + Status string `json:"status"` + MaxContextTokens int `json:"max_context_tokens"` + MaxOutputTokens int `json:"max_output_tokens"` + Capabilities map[string]bool `json:"capabilities"` + Description string `json:"description"` + Tags []string `json:"tags"` + SunsetDate string `json:"sunset_date"` + ReplacementModel string `json:"replacement_model"` + Pricing []PricingItem `json:"pricing"` + TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"` + ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"` + Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"` + Conflict string `json:"conflict"` // "" 或 "exists" } // ModelImportReq 确认导入模型请求 @@ -277,22 +332,26 @@ 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:"模型分类"` + Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" 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:"参数倍率规则(可选)"` + Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"` + ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"` } // ModelImportRes 导入结果响应 diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 57c40a9..dc240ea 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -25,6 +25,8 @@ type TenantListReq struct { PageSize int `json:"page_size" d:"20" dc:"每页数量"` Keyword string `json:"keyword" dc:"搜索关键词(名称/代码)"` Status string `json:"status" dc:"状态筛选:active/suspended/closed"` + Tag string `json:"tag" dc:"标签精确筛选"` + Level *int `json:"level" dc:"等级筛选(对应 tnt_tenant_level_configs.level)"` } type TenantListRes struct { @@ -35,24 +37,27 @@ type TenantListRes struct { } type TenantItem struct { - ID int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - LogoURL string `json:"logo_url"` - OwnerUserID int64 `json:"owner_user_id"` - OwnerName string `json:"owner_name"` - Status string `json:"status"` - MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"` - MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"` - EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"` - EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"` - DefaultChannelScope string `json:"default_channel_scope"` - MemberCount int `json:"member_count"` - WalletBalance string `json:"wallet_balance"` - Level int `json:"level"` - LevelName string `json:"level_name"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + LogoURL string `json:"logo_url"` + OwnerUserID int64 `json:"owner_user_id"` + OwnerName string `json:"owner_name"` + Status string `json:"status"` + MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"` + MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"` + EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"` + EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"` + DefaultChannelScope string `json:"default_channel_scope"` + MemberCount int `json:"member_count"` + WalletBalance string `json:"wallet_balance"` + Tags []string `json:"tags" dc:"租户标签(管理后台运营标注)"` + Remark string `json:"remark" dc:"管理员备注"` + TotalConsumed string `json:"total_consumed" dc:"累计消费(本位币,decimal 字符串;Redis 权威计数的物化副本,≤5s 滞后)"` + Level int `json:"level"` + LevelName string `json:"level_name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // TenantGetReq 获取租户详情 @@ -85,11 +90,13 @@ type TenantUpdateStatusRes struct{} // TenantUpdateReq 更新租户信息 type TenantUpdateReq struct { g.Meta `path:"/tenants/{id}" method:"put" mime:"json" tags:"管理后台-租户管理" summary:"更新租户"` - Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"` - Name string `json:"name" dc:"租户名称"` - MaxMembers *int `json:"max_members" dc:"最大成员数"` - MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"` - Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"` + Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"` + Name string `json:"name" dc:"租户名称"` + MaxMembers *int `json:"max_members" dc:"最大成员数"` + MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"` + Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"` + Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符,传空数组清空标签)"` + Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"` } type TenantUpdateRes struct{} @@ -138,6 +145,8 @@ type TenantExportReq struct { Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"` Keyword string `json:"keyword" in:"query" dc:"搜索关键词(名称/代码)"` Status string `json:"status" in:"query" dc:"状态筛选:active/suspended/closed"` + Tag string `json:"tag" in:"query" dc:"标签筛选"` + Level *int `json:"level" in:"query" dc:"等级筛选"` } type TenantExportRes struct{} diff --git a/api/tenant/v1/marketplace.go b/api/tenant/v1/marketplace.go index a03dd74..a9b432a 100644 --- a/api/tenant/v1/marketplace.go +++ b/api/tenant/v1/marketplace.go @@ -8,6 +8,7 @@ type MarketplaceListReq struct { g.Meta `path:"/marketplace/models" method:"get" tags:"公开-模型广场" summary:"获取模型广场列表" group:"public" middleware:"-"` Keyword string `json:"keyword" dc:"搜索关键词"` Category string `json:"category" dc:"模型类别筛选(chat/embedding/image/audio等)"` + Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"` Page int `json:"page" d:"1" dc:"页码"` PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"` } @@ -17,26 +18,31 @@ type MarketplaceListRes struct { Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` + // Vendors 当前筛选条件(关键词/分类,忽略厂商筛选本身)下有模型的厂商去重列表, + // 供前端只渲染实际有模型的厂商筛选项;顺序不保证,前端按本地字典序展示 + Vendors []string `json:"vendors"` } 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等) + Vendor string `json:"vendor"` // 研发厂商(openai/anthropic/alibaba等,空=未分类) + 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..79d6866 100644 --- a/api/tenant/v1/models.go +++ b/api/tenant/v1/models.go @@ -6,6 +6,7 @@ import "github.com/gogf/gf/v2/frame/g" type TenantAvailableModelsReq struct { g.Meta `path:"/models" method:"get" mime:"json" tags:"租户控制台-模型" summary:"租户可用模型列表"` Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"` + Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"` Search string `json:"search" dc:"搜索关键词(模型名或显示名)"` // ApiKeyID 按指定 API Key 的模型范围过滤(可选;不传或为 0 时返回租户全部可用模型) ApiKeyID int64 `json:"api_key_id" dc:"API Key ID(可选,传入时按该 Key 的模型范围过滤)"` @@ -36,6 +37,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 租户可用模型信息 @@ -44,6 +47,7 @@ type TenantAvailableModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` // 研发厂商(空=未分类) MaxContext int `json:"max_context_tokens"` MaxOutput int `json:"max_output_tokens"` Description string `json:"description"` @@ -58,6 +62,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/docs/reference/api-format-reference.md b/docs/reference/api-format-reference.md index 72e2d4a..28e42d2 100644 --- a/docs/reference/api-format-reference.md +++ b/docs/reference/api-format-reference.md @@ -69,7 +69,7 @@ | 10003 | `CodeChannelUnavailable` | 没有可用的渠道 | | ... | 更多见 `consts.go` | ... | -## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/suno/*`) +## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/v2/*`、`/suno/*`) ### 已注册的代理端点 @@ -92,12 +92,65 @@ | GET | `/v1/realtime` | 实时对话(WebSocket) | OpenAI | WebSocket | | POST | `/v1/video/generations` | 视频生成(异步任务) | 自定义 | 否 | | GET | `/v1/video/generations/{task_id}` | 视频生成任务查询 | — | 否 | +| POST | `/v1/videos` | OpenAI Videos 视频生成(multipart/JSON) | OpenAI | 否 | +| GET | `/v1/videos/{video_id}` | OpenAI Videos 任务查询 | — | 否 | +| GET | `/v1/videos/{video_id}/content` | OpenAI Videos 成品下载 | — | 否 | +| DELETE | `/v1/videos/{video_id}` | OpenAI Videos 任务删除(终态软删) | — | 否 | | GET | `/v1beta/models` | Gemini 模型列表 | Gemini | 否 | | GET | `/v1beta/models/{model}` | Gemini 模型详情 | Gemini | 否 | | POST | `/v1beta/models/{model}` | Gemini 内容生成 | Gemini | SSE | | POST | `/suno/submit/{action}` | Suno 音乐生成提交 | 自定义 | 否 | | POST | `/suno/fetch` | Suno 批量查询 | 自定义 | 否 | | GET | `/suno/fetch/{task_id}` | Suno 任务查询 | — | 否 | +| POST | `/v2/video_generation` | MiniMax H3 视频生成(官方协议 v2) | MiniMax | 否 | +| GET | `/v2/query/video_generation/{task_id}` | MiniMax H3 任务查询(v2) | — | 否 | +| DELETE | `/v2/video_generation/{task_id}` | MiniMax H3 任务取消(仅排队中,v2) | — | 否 | +| POST | `/v1/video_generation` | MiniMax Hailuo 视频生成(官方协议 v1,四形态共用) | MiniMax | 否 | +| GET | `/v1/query/video_generation?task_id=` | MiniMax Hailuo 任务查询(v1,query 参数) | — | 否 | + +### MiniMax 官方视频协议(`/v2/*`,MiniMax-H3/H3-Max) + +对 MiniMax v2 视频协议的原生接入:官方 SDK 把 base_url 指向网关即可直连。协议规格见 `docs/modeldocs/minimax/video/`。 + +**提交** `POST /v2/video_generation`(Bearer 平台 API Key): + +```json +{ + "model": "MiniMax-H3", + "content": [{ "type": "text", "text": "海浪拍打礁石,慢镜头。" }], + "resolution": "768P", + "duration": 6, + "ratio": "16:9" +} +``` + +- `model` / `content`(含非空 text 项)/ `resolution` / `duration` 四项必填;`ratio` 文生视频时必填(不能为 `adaptive`) +- `callback_url` **会被剥离**:网关按自身任务体系轮询计费,上游回调携带的上游 task_id 与网关公开 ID 不互通 +- 提交响应为官方形态:`{"task_id": "task_xxx"}`(网关公开任务 ID,非上游 ID) + +**查询** `GET /v2/query/video_generation/{task_id}` → `{"task": {官方 VideoTask}}`: +状态机 `queued/running/succeeded/failed`(上游取消的任务回放为 `failed`)。`content.url` 为上游限时直链; +`usage/resolution/duration/ratio` 从最近一次上游查询响应回放,任务提交后首拍轮询前(≤15s)可能缺失。 + +**取消** `DELETE /v2/video_generation/{task_id}`:仅支持取消**排队中**(queued)的任务,取消结果以远程返回为准—— +网关调用上游 DELETE,上游确认 `action=cancelled` 才向客户端返回成功 +(`{"task_id": "...", "action": "cancelled", "status": "cancelled"}`);上游拒绝时(如任务已进入 running) +原样透传上游错误。**不支持删除任务记录**(终态任务不可删,软删也不做,任务数据保留用于计费与审计;错误码 `task_not_cancellable`)。 +取消确认后本地状态由轮询循环在下一拍(≤15s)对账收敛为 failed,预扣随之退款。 + +错误响应为 OpenAI 风格 OaiError(`{"error": {"type": "...", "message": "..."}}`),与官方 v2 一致。 + +**OpenAI 协议互通**:同一渠道下 `POST /v1/videos`(OpenAI Videos 协议)与 `POST /v1/video/generations`(通用任务体)可直接调用 MiniMax 视频模型(H3 与 Hailuo 系列按模型自动分派 v2/v1 上游协议)。`size` 只借 OpenAI 协议的字段形态、**值原样透传不换算**——请直接传目标供应商的原生分辨率词汇(H3:`768P`/`2K`/`480P`;Hailuo 2.x:`768P`/`1080P`);非官方档位值会原样送达上游(上游 400 则任务提交失败、预扣全额退款)。时长取 `seconds`/`duration`/`metadata.duration`(缺省 6 秒),`input_reference`/`images[0]` 作为首帧图(v1 转为 `first_frame_image`),纯文生视频时 v2 ratio 默认 `16:9`。 + +### MiniMax 官方视频协议 v1(`/v1/video_generation`,Hailuo 系列) + +Hailuo 2.x 及旧型号(T2V/I2V/S2V-01,传入即转发不拦截)走 v1 扁平字段协议: + +- **提交** `POST /v1/video_generation`:文生(`prompt` 必填)/ 图生(`first_frame_image` 必填,支持 URL 或 data URI)/ 首尾帧(`last_frame_image`,仅 Hailuo-02)/ 主体参考(`subject_reference`,S2V-01)四形态共用;`duration`(默认 6,768P 支持 10)/`resolution`(Hailuo 2.x 默认 `768P`,旧型号默认 `720P`)可选;`prompt_optimizer`/`fast_pretreatment`/`aigc_watermark` 透传;`callback_url` 剥离。响应为官方 `{task_id, base_resp}` 形态(task_id 为网关公开 ID) +- **查询** `GET /v1/query/video_generation?task_id=`:官方 `{task_id, status, file_id, video_width, video_height, base_resp}` 形态,状态机 `Preparing/Queueing/Processing/Success/Fail`;**增补字段** `download_url`(网关轮询时经官方 files/retrieve 二跳解析的限时直链,官方查询响应无此字段)与 `error`(失败原因) +- **成品二跳由网关代取**:官方流程「file_id → GET /v1/files/retrieve → download_url」在轮询时链式完成,`download_url` 落任务记录并回放到查询响应;不暴露 files/retrieve 代理端点 +- **取消/删除**:v1 上游无此能力,端点不提供(v2 的 DELETE 端点对 v1 任务返回 400) +- v1 无 usage 返回,计费按请求规格(per_second 矩阵 × 时长),与 v2 口径一致 ### OpenAI 格式端点响应 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 9a48fcd..be79414 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -157,6 +157,10 @@ var ( // relay 层运行时配置注入(全局超时兜底/系统代理;渠道级 settings 仍优先) syncGlobalRelaySettings(ctx) + // 累计消费基线种子:为存量钱包 hash 补种 total_consumed 历史基线(幂等), + // 必须在 relay 流量进入前完成,防止结算 HINCRBY 从 0 起算丢历史 + billing.SeedWalletTotalConsumed(ctx) + // 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron) billing.StartWalletMaterializer(ctx) @@ -539,6 +543,11 @@ func registerRelayRoutes(server *ghttp.Server) { group.POST("/moderations", relay.HandleModerations) group.POST("/images/edits", relay.HandleImagesEdits) group.GET("/realtime", relay.HandleRealtime) + // OpenAI Videos 协议端点(官方 SDK 兼容,对接第三方视频模型) + group.POST("/videos", relay.HandleVideoCreate) + group.GET("/videos/{video_id}", relay.HandleVideoRetrieve) + group.GET("/videos/{video_id}/content", relay.HandleVideoContent) + group.DELETE("/videos/{video_id}", relay.HandleVideoDelete) }) // Gemini 兼容路由(/v1beta/models/{model}:generateContent) @@ -570,6 +579,22 @@ func registerRelayRoutes(server *ghttp.Server) { group.POST("/fetch", relay.HandleSunoFetchBatch) group.GET("/fetch/{task_id}", relay.HandleTaskFetch) }) + + // MiniMax 官方视频协议端点(H3 v2,官方 SDK 换 base_url 直连;协议文档 docs/modeldocs/minimax/video/) + server.Group("/v2", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.POST("/video_generation", relay.HandleMiniMaxVideoSubmit) + group.GET("/query/video_generation/{task_id}", relay.HandleMiniMaxVideoRetrieve) + group.DELETE("/video_generation/{task_id}", relay.HandleMiniMaxVideoCancel) + }) + + // MiniMax 官方视频协议端点(Hailuo 系列 v1,扁平字段协议;查询走 query 参数, + // 成品经 files/retrieve 二跳由网关轮询时代取,官方无取消/删除端点) + server.Group("/v1", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter) + group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit) + group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve) + }) } // registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页, 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/bil_wallets.go b/internal/dao/internal/bil_wallets.go index 2d84fa0..f5d8ceb 100644 --- a/internal/dao/internal/bil_wallets.go +++ b/internal/dao/internal/bil_wallets.go @@ -31,6 +31,7 @@ type BilWalletsColumns struct { UpdatedAt string // 更新时间 CumulativeRecharge string // 累计充值总额(USD) LowBalanceNotified string // 低余额预警是否已推送(充值恢复后重置为 false) + TotalConsumed string // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减) } // bilWalletsColumns holds the columns for the table bil_wallets. @@ -45,6 +46,7 @@ var bilWalletsColumns = BilWalletsColumns{ UpdatedAt: "updated_at", CumulativeRecharge: "cumulative_recharge", LowBalanceNotified: "low_balance_notified", + TotalConsumed: "total_consumed", } // NewBilWalletsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_models.go b/internal/dao/internal/mdl_models.go index 88fe9ec..49998fe 100644 --- a/internal/dao/internal/mdl_models.go +++ b/internal/dao/internal/mdl_models.go @@ -36,6 +36,7 @@ type MdlModelsColumns struct { DeprecatedAt string // 标记弃用的时间(NULL表示未弃用) SunsetDate string // 计划下线日期(到达后返回410 Gone,NULL表示未设置) ReplacementModel string // 推荐替代模型名(NULL表示无替代) + Vendor string // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类 } // mdlModelsColumns holds the columns for the table mdl_models. @@ -55,6 +56,7 @@ var mdlModelsColumns = MdlModelsColumns{ DeprecatedAt: "deprecated_at", SunsetDate: "sunset_date", ReplacementModel: "replacement_model", + Vendor: "vendor", } // NewMdlModelsDao 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..e5e754d 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -21,42 +21,30 @@ 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 @@ -91,6 +101,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` Status string `json:"status"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` @@ -104,7 +115,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ReplacementModel string `json:"replacement_model"` } - err := query.Fields("id, model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model"). + err := query.Fields("id, model_id, model_name, category, vendor, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model"). OrderDesc("id"). Page(req.Page, req.PageSize). ScanAndCount(&models, &total, false) @@ -118,17 +129,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 @@ -179,6 +188,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, Status: m.Status, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, @@ -197,13 +207,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 + } + } } } // 填充可用渠道 @@ -235,6 +260,7 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M ModelId: req.ModelId, ModelName: req.ModelName, Category: req.Category, + Vendor: req.Vendor, // 空串=未分类 Status: "active", MaxContextTokens: req.MaxContext, MaxOutputTokens: req.MaxOutput, @@ -255,13 +281,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 @@ -270,6 +294,22 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M return &v1.ModelCreateRes{ID: id}, nil } +// validModelVendors 模型研发厂商合法值集合(与 mdl_models.vendor 枚举、API v:"in:" 标签、前端字典对齐)。 +// 注意与 chn_channels.type 的"渠道供应商"是两个维度:研发厂商指模型的开发公司, +// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。 +var validModelVendors = map[string]struct{}{ + "openai": {}, "anthropic": {}, "google": {}, "xai": {}, "mistral": {}, "cohere": {}, + "meta": {}, "alibaba": {}, "bytedance": {}, "deepseek": {}, "zhipu": {}, "moonshot": {}, + "minimax": {}, "baidu": {}, "tencent": {}, "xunfei": {}, "kuaishou": {}, "midjourney": {}, + "suno": {}, +} + +// isValidModelVendor 校验研发厂商枚举值 +func isValidModelVendor(v string) bool { + _, ok := validModelVendors[v] + return ok +} + // UpdateModel 更新模型(含弃用状态管理) func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) { var oldModel *struct { @@ -293,6 +333,14 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M data.Category = req.Category hasUpdate = true } + // 厂商指针语义:nil=不更新;空串=清空回未分类(do 字段为 any,空串不会被 ORM 忽略);非空=校验枚举后设置 + if req.Vendor != nil { + if *req.Vendor != "" && !isValidModelVendor(*req.Vendor) { + return nil, common.NewBusinessError(422, "厂商值不合法") + } + data.Vendor = *req.Vendor + hasUpdate = true + } data.MaxContextTokens = req.MaxContext data.MaxOutputTokens = req.MaxOutput hasUpdate = true @@ -440,68 +488,126 @@ 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"` + OfficialPricing string `json:"official_pricing" orm:"official_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)) - var timeSegments []billing.TimeSegment + var result []v1.PricingItem + var timeSegments []v1.TimeSegmentItem + var paramRules []v1.ParamMultiplierItem + var officialItems []v1.PricingItem + var officialTimeSegments []v1.TimeSegmentItem + var officialParamRules []v1.ParamMultiplierItem 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) + var scheme string + var schemeConfig json.RawMessage + if row != nil { + // 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑) + blob := billing.ParsePricingBlob(row.Pricing) + result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, blob) + if blob != nil { + scheme = blob.Scheme + schemeConfig = blob.SchemeConfig } + + // 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil + offMode, offBlob := officialBlobFromJSON(row.OfficialPricing) + if offBlob != nil { + officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob) + } + + 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: timeSegments, + ParamMultipliers: paramRules, + OfficialItems: officialItems, + OfficialTimeSegments: officialTimeSegments, + OfficialParamMultipliers: officialParamRules, + PriceNote: priceNote, + DiscountLabel: discountLabel, + PriceChangeNote: priceChangeNote, + Scheme: scheme, + SchemeConfig: schemeConfig, }, nil } +// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。 +// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时 +// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。 +func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) { + if blob == nil { + blob = &billing.PricingBlob{} + } + items := make([]v1.PricingItem, 0, 1) + switch { + case len(blob.Tiers) > 0: + // tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。 + // 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容) + for i, tier := range blob.Tiers { + item := v1.PricingItem{ + BillingMode: mode, + MinTokens: tier.MinTokens, + MaxTokens: tier.MaxTokens, + InputPrice: tier.InputPrice, + OutputPrice: tier.OutputPrice, + } + if tier.CacheReadPrice != nil { + item.CacheReadPrice = *tier.CacheReadPrice + } else if i == 0 && blob.CacheReadPrice != nil { + item.CacheReadPrice = *blob.CacheReadPrice + } + if tier.CacheCreationPrice != nil { + item.CacheCreationPrice = *tier.CacheCreationPrice + } else if i == 0 && blob.CacheCreationPrice != nil { + item.CacheCreationPrice = *blob.CacheCreationPrice + } + items = append(items, item) + } + default: + item := v1.PricingItem{BillingMode: mode} + 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 + } + items = append(items, item) + } + // 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原 + return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers) +} + // derefStr 字符串指针安全解引用(NULL → 空串) func derefStr(s *string) string { if s == nil { @@ -553,17 +659,222 @@ 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 顶层写入。 +// scheme/schemeConfig 为计费方案声明与私有配置:导入路径透传导出文件中的方案字段 +// (旧版导出文件无该字段=空串/nil,导入后为通用引擎定价);两条路径均已在调用前 +// 完成 special ⇔ scheme 配对与 SchemeRegistered 校验。 +// 调用方需保证事务 ctx 传播。 +func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule, scheme string, schemeConfig json.RawMessage) error { + blob, err := billing.BuildPricingBlob(pricingItemsToInput(items)) + if err != nil { + return err + } + blob.ParamMultipliers = paramRules + blob.Scheme = scheme + blob.SchemeConfig = schemeConfig + blobJSON, err := json.Marshal(blob) + if err != nil { + return err + } + + // 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing, + // official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工 + // 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。 + // 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。 + if _, err := dao.MdlPricing.Ctx(ctx). + Data(g.Map{ + "model_id": modelDBID, + "billing_mode": items[0].BillingMode, + "pricing": string(blobJSON), + }). + OnConflict("model_id"). + OnDuplicate(g.Map{ + "billing_mode": gdb.Raw("excluded.billing_mode"), + "pricing": gdb.Raw("excluded.pricing"), + }). + Save(); err != nil { + return err + } + return nil +} + +// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺 +// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构, +// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。 +type officialPricingBlob struct { + BillingMode string `json:"billing_mode"` + billing.PricingBlob +} + +// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。 +// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。 +// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」 +// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存), +// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。 +func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) { + segs, err := timeSegmentsFromAPI(segments) + if err != nil { + return nil, err + } + paramRules, err := paramMultipliersFromAPI(rules) + if err != nil { + return nil, err + } + input := pricingItemsToInput(items) + var blob *billing.PricingBlob + if len(input) > 0 && (input[0].BillingMode == "per_second" || input[0].BillingMode == billing.BillingModeSpecial) && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) { + blob = &billing.PricingBlob{} + } else { + blob, err = billing.BuildPricingBlob(input) + if err != nil { + return nil, err + } + } + blob.TimeSegments = segs + blob.ParamMultipliers = paramRules + empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil && + blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil && + len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0 + if empty { + return nil, nil + } + return blob, nil +} + +// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态) +func perSecondMatrixHasPositivePrice(prices map[string]float64) bool { + for _, p := range prices { + if p > 0 { + return true + } + } + return false +} + +// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。 +// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。 +func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) { + if raw == "" || raw == "null" { + return "", nil + } + wrapped := officialPricingBlob{} + if err := json.Unmarshal([]byte(raw), &wrapped); err != nil { + return "", nil + } + mode := wrapped.BillingMode + if mode == "" { + mode = "token" + } + return mode, &wrapped.PricingBlob +} + +// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换, +// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层, +// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦 +// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。 +func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error { + blob, err := buildOfficialBlob(items, segments, rules) + if err != nil { + return err + } + data := any(gdb.Raw("NULL")) + if blob != nil { + mode := "token" + if len(items) > 0 { + mode = items[0].BillingMode + } + raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob}) + if mErr != nil { + return mErr + } + data = string(raw) + } + _, err = dao.MdlPricing.Ctx(ctx). + Where("model_id", modelDBID). + Data(g.Map{"official_pricing": data}). + Update() + return err +} + +// 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 +883,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 +907,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,22 +919,46 @@ 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 := timeSegmentsFromAPI(req.TimeSegments); err != nil { + return nil, err + } + + // 参数倍率规则校验(路径/匹配方式/值形态/倍率区间),fail-fast + paramRules, err := paramMultipliersFromAPI(req.ParamMultipliers) if 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 - } + // 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价) + if req.OfficialItems != nil { + if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") } - if !hasAnchor { - return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行") + if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") + } + } + + // 计费方案校验:未注册的方案名不得写入(运行期引擎无从得知正确价格); + // scheme_config 由方案实现自校验(generic 不接受非空配置,拦截「空方案带配置」的错位数据) + req.Scheme = strings.TrimSpace(req.Scheme) + if req.Scheme != "" && !billing.SchemeRegistered(req.Scheme) { + return nil, gerror.Newf("计费方案 %q 未注册", req.Scheme) + } + if err := billing.LookupScheme(req.Scheme).ValidateSchemeConfig(req.SchemeConfig); err != nil { + return nil, err + } + + // billing_mode 与 scheme 双向配对(special ⇔ 方案声明):裸 special 会落 generic + // 分发走 default 分支(错价口径不可预期),方案挂非 special 模式会造成展示标签 + // 与计费口径错位(如显示「按秒」实为素材组合计费),两者都在保存时拦截 + if len(req.Items) > 0 { + isSpecial := req.Items[0].BillingMode == billing.BillingModeSpecial + if isSpecial && req.Scheme == "" { + return nil, gerror.New("特殊计费模式(special)必须声明计费方案(scheme)") + } + if !isSpecial && req.Scheme != "" { + return nil, gerror.New("声明计费方案的模型必须使用特殊计费模式(billing_mode=special)") } } @@ -635,42 +972,28 @@ 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, req.Scheme, req.SchemeConfig); 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 + // 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。 + // 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据, + // 不触发价格缓存失效 + if req.OfficialItems != nil { + if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil { + return gerror.Wrap(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..452bcdb 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -6,13 +6,13 @@ import ( "encoding/json" "io" "net/url" + "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "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" @@ -29,6 +29,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe ModelId string `orm:"model_id" json:"model_id"` ModelName string `orm:"model_name" json:"model_name"` Category string `orm:"category" json:"category"` + Vendor string `orm:"vendor" json:"vendor"` Status string `orm:"status" json:"status"` MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"` MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"` @@ -50,30 +51,37 @@ 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"` + Vendor string `json:"vendor,omitempty"` // 研发厂商(空=未分类,旧文件导入兼容) + 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"` + // 特殊计费方案声明(pricing JSONB 顶层 scheme/scheme_config,模型级语义): + // 随导出携带,导入侧透传还原,避免跨环境迁移后方案定价退化为通用模式 + Scheme string `json:"scheme,omitempty"` + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` } // 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。 @@ -82,16 +90,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 +100,58 @@ 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)) + schemeByModel := make(map[int64]string, len(allPricingRows)) + schemeConfigByModel := make(map[int64]json.RawMessage, 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) + } + if blob.Scheme != "" { + schemeByModel[p.ModelId] = blob.Scheme + schemeConfigByModel[p.ModelId] = blob.SchemeConfig } } @@ -127,6 +161,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, Status: m.Status, MaxContextTokens: m.MaxContextTokens, MaxOutputTokens: m.MaxOutputTokens, @@ -135,6 +170,9 @@ 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], + Scheme: schemeByModel[m.ID], + SchemeConfig: schemeConfigByModel[m.ID], } if em.Pricing == nil { em.Pricing = []exportPricing{} @@ -237,6 +275,23 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. res := &v1.ModelImportRes{} err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { for _, item := range req.Models { + // 计费方案配对校验(与 SetModelPricing 同口径):special ⇔ scheme 声明, + // 且方案必须已注册——导入文件引用本环境不存在的方案时运行期会 fail-closed + // 拒绝该模型请求,应在导入时报错而非留隐患 + scheme := strings.TrimSpace(item.Scheme) + if len(item.Pricing) > 0 { + isSpecial := item.Pricing[0].BillingMode == billing.BillingModeSpecial + if isSpecial && scheme == "" { + return gerror.Newf("模型 %s:特殊计费模式(special)缺少计费方案声明", item.ModelId) + } + if !isSpecial && scheme != "" { + return gerror.Newf("模型 %s:计费方案模型必须使用特殊计费模式(billing_mode=special)", item.ModelId) + } + } + if scheme != "" && !billing.SchemeRegistered(scheme) { + return gerror.Newf("模型 %s:计费方案 %q 未注册", item.ModelId, scheme) + } + var existing *struct { ID int64 `orm:"id"` } @@ -258,6 +313,10 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. MaxOutputTokens: item.MaxOutputTokens, Description: item.Description, } + // 旧导出文件无 vendor 字段(空串),不覆盖已有厂商配置 + if item.Vendor != "" { + updateData.Vendor = item.Vendor + } if item.Tags != nil { updateData.Tags = item.Tags } @@ -280,32 +339,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, scheme, item.SchemeConfig); err != nil { + return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } // 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除) @@ -322,6 +361,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. ModelId: item.ModelId, ModelName: item.ModelName, Category: item.Category, + Vendor: item.Vendor, Status: "active", MaxContextTokens: item.MaxContextTokens, MaxOutputTokens: item.MaxOutputTokens, @@ -342,28 +382,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, scheme, item.SchemeConfig); err != nil { + return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } // 时段定价透传(挂锚点行) diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 4c4f408..2c84896 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strconv" "strings" + "unicode/utf8" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" @@ -22,6 +23,64 @@ import ( "github.com/qianfree/team-api/internal/utility/export" ) +// 租户标签约束:数量与单项长度上限(字符数,非字节数) +const ( + tenantTagsMaxCount = 10 + tenantTagsMaxRunes = 30 + tenantRemarkMaxRunes = 1000 +) + +// marshalTagFilter 把单个标签序列化为 JSONB 包含查询参数(tags @> '["vip"]')。 +// 序列化不会失败(输入为字符串),错误时返回空数组条件(匹配不到任何行,安全降级)。 +func marshalTagFilter(tag string) string { + b, err := json.Marshal([]string{strings.TrimSpace(tag)}) + if err != nil { + return "[]" + } + return string(b) +} + +// parseTenantTags 解析租户标签 JSONB(空/非法输入返回空切片,不报错——展示路径容错) +func parseTenantTags(raw string) []string { + if raw == "" { + return []string{} + } + var tags []string + if err := json.Unmarshal([]byte(raw), &tags); err != nil { + return []string{} + } + return tags +} + +// marshalTenantTags 校验并序列化租户标签为 JSONB 字符串: +// 去首尾空白、跳过空项、去重,≤ tenantTagsMaxCount 个、每项 ≤ tenantTagsMaxRunes 字符。 +func marshalTenantTags(tags []string) (string, error) { + cleaned := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "" { + continue + } + if utf8.RuneCountInString(tag) > tenantTagsMaxRunes { + return "", common.NewBadRequestError("单个标签最长 30 字符") + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + cleaned = append(cleaned, tag) + } + if len(cleaned) > tenantTagsMaxCount { + return "", common.NewBadRequestError("标签数量最多 10 个") + } + b, err := json.Marshal(cleaned) + if err != nil { + return "", err + } + return string(b), nil +} + // TenantSelect returns a lightweight paginated tenant list for dropdown selectors. func (s *sAdmin) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) { page, pageSize := common.NormalizePagination(req.Page, req.PageSize) @@ -217,10 +276,12 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) ( ownerNames map[int64]string, memberCounts map[int64]int, walletBalances map[int64]string, + walletTotalConsumed map[int64]string, ) { ownerNames = make(map[int64]string, len(ownerIDs)) memberCounts = make(map[int64]int, len(tenantIDs)) walletBalances = make(map[int64]string, len(tenantIDs)) + walletTotalConsumed = make(map[int64]string, len(tenantIDs)) // owner 名称:按 owner_user_id 批量取 if len(ownerIDs) > 0 { @@ -246,15 +307,17 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) ( memberCounts[c.TenantID] = c.Count } - // 钱包余额:按 tenant_id 批量取 + // 钱包余额 / 累计消费:按 tenant_id 批量取(DB 物化副本,累计消费随物化器 ≤5s 滞后) var wallets []struct { - TenantID int64 `json:"tenant_id"` - Balance string `json:"balance"` + TenantID int64 `json:"tenant_id"` + Balance string `json:"balance"` + TotalConsumed string `json:"total_consumed"` } - _ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance"). + _ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance, total_consumed"). WhereIn("tenant_id", tenantIDs).Scan(&wallets) for _, w := range wallets { walletBalances[w.TenantID] = w.Balance + walletTotalConsumed[w.TenantID] = w.TotalConsumed } } @@ -274,6 +337,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } total, err := m.Count() if err != nil { @@ -289,6 +358,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } // 扫描结构体必须带 orm 标签:gf 的 Scan 会用「结构体字段名」自动生成 SELECT 字段列表, // 无 orm 标签时靠运行时查表结构(TableFields)做 Id→id 映射;该元数据查询失败会被框架静默吞掉, @@ -305,6 +380,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope"` Settings string `json:"settings" orm:"settings"` Level int `json:"level" orm:"level"` + Tags string `json:"tags" orm:"tags"` + Remark string `json:"remark" orm:"remark"` CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"` } @@ -336,7 +413,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te tenantIDs[i] = t.Id ownerIDs[i] = t.OwnerUserID } - ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) + ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) for i, t := range tenants { item := v1.TenantItem{ @@ -351,6 +428,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te DefaultChannelScope: t.DefaultChannelScope, Level: t.Level, LevelName: levelNameMap[t.Level], + Tags: parseTenantTags(t.Tags), + Remark: t.Remark, CreatedAt: t.CreatedAt.String(), UpdatedAt: t.UpdatedAt.String(), } @@ -371,7 +450,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te item.EffectiveMaxConcurrency = 0 } - // 从批量结果中取 owner 名称 / 成员数 / 钱包余额 + // 从批量结果中取 owner 名称 / 成员数 / 钱包余额 / 累计消费 item.OwnerName = ownerNameMap[t.OwnerUserID] item.MemberCount = memberCountMap[t.Id] if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" { @@ -379,6 +458,11 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te } else { item.WalletBalance = "0" } + if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" { + item.TotalConsumed = tc + } else { + item.TotalConsumed = "0" + } items[i] = item } @@ -405,6 +489,8 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan DefaultChannelScope string `json:"default_channel_scope"` Settings string `json:"settings"` Level int `json:"level"` + Tags string `json:"tags"` + Remark string `json:"remark"` CreatedAt *gtime.Time `json:"created_at"` UpdatedAt *gtime.Time `json:"updated_at"` } @@ -428,19 +514,27 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan memberCount, _ := dao.TntUsers.Ctx(ctx). Where("tenant_id", req.Id).Count() - // Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底 + // Get wallet balance / 累计消费:优先读 Redis 权威值(调整余额/入账/结算后详情页立即可见),DB 物化值兜底 walletBalance := "0" + totalConsumed := "0" if w, err := billing.GetWallet(ctx, req.Id); err == nil { walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64) + totalConsumed = strconv.FormatFloat(w.TotalConsumed, 'f', -1, 64) } else { var wallet *struct { - Balance string `json:"balance"` + Balance string `json:"balance"` + TotalConsumed string `json:"total_consumed"` } _ = dao.BilWallets.Ctx(ctx). Where("tenant_id", req.Id).Scan(&wallet) - if wallet != nil && wallet.Balance != "" { - walletBalance = wallet.Balance + if wallet != nil { + if wallet.Balance != "" { + walletBalance = wallet.Balance + } + if wallet.TotalConsumed != "" { + totalConsumed = wallet.TotalConsumed + } } } ownerName := "" @@ -486,6 +580,9 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan DefaultChannelScope: tenant.DefaultChannelScope, MemberCount: memberCount, WalletBalance: walletBalance, + Tags: parseTenantTags(tenant.Tags), + Remark: tenant.Remark, + TotalConsumed: totalConsumed, Level: tenant.Level, LevelName: levelName, CreatedAt: tenant.CreatedAt.String(), @@ -556,6 +653,22 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1 data.Level = *req.Level } + // 标签:nil 表示未传(不更新);空数组表示清空(序列化为 []) + if req.Tags != nil { + tagsJSON, err := marshalTenantTags(req.Tags) + if err != nil { + return nil, err + } + data.Tags = tagsJSON + } + if req.Remark != nil { + remark := strings.TrimSpace(*req.Remark) + if utf8.RuneCountInString(remark) > tenantRemarkMaxRunes { + return nil, common.NewBadRequestError("备注最长 1000 字符") + } + data.Remark = remark + } + _, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(data) if err != nil { return nil, err @@ -593,8 +706,11 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v {Field: "code", Header: "代码"}, {Field: "owner_name", Header: "所有者"}, {Field: "status", Header: "状态"}, + {Field: "tags", Header: "标签"}, + {Field: "remark", Header: "备注"}, {Field: "member_count", Header: "成员数"}, {Field: "wallet_balance", Header: "钱包余额"}, + {Field: "total_consumed", Header: "累计消费"}, {Field: "created_at", Header: "创建时间"}, } @@ -615,40 +731,55 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v if req.Status != "" { m = m.Where("status", req.Status) } + if req.Tag != "" { + m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag)) + } + if req.Level != nil { + m = m.Where("level", *req.Level) + } var batch []struct { Id int64 `json:"id"` Name string `json:"name"` Code string `json:"code"` OwnerUserID int64 `json:"owner_user_id"` Status string `json:"status"` + Tags string `json:"tags"` + Remark string `json:"remark"` CreatedAt *gtime.Time `json:"created_at"` } - if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil { + if err := m.Fields("id, name, code, owner_user_id, status, tags, remark, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil { return } - // 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1) + // 批量查询本页的 owner 名称 / 成员数 / 钱包余额 / 累计消费(避免逐行 N+1) tenantIDs := make([]int64, len(batch)) ownerIDs := make([]int64, len(batch)) for i, t := range batch { tenantIDs[i] = t.Id ownerIDs[i] = t.OwnerUserID } - ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) + ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs) for _, t := range batch { walletBalance := "0" if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" { walletBalance = bal } + totalConsumed := "0" + if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" { + totalConsumed = tc + } row := map[string]any{ "id": t.Id, "name": t.Name, "code": t.Code, "owner_name": ownerNameMap[t.OwnerUserID], "status": t.Status, + "tags": strings.Join(parseTenantTags(t.Tags), ","), + "remark": t.Remark, "member_count": memberCountMap[t.Id], "wallet_balance": walletBalance, + "total_consumed": totalConsumed, "created_at": t.CreatedAt.String(), } if !yield(row) { diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index 26bc783..c794913 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" v1 "github.com/qianfree/team-api/api/admin/v1" "github.com/qianfree/team-api/internal/dao" @@ -190,6 +191,20 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat data.Enabled = *req.Enabled } if req.BillingMode != nil { + // 特殊计费方案模型不允许租户级 billing_mode 覆盖:计费模式由平台方案决定(special), + // 租户改模式只会造成日志标签与计费口径错位(读取侧 GetModelPriceAt 同口径忽略覆盖)。 + // 置空(恢复平台默认)不受限 + if *req.BillingMode != "" { + var pricingRow struct { + Pricing string `json:"pricing"` + } + if err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Fields("pricing").Scan(&pricingRow); err != nil { + return nil, gerror.Wrap(err, "查询模型定价") + } + if blob := billing.ParsePricingBlob(pricingRow.Pricing); blob != nil && blob.Scheme != "" { + return nil, gerror.New("该模型使用特殊计费方案,计费模式由平台方案决定,不允许租户级覆盖") + } + } data.BillingMode = *req.BillingMode } if req.PerRequestPrice != nil { @@ -269,7 +284,7 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet // ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重) func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) { - models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "") + models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "", "") if err != nil { return nil, err } diff --git a/internal/logic/admin/tenant_tags_test.go b/internal/logic/admin/tenant_tags_test.go new file mode 100644 index 0000000..2c5184b --- /dev/null +++ b/internal/logic/admin/tenant_tags_test.go @@ -0,0 +1,66 @@ +package admin + +import ( + "testing" +) + +// TestMarshalTenantTags 标签校验与序列化:去空白、去重、跳过空项、数量与长度上限 +func TestMarshalTenantTags(t *testing.T) { + cases := []struct { + name string + input []string + want string + wantErr bool + }{ + {"普通标签", []string{"vip", "重点客户"}, `["vip","重点客户"]`, false}, + {"去首尾空白", []string{" vip ", "dev"}, `["vip","dev"]`, false}, + {"跳过空项", []string{"", " ", "vip"}, `["vip"]`, false}, + {"去重", []string{"vip", "vip", "dev"}, `["vip","dev"]`, false}, + {"空数组清空", []string{}, `[]`, false}, + {"nil 清空", nil, `[]`, false}, + {"超长单标签", []string{"一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一"}, "", true}, + {"数量超限", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, "", true}, + {"英文30字符边界", []string{"abcdefghijklmnopqrstuvwxyz0123"}, `["abcdefghijklmnopqrstuvwxyz0123"]`, false}, + {"英文31字符超限", []string{"abcdefghijklmnopqrstuvwxyz0123" + "4"}, "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := marshalTenantTags(c.input) + if c.wantErr { + if err == nil { + t.Fatalf("want error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.want { + t.Fatalf("got=%s want=%s", got, c.want) + } + }) + } +} + +// TestParseTenantTags 标签解析容错:空/非法输入返回空切片而非报错(展示路径) +func TestParseTenantTags(t *testing.T) { + if got := parseTenantTags(""); got == nil || len(got) != 0 { + t.Fatalf("empty input: want empty slice, got %v", got) + } + if got := parseTenantTags("not-json"); got == nil || len(got) != 0 { + t.Fatalf("invalid json: want empty slice, got %v", got) + } + if got := parseTenantTags(`["vip","dev"]`); len(got) != 2 || got[0] != "vip" || got[1] != "dev" { + t.Fatalf("valid json: got %v", got) + } +} + +// TestMarshalTagFilter 标签筛选参数序列化为 JSONB 包含查询条件 +func TestMarshalTagFilter(t *testing.T) { + if got := marshalTagFilter("vip"); got != `["vip"]` { + t.Fatalf("got=%s want=[\"vip\"]", got) + } + if got := marshalTagFilter(" vip "); got != `["vip"]` { + t.Fatalf("trimmed: got=%s", got) + } +} 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..6bacf06 --- /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, nil), 18.0, "param multiplier cost") + + // 未命中的任务体不注入(保持 0.5 × 5 缺省 = 2.5) + ratios2 := map[string]any{} + assertDecimal(t, estimateTaskCost(pricing, ratios2, nil), 2.5, "no param match cost") +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 5130b0e..2be9c91 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -57,15 +57,71 @@ 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"` + + // 计费方案(pricing JSONB 顶层 scheme):空 = generic 通用引擎; + // 特殊方案的估算分发见 estimateTaskCost / LookupScheme。 + // 旧缓存条目无此字段(空串),不受影响 + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器,方案自行解析)。旧缓存条目无此字段(nil) + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` +} + +// perSecondWildcard 矩阵兜底价键:规格未命中时使用 +const perSecondWildcard = "*" + +// BillingModeSpecial 特殊计费模式(与 per_second 平级):仅由特殊计费方案使用, +// pricing JSONB 顶层必须同时声明 scheme 键(写侧双向配对校验)。计费分发只看 +// Scheme 不看此值,它用于展示层区分「按秒计费」与「特殊方案组合计费」, +// 不出现在通用计费模式下拉中(只由方案专属编辑器提交)。 +const BillingModeSpecial = "special" + +// 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 +194,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 +216,48 @@ 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 + var schemeName string + var schemeConfig json.RawMessage 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 + schemeName = blob.Scheme + schemeConfig = blob.SchemeConfig + } } - cacheReadPrice = pricing.CacheReadPrice - cacheCreationPrice = pricing.CacheCreationPrice } // 3. 查租户独立价格(mdl_tenant_models) @@ -232,7 +291,9 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill if tm != nil && tm.Enabled { billingSource = "tenant_custom" - if tm.BillingMode != nil && *tm.BillingMode != "" { + // 特殊计费方案模型的 billing_mode 由平台方案决定(special),租户级模式覆盖无效: + // 方案的输出生成组件消费平台 per_second 矩阵,租户改模式只会造成日志标签与计费口径错位 + if schemeName == "" && tm.BillingMode != nil && *tm.BillingMode != "" { billingMode = *tm.BillingMode } @@ -272,21 +333,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 +382,10 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill MaxOutputTokens: model.MaxOutputTokens, CustomTiers: customTiers, TimeSegments: timeSegments, + PerSecondPrices: perSecondPrices, + ParamMultipliers: paramMultipliers, + Scheme: schemeName, + SchemeConfig: schemeConfig, } // 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考) @@ -588,6 +644,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 BillingModeSpecial, "per_second": + if LookupPerSecondPrice(pricing.PerSecondPrices, perSecondWildcard) <= 0 { + return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (%s prices not set)", modelName, pricing.BillingMode) + } default: // token / tiered if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && !tiersHavePrice(pricing.CustomTiers) { return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s", modelName) @@ -596,6 +656,144 @@ 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 BillingModeSpecial, "per_second": + if len(items) > 1 { + return nil, gerror.Newf("%s 计费模式只允许一行定价(分辨率矩阵)", mode) + } + if anchor.MinTokens != 0 { + return nil, gerror.Newf("%s 计费模式要求 min_tokens=0 的定价行", mode) + } + if len(anchor.PerSecondPrices) == 0 { + return nil, gerror.Newf("%s 计费模式要求配置分辨率单价矩阵(per_second_prices)", mode) + } + 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, + CacheReadPrice: positivePtr(item.CacheReadPrice), + CacheCreationPrice: positivePtr(item.CacheCreationPrice), + }) + } + 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"` + // 计费方案:特殊计费模型声明引用的方案名(空 = generic 通用引擎), + // 前端按此字段分发定价编辑器。导出格式不含该字段(特殊方案定价不随导入导出迁移) + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器):schema 由方案实现自定义并自行解析校验, + // 引擎与通用编辑器不理解其内容,防止主干结构随方案膨胀 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` +} + // tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。 // 平台阶梯兜底后 CustomTiers 可能恒非空,全 0 价阶梯不得视为「已配置定价」放行 func tiersHavePrice(tiers []pricingTierRow) bool { @@ -607,12 +805,16 @@ func tiersHavePrice(tiers []pricingTierRow) bool { return false } -// pricingTierRow 定价阶梯行(绝对价格) +// pricingTierRow 定价阶梯行(绝对价格)。 +// 逐档缓存价为可选键(omitempty,旧数据缺键 unmarshal 为 nil,无需迁移): +// 编辑器逐档缓存价落库于此;计费引擎的缓存 token 计费仍取 blob 顶层锚点缓存价,不读档内字段 type pricingTierRow struct { - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` + MinTokens int64 `json:"min_tokens"` + MaxTokens *int64 `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` + CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"` } // effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。 diff --git a/internal/logic/billing/pricing_blob_test.go b/internal/logic/billing/pricing_blob_test.go new file mode 100644 index 0000000..8bce9b4 --- /dev/null +++ b/internal/logic/billing/pricing_blob_test.go @@ -0,0 +1,190 @@ +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_Special 特殊计费模式:与 per_second 同构的矩阵校验 +// (special 是方案模型的 billing_mode 标识,定价形态仍是按秒矩阵) +func TestBuildPricingBlob_Special(t *testing.T) { + // 正向:矩阵照常构造 + blob, err := BuildPricingBlob([]PricingItemInput{{ + BillingMode: BillingModeSpecial, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6, "*": 0.35}, + }}) + if err != nil { + t.Fatalf("build: %v", err) + } + if blob.Unit != "second" || blob.Prices["2K"] != 0.6 { + t.Fatalf("unexpected blob: %+v", blob) + } + + // 多行拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"*": 0.5}}, + {BillingMode: BillingModeSpecial, MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}}, + }); err == nil { + t.Fatal("expected multi-row rejection") + } + // min_tokens ≠ 0 拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, MinTokens: 100, PerSecondPrices: map[string]float64{"*": 0.5}}, + }); err == nil { + t.Fatal("expected min_tokens rejection") + } + // 空矩阵拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{{BillingMode: BillingModeSpecial}}); err == nil { + t.Fatal("expected empty matrix rejection") + } + // 全零/负价拒绝 + if _, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"768P": -0.1}}, + }); err == nil { + t.Fatal("expected non-positive matrix 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) + } +} + +// TestBuildPricingBlob_TieredPerTierCachePrices 阶梯逐档缓存价:档内键随行落库, +// 顶层锚点缓存价照旧写入(计费引擎的缓存 token 计费读顶层,不读档内字段)。 +// JSONB 往返后两处都完整保留;旧格式(档内无缓存键)unmarshal 为 nil 不报错。 +func TestBuildPricingBlob_TieredPerTierCachePrices(t *testing.T) { + max := int64(100000) + blob, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5, CacheCreationPrice: 18.75}, + {BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60, CacheReadPrice: 1.2, CacheCreationPrice: 15}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + // 顶层锚点缓存价保留(计费引擎读取口径) + if blob.CacheReadPrice == nil || *blob.CacheReadPrice != 1.5 || blob.CacheCreationPrice == nil || *blob.CacheCreationPrice != 18.75 { + t.Fatalf("anchor cache prices lost: %+v", blob) + } + // 逐档缓存价落行 + if blob.Tiers[0].CacheReadPrice == nil || *blob.Tiers[0].CacheReadPrice != 1.5 { + t.Fatalf("tier0 cache_read_price lost: %+v", blob.Tiers[0]) + } + if blob.Tiers[1].CacheReadPrice == nil || *blob.Tiers[1].CacheReadPrice != 1.2 { + t.Fatalf("tier1 cache_read_price lost: %+v", blob.Tiers[1]) + } + + // JSONB 往返(mdl_pricing.pricing 的真实存取路径) + raw, err := json.Marshal(blob) + if err != nil { + t.Fatalf("marshal: %v", err) + } + parsed := ParsePricingBlob(string(raw)) + if parsed == nil { + t.Fatal("parse failed") + } + if parsed.CacheReadPrice == nil || *parsed.CacheReadPrice != 1.5 { + t.Fatalf("roundtrip anchor cache lost: %+v", parsed) + } + if parsed.Tiers[1].CacheCreationPrice == nil || *parsed.Tiers[1].CacheCreationPrice != 15 { + t.Fatalf("roundtrip tier1 cache_creation lost: %+v", parsed.Tiers[1]) + } + + // 旧格式(档内无缓存键、顶层有锚点):unmarshal 不报错,档内为 nil + legacy := ParsePricingBlob(`{"tiers":[{"min_tokens":0,"input_price":15,"output_price":75}],"cache_read_price":1.5}`) + if legacy == nil || legacy.Tiers[0].CacheReadPrice != nil || legacy.CacheReadPrice == nil || *legacy.CacheReadPrice != 1.5 { + t.Fatalf("legacy blob parse broken: %+v", legacy) + } +} 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..efc65a1 --- /dev/null +++ b/internal/logic/billing/pricing_migration_verify_test.go @@ -0,0 +1,79 @@ +//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 迁移(000021)后完整性校验: +// 每模型一行、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", BillingModeSpecial: + if len(blob.Prices) == 0 { + t.Errorf("model_id=%v %s 模式 prices 矩阵为空", row["model_id"], row["billing_mode"].String()) + bad++ + } + // special 必须与方案配对(pricing JSONB 顶层 scheme 键),裸 special 会在 + // 计费入口 fail-closed 拒绝请求 + if row["billing_mode"].String() == BillingModeSpecial && blob.Scheme == "" { + t.Errorf("model_id=%v special 模式缺少 scheme 声明", row["model_id"]) + bad++ + } + } + } + if bad > 0 { + t.Fatalf("%d 行 pricing JSON 内容异常", bad) + } +} diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index 71b90e8..fad89e1 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -326,6 +326,17 @@ func TestValidatePricingConfigured(t *testing.T) { pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, OutputPrice: 10.0}}}, wantErr: false, }, + { + // special 与 per_second 同走矩阵校验:token 价全 0 不构成「已配置」 + name: "special 空矩阵拒绝(防 fail-closed 误放行)", + pricing: &PricingResult{BillingMode: BillingModeSpecial}, + wantErr: true, + }, + { + name: "special 有矩阵放行", + pricing: &PricingResult{BillingMode: BillingModeSpecial, PerSecondPrices: map[string]float64{"*": 0.35}}, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/logic/billing/reconciliation.go b/internal/logic/billing/reconciliation.go index 330fa28..b13d0e8 100644 --- a/internal/logic/billing/reconciliation.go +++ b/internal/logic/billing/reconciliation.go @@ -185,7 +185,7 @@ func reconcileFrozenBalance(ctx context.Context) { // checkOneWalletFrozenDrift 比对单个租户的 Redis/DB 冻结值,超窗漂移告警 func checkOneWalletFrozenDrift(ctx context.Context, tenantID int64, materializeLag time.Duration) { - _, frozenMicro, exists, err := readWalletHash(ctx, tenantID) + _, frozenMicro, _, _, exists, err := readWalletHash(ctx, tenantID) if err != nil || !exists { return } diff --git a/internal/logic/billing/scheme.go b/internal/logic/billing/scheme.go new file mode 100644 index 0000000..5057bd2 --- /dev/null +++ b/internal/logic/billing/scheme.go @@ -0,0 +1,209 @@ +package billing + +import ( + "encoding/json" + "sync" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// ============================================================ +// 计费方案(BillingScheme)扩展点 +// ============================================================ +// 背景:部分模型的计费规则无法归约为「单价 × 数量」的结构化配置(条件切价、 +// 组合计费、依赖上游私有字段等)。为此把「任务预扣估算」的定价解释权抽象为 +// 可注册的方案接口:模型在 pricing JSONB 顶层 "scheme" 键声明引用,未声明走 +// generic(现有通用引擎),计费行为与抽象前完全一致。 +// +// 设计约束(新增方案必须遵守,否则账目口径漂移): +// 1. 方案只负责组件费用计算:EstimateTaskCost 返回已乘租户乘数 × 时段乘数的 +// 金额;ratios 附加乘数(param_multiplier 等)、minCost 钳制与 RoundMoney +// 由通用层统一应用——全站结算口径、快照与对账不因方案而分叉; +// 2. 方案私有配置存 pricing JSONB 顶层 "scheme_config"(不透明容器),由方案 +// 自行解析校验,不得向 PricingBlob 增加方案私有字段(主干文件不随方案膨胀, +// 二开新增方案不改任何既有文件); +// 3. 优先消费归一化计费上下文(ratios,adaptor 经 EstimateBilling 上报); +// taskBody 为归一化任务体,仅确实需要原始请求体的方案使用(nil = 不可用); +// 4. 自定义方案建议 "custom:" 前缀命名,避免与内置方案撞名。 +// +// 注册模式与 relay/channel 的供应商适配器一致:方案实现放独立文件/目录, +// init() 中 RegisterScheme 自注册,新增方案不改本文件与 task_billing.go。 + +// SchemeGeneric 通用计费方案名(pricing JSONB 未声明 scheme 时的默认值) +const SchemeGeneric = "generic" + +// BillingScheme 计费方案接口:特殊计费模型的任务预扣估算与结算重算扩展点。 +// 接口签名视为公共契约(主干与二开方案的边界),保持稳定;修改需评估所有已注册方案。 +type BillingScheme interface { + // Name 方案名(唯一,与 pricing JSONB scheme 键对应) + Name() string + + // EstimateTaskCost 任务预扣估算(纯函数,不触 DB/缓存,便于单测)。 + // 返回已含租户乘数 × 时段乘数、未应用 ratios 附加乘数的费用; + // 附加乘数、minCost 钳制与 RoundMoney 由 estimateTaskCost 骨架统一应用。 + EstimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal + + // SettleTaskCost 任务成功结算口径(纯函数):按上游 usage 的素材计量计算最终费用。 + // 素材计量是「预扣时未知、生成后官方返回」的计费依据(如输入视频时长、图片张数); + // usage 为 nil 表示上游未提供,方案应回退 EstimateTaskCost 的预扣口径(多退少补兜底)。 + // 返回值口径与 EstimateTaskCost 一致(含租户/时段乘数,不含 ratios 附加乘数)。 + SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal + + // ValidateSchemeConfig 校验方案私有配置(pricing JSONB scheme_config), + // 管理端保存定价时 fail-fast 调用;generic 方案不接受非空配置。 + ValidateSchemeConfig(cfg json.RawMessage) error +} + +// schemeRegistry 方案注册表(init 自注册,同 relay/channel 适配器模式) +var schemeRegistry sync.Map // name → BillingScheme + +// RegisterScheme 注册计费方案(方案实现的 init 中调用)。 +// 空名/重名直接 panic:注册发生在进程启动期,快死优于带病运行。 +func RegisterScheme(s BillingScheme) { + if s == nil || s.Name() == "" { + panic("billing: register nil or empty-name scheme") + } + if _, loaded := schemeRegistry.LoadOrStore(s.Name(), s); loaded { + panic("billing: duplicate scheme registration: " + s.Name()) + } +} + +// LookupScheme 按方案名查注册表:空名/未注册一律回落 generic。 +// 未注册兜底是纯函数层的防御(写路径已由 SchemeRegistered 拦截、预扣入口 +// fail-closed 拒绝,正常流量到不了这里),保证估算函数不因配置异常而 panic。 +func LookupScheme(name string) BillingScheme { + if name != "" { + if s, ok := schemeRegistry.Load(name); ok { + return s.(BillingScheme) + } + } + if s, ok := schemeRegistry.Load(SchemeGeneric); ok { + return s.(BillingScheme) + } + return GenericScheme{} +} + +// SchemeRegistered 方案是否已注册。管理端保存定价前校验:未注册的方案名 +// 不得写入配置(防止配置引用不存在的实现,运行期才暴露成计费异常)。 +func SchemeRegistered(name string) bool { + _, ok := schemeRegistry.Load(name) + return ok +} + +func init() { + RegisterScheme(GenericScheme{}) +} + +// GenericScheme 通用计费方案:token / per_request / per_second / tiered 的 +// 现有通用引擎(自 estimateTaskCost 分支逻辑原样搬迁,行为不变)。 +type GenericScheme struct{} + +func (GenericScheme) Name() string { return SchemeGeneric } + +func (GenericScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + if len(cfg) > 0 && string(cfg) != "null" { + return gerror.New("通用计费方案不接受 scheme_config(仅特殊方案支持私有配置)") + } + return nil +} + +// SettleTaskCost 通用引擎无素材计量语义:忽略 usage,回退预扣口径 +// (与 estimateTaskCost 的估算公式一致,结算保持「预扣即终价,token 重算另走 RecalculateByTokens」)。 +func (g GenericScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, _ *rcommon.TaskMaterialUsage) decimal.Decimal { + return g.EstimateTaskCost(pricing, ratios, nil) +} + +// wrapSchemeCost 方案费用统一收口:应用 ratios 附加乘数 → minCost 钳制 → RoundMoney。 +// 预扣估算(estimateTaskCost)与结算重算(RecalculateByMaterials)共用, +// 保证两个时点对同一方案的费用处理口径完全一致。 +func wrapSchemeCost(pricing *PricingResult, ratios map[string]any, cost decimal.Decimal) decimal.Decimal { + // 应用附加比率(video_input 折扣等):只乘 float 乘数值, + // 跳过 duration/resolution(已在时长类分支消费)与 spec.*(规格事实值非乘数) + cost = applyRatioMultipliers(cost, ratios, "duration", "resolution") + + minCost := NewFromFloat(0.01) + if cost.LessThan(minCost) { + cost = minCost + } + return RoundMoney(cost) +} + +// EstimateTaskCost 通用引擎的任务预扣估算。计费口径按任务类型分流: +// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")× +// spec.duration 秒 × 租户乘数 × 时段乘数; +// - per_request(按次计费,图片/音乐等):直接取按次单价; +// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 +// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径); +// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价, +// 不再套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出 +// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。 +func (GenericScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { + 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 { + // 矩阵全零/为空:按未配价占位预扣,结算多退少补 + return NewFromFloat(imagePlaceholderPreDeduct) + } + costD = NewFromFloat(price). + Mul(NewFromFloat(duration)). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.BillingMode == "per_request": + // 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的 + // 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价, + // 预扣漏乘租户/时段折扣会让折扣租户按原价多扣 + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.OutputPrice > 0 && hasDurationSignal(ratios): + duration := 5.0 // 默认 5 秒 + if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 { + duration = d + } + resolutionMul := 2.25 // 默认 720p + if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 { + resolutionMul = r + } + + // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier + // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 + // 视频预扣三连乘全程 decimal 避免链式误差 + baseTokensPerSec := decimal.NewFromInt(10000) + durationD := NewFromFloat(duration) + resolutionMulD := NewFromFloat(resolutionMul) + million := decimal.NewFromInt(1_000_000) + + costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). + Div(million). + Mul(NewFromFloat(pricing.OutputPrice)). + Mul(NewFromFloat(pricing.TenantMultiplier)) + default: + // 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数, + // 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣, + // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 + // (见 sync_image_worker.settleSyncImageSuccess)。 + if pricing.PerRequestPrice > 0 { + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + } else { + costD = NewFromFloat(imagePlaceholderPreDeduct) + } + } + return costD +} diff --git a/internal/logic/billing/scheme_minimax_material.go b/internal/logic/billing/scheme_minimax_material.go new file mode 100644 index 0000000..dcbd3c5 --- /dev/null +++ b/internal/logic/billing/scheme_minimax_material.go @@ -0,0 +1,235 @@ +package billing + +import ( + "encoding/json" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// ============================================================ +// MiniMax-H3 素材计费方案(custom:minimax-material) +// ============================================================ +// 适用:MiniMax-H3 / H3-Max 等按「输入素材 + 输出生成」组合计费的视频模型。 +// 计费公式(金额为本位币,统一乘 租户乘数 × 时段乘数): +// +// 总费用 = 输出生成费 + 输入图片费 + 输入视频费(输入音频免费,不配置即不计费) +// 输出生成费 = 输出秒数 × pricing JSONB per_second 矩阵[生成分辨率] +// 输入图片费 = max(0, 图片张数 - free_count) × price_per_unit +// 输入视频费 = 输入视频秒数 × price_per_second(官方 usage 无输入视频分辨率,按秒单一单价) +// +// 预扣 vs 结算: +// - 图片张数提交时可知(adaptor 上报 ratios 的 spec.input_image_count)→ 预扣即精确; +// - 输入视频常以 URL 提交、时长未知 → 提交含输入视频(spec.has_input_video)时预扣按 +// 生成长度上限 15s 冻结,结算以官方 usage(input_seconds)为准多退少补; +// - 输出秒数预扣按请求时长信号估,结算以 usage.output_seconds 为准。 +// 结算时 usage 某字段缺失(上游未返回)则回退 ratios 中提交时刻的对应 spec.* 事实值, +// 官方完全不返回素材计量时整体回退预扣口径(多退少补兜底)。 +// +// scheme_config(pricing JSONB 不透明容器,金额本位币): +// +// { +// "image": {"free_count": 5, "price_per_unit": 0.20}, +// "input_video": {"price_per_second": 0.50} +// } + +// SchemeMiniMaxMaterial MiniMax 素材计费方案名(模型 pricing JSONB scheme 键引用) +const SchemeMiniMaxMaterial = "custom:minimax-material" + +// inputVideoMaxSeconds 输入视频计费时长上限(秒)。官方 usage 可信度高, +// 钳制仅防御异常大值刷扣;输出侧沿用 maxTaskDurationSeconds。 +const inputVideoMaxSeconds = 600.0 + +// inputVideoEstimateSeconds 输入视频预扣估算时长(秒):H3 生成本身上限 15s, +// 输入视频计费时长不会超过生成时长,提交含输入视频时按上限冻结, +// 避免 URL 素材时长未知导致预扣少冻结。 +const inputVideoEstimateSeconds = 15.0 + +// minimaxMaterialConfig 方案私有配置 +type minimaxMaterialConfig struct { + Image *minimaxImagePrice `json:"image,omitempty"` + InputVideo *minimaxInputVideoPrice `json:"input_video,omitempty"` +} + +// minimaxImagePrice 输入图片单价:请求级免费额度 + 超出部分单价 +type minimaxImagePrice struct { + FreeCount int `json:"free_count"` // 免费张数(0 = 无免费额度) + PricePerUnit float64 `json:"price_per_unit"` // 超出部分单价(本位币/张) +} + +// minimaxInputVideoPrice 输入视频按秒单一单价:官方 usage 只有 input_seconds +// (无输入视频分辨率信息),不做规格区分 +type minimaxInputVideoPrice struct { + PricePerSecond float64 `json:"price_per_second"` +} + +// minimaxMaterialScheme MiniMax 素材计费方案(无状态单例,配置从 pricing.SchemeConfig 读取) +type minimaxMaterialScheme struct{} + +func init() { + RegisterScheme(minimaxMaterialScheme{}) +} + +func (minimaxMaterialScheme) Name() string { return SchemeMiniMaxMaterial } + +// parseConfig 解析方案私有配置(解析失败按未配置处理:仅输出组件计费) +func (minimaxMaterialScheme) parseConfig(pricing *PricingResult) *minimaxMaterialConfig { + cfg := &minimaxMaterialConfig{} + if pricing == nil || len(pricing.SchemeConfig) == 0 { + return cfg + } + if err := json.Unmarshal(pricing.SchemeConfig, cfg); err != nil { + return &minimaxMaterialConfig{} + } + return cfg +} + +// ValidateSchemeConfig 校验 scheme_config(管理端保存 fail-fast): +// 单价非负、免费额度非负、输入视频矩阵至少一档正价且规格键非空、至少配置一个计费节。 +func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + if len(cfg) == 0 || string(cfg) == "null" { + return gerror.New("素材计费方案必须配置 scheme_config(图片单价 / 输入视频单价至少一项)") + } + var c minimaxMaterialConfig + if err := json.Unmarshal(cfg, &c); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + hasPricedSection := false + if c.Image != nil { + if c.Image.PricePerUnit < 0 { + return gerror.New("输入图片单价不能为负") + } + if c.Image.FreeCount < 0 { + return gerror.New("输入图片免费张数不能为负") + } + if c.Image.PricePerUnit > 0 { + hasPricedSection = true + } + } + if c.InputVideo != nil { + if c.InputVideo.PricePerSecond < 0 { + return gerror.New("输入视频每秒单价不能为负") + } + if c.InputVideo.PricePerSecond == 0 { + return gerror.New("输入视频计费必须配置正的每秒单价(音频免费无需配置,视频不免费请填正数)") + } + hasPricedSection = true + } + if !hasPricedSection { + return gerror.New("素材计费方案至少配置图片单价或输入视频单价之一") + } + return nil +} + +// EstimateTaskCost 预扣估算:输出组件 + 提交可知的素材组件(图片张数精确、输入视频按 0 估)。 +func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { + cfg := s.parseConfig(pricing) + spec, _ := ratioString(ratios, "spec.resolution") + total := decimal.Zero + + // 输出生成组件:per_second 矩阵(与通用按秒引擎共用查价与兜底链),矩阵未配置时为 0 + if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { + duration := defaultTaskDurationSeconds + if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { + duration = d + } + if duration > maxTaskDurationSeconds { + duration = maxTaskDurationSeconds + } + total = total.Add(NewFromFloat(price).Mul(NewFromFloat(duration))) + } + + // 输入图片:请求级免费额度扣减 + total = total.Add(s.imageCost(cfg, ratioFloatOr(ratios, "spec.input_image_count", 0))) + + // 输入视频:时长 × 按秒单价(提交含输入视频按生成长度上限 15s 冻结,结算以官方 usage 补扣) + total = total.Add(s.inputVideoCost(cfg, estimateInputVideoSeconds(ratios))) + + return total.Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) +} + +// SettleTaskCost 结算口径:素材计量以官方 usage 为准,缺失字段回退 ratios 的提交时事实值, +// 官方完全不返回素材计量时整体回退预扣口径。 +func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal { + if usage == nil || (usage.InputVideoSeconds <= 0 && usage.InputImageCount <= 0 && usage.OutputSeconds <= 0) { + return s.EstimateTaskCost(pricing, ratios, nil) + } + + cfg := s.parseConfig(pricing) + spec, _ := ratioString(ratios, "spec.resolution") + total := decimal.Zero + + // 输出生成组件:优先官方 output_seconds,缺失回退请求时长信号(与预扣同源) + if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 { + outputSecs := usage.OutputSeconds + if outputSecs <= 0 { + outputSecs = ratioFloatOr(ratios, "spec.duration", defaultTaskDurationSeconds) + } + if outputSecs > maxTaskDurationSeconds { + outputSecs = maxTaskDurationSeconds + } + total = total.Add(NewFromFloat(price).Mul(NewFromFloat(outputSecs))) + } + + // 输入图片:官方 input_image_count,缺失回退提交时上报的张数 + imageCount := float64(usage.InputImageCount) + if usage.InputImageCount <= 0 { + imageCount = ratioFloatOr(ratios, "spec.input_image_count", 0) + } + total = total.Add(s.imageCost(cfg, imageCount)) + + // 输入视频:官方 input_seconds,缺失回退预扣口径(确知时长 > 含视频按 15s 上限估 > 0) + inputSecs := usage.InputVideoSeconds + if inputSecs <= 0 { + inputSecs = estimateInputVideoSeconds(ratios) + } + total = total.Add(s.inputVideoCost(cfg, inputSecs)) + + return total.Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) +} + +// imageCost 输入图片费用:max(0, 张数 - 免费额度) × 单价 +func (minimaxMaterialScheme) imageCost(cfg *minimaxMaterialConfig, count float64) decimal.Decimal { + if cfg == nil || cfg.Image == nil || cfg.Image.PricePerUnit <= 0 || count <= float64(cfg.Image.FreeCount) { + return decimal.Zero + } + charged := count - float64(cfg.Image.FreeCount) + return NewFromFloat(charged).Mul(NewFromFloat(cfg.Image.PricePerUnit)) +} + +// inputVideoCost 输入视频费用:时长(钳制上限)× 按秒单一单价 +// (官方 usage 无输入视频分辨率信息,不做规格区分) +func (minimaxMaterialScheme) inputVideoCost(cfg *minimaxMaterialConfig, seconds float64) decimal.Decimal { + if cfg == nil || cfg.InputVideo == nil || cfg.InputVideo.PricePerSecond <= 0 || seconds <= 0 { + return decimal.Zero + } + if seconds > inputVideoMaxSeconds { + seconds = inputVideoMaxSeconds + } + return NewFromFloat(seconds).Mul(NewFromFloat(cfg.InputVideo.PricePerSecond)) +} + +// estimateInputVideoSeconds 输入视频预扣估算时长(秒): +// adaptor 确知时长(spec.input_video_seconds)优先;否则提交含输入视频 +// (spec.has_input_video)按生成长度上限 15s 估;无输入视频为 0。 +func estimateInputVideoSeconds(ratios map[string]any) float64 { + if s, ok := ratioFloat(ratios, "spec.input_video_seconds"); ok && s > 0 { + return s + } + if has, _ := ratios["spec.has_input_video"].(bool); has { + return inputVideoEstimateSeconds + } + return 0 +} + +// ratioFloatOr 取计费上下文 float 值,缺失返回默认值 +func ratioFloatOr(ratios map[string]any, key string, def float64) float64 { + if f, ok := ratioFloat(ratios, key); ok { + return f + } + return def +} diff --git a/internal/logic/billing/scheme_minimax_material_test.go b/internal/logic/billing/scheme_minimax_material_test.go new file mode 100644 index 0000000..4c4c927 --- /dev/null +++ b/internal/logic/billing/scheme_minimax_material_test.go @@ -0,0 +1,168 @@ +package billing + +import ( + "encoding/json" + "testing" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// mmTestPricing 构造 MiniMax 素材方案测试定价: +// 输出矩阵 768P=0.35 / 2K=0.6;图片 5 张免费、超出 0.20/张;输入视频按秒 0.50。 +// 乘数恒 1(租户/时段折扣单测用例另行覆盖)。 +// billing_mode 用 special(方案模型的标准配对形态);分发只看 Scheme, +// per_second+scheme 的存量组合由 scheme_test 覆盖。 +func mmTestPricing(t *testing.T, tenantMul float64) *PricingResult { + t.Helper() + return &PricingResult{ + BillingMode: BillingModeSpecial, + TenantMultiplier: tenantMul, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6}, + Scheme: SchemeMiniMaxMaterial, + SchemeConfig: json.RawMessage(`{ + "image": {"free_count": 5, "price_per_unit": 0.20}, + "input_video": {"price_per_second": 0.50} + }`), + } +} + +// TestMinimaxMaterial_Estimate 预扣口径:输出组件 + 提交可知的素材组件 +// (图片精确、含输入视频按生成长度上限 15s 冻结)。 +func TestMinimaxMaterial_Estimate(t *testing.T) { + pricing := mmTestPricing(t, 1.0) + + // 纯文生视频:6s × 768P 输出价,无素材费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", + }, nil), 2.1, "t2v output only") + + // 8 张图:输出 2.1 + (8-5)×0.20 = 2.7(5 张内免费见下一例) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, nil), 2.7, "8 images beyond free quota") + + // 4 张图(免费额度内):图费 0 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 4.0, + }, nil), 2.1, "4 images within free quota") + + // 提交不含输入视频(has_input_video 缺省):预扣不含输入视频费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", "spec.input_image_count": 8.0, + }, nil), 6*0.6+3*0.20, "no input video at estimate") + + // 提交含输入视频(时长未知):按生成长度上限 15s 冻结 → 2.1 + 15×0.50 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, + }, nil), 2.1+15*0.50, "input video frozen at generation cap") + + // has_input_video=false 显式无视频:不含输入视频费 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": false, + }, nil), 2.1, "explicit no input video") + + // adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限 + assertDecimal(t, estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", "spec.has_input_video": true, "spec.input_video_seconds": 8.0, + }, nil), 6*0.6+8*0.50, "input video estimated via ratios") +} + +// TestMinimaxMaterial_Settle 结算口径:官方 usage 为最终计费依据。 +func TestMinimaxMaterial_Settle(t *testing.T) { + pricing := mmTestPricing(t, 1.0) + scheme := minimaxMaterialScheme{} + + // usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.50 = 6.7 + cost := scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 8, InputImageCount: 8, OutputSeconds: 6}) + assertDecimal(t, cost, 6.7, "settle full usage 768P") + + // 2K 档:输出 6×0.6 + 输入视频 10×0.50 = 8.6(输入视频按秒单一单价,与生成分辨率无关) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "2K", + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 10, OutputSeconds: 6}) + assertDecimal(t, cost, 8.6, "settle 2K with input video") + + // usage 字段部分缺失:图片回退 ratios 提交时事实值,输出回退 spec.duration; + // 输入视频缺失且提交不含视频 → 0 + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) + assertDecimal(t, cost, 5*0.35+3*0.20, "settle partial usage falls back to ratios") + + // 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true, + }, &rcommon.TaskMaterialUsage{OutputSeconds: 5}) + assertDecimal(t, cost, 5*0.35+3*0.20+15*0.50, "settle missing input seconds falls back to cap estimate") + + // usage 为 nil:整体回退预扣口径(含输入视频时同样含 15s 冻结) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, + }, nil) + assertDecimal(t, cost, 2.7, "settle nil usage falls back to estimate") + + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true, + }, nil) + assertDecimal(t, cost, 6*0.35+15*0.50, "settle nil usage with input video falls back to cap estimate") + + // 输入视频异常大值钳制到 600s(防 usage 异常刷扣) + cost = scheme.SettleTaskCost(pricing, map[string]any{ + "spec.resolution": "768P", + }, &rcommon.TaskMaterialUsage{InputVideoSeconds: 99999, OutputSeconds: 1}) + assertDecimal(t, cost, 1*0.35+600*0.50, "settle clamps input video seconds") +} + +// TestMinimaxMaterial_Multipliers 租户折扣作用于全部组件(输出 + 素材)。 +func TestMinimaxMaterial_Multipliers(t *testing.T) { + pricing := mmTestPricing(t, 0.5) + cost := estimateTaskCost(pricing, map[string]any{ + "spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.input_video_seconds": 4.0, + }, nil) + assertDecimal(t, cost, (6*0.35+3*0.20+4*0.50)*0.5, "tenant multiplier applies to all components") +} + +// TestMinimaxMaterial_ValidateSchemeConfig 方案私有配置校验。 +func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) { + scheme := minimaxMaterialScheme{} + + // 合法配置 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":5,"price_per_unit":0.2}}`)); err != nil { + t.Errorf("valid image config rejected: %v", err) + } + // 空 / null / 全空 → 拒绝 + for _, cfg := range []json.RawMessage{nil, json.RawMessage("null"), json.RawMessage(`{}`)} { + if err := scheme.ValidateSchemeConfig(cfg); err == nil { + t.Errorf("empty config should be rejected: %s", cfg) + } + } + // 负单价 / 负免费额度 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"price_per_unit":-1}}`)); err == nil { + t.Error("negative image price should be rejected") + } + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":-1,"price_per_unit":0.2}}`)); err == nil { + t.Error("negative free count should be rejected") + } + // 输入视频按秒单价:零价(配置了节但不收费)与负价拒绝 + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0}}`)); err == nil { + t.Error("zero input video price should be rejected") + } + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":-0.5}}`)); err == nil { + t.Error("negative input video price should be rejected") + } + if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err != nil { + t.Errorf("valid input video price rejected: %v", err) + } +} + +// TestMinimaxMaterial_Registered 方案已注册且可经 LookupScheme 分发。 +func TestMinimaxMaterial_Registered(t *testing.T) { + if !SchemeRegistered(SchemeMiniMaxMaterial) { + t.Fatal("minimax material scheme should be registered") + } + if got := LookupScheme(SchemeMiniMaxMaterial).Name(); got != SchemeMiniMaxMaterial { + t.Errorf("lookup returned %s", got) + } +} diff --git a/internal/logic/billing/scheme_test.go b/internal/logic/billing/scheme_test.go new file mode 100644 index 0000000..98ae415 --- /dev/null +++ b/internal/logic/billing/scheme_test.go @@ -0,0 +1,142 @@ +package billing + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" + + rcommon "github.com/qianfree/team-api/relay/common" +) + +// testConfigScheme 测试用方案:演示方案实现的正确形态——注册实例无状态单例, +// 费用参数从 pricing.SchemeConfig(不透明容器)读取,同名注册仅一次。 +type testConfigScheme struct{} + +func (testConfigScheme) Name() string { return "test:config-scheme" } + +func (testConfigScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + var c struct { + Base float64 `json:"base"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if c.Base < 0 { + return gerror.New("base 不能为负") + } + return nil +} + +func (testConfigScheme) EstimateTaskCost(pricing *PricingResult, _ map[string]any, _ []byte) decimal.Decimal { + var c struct { + Base float64 `json:"base"` + } + _ = json.Unmarshal(pricing.SchemeConfig, &c) + return NewFromFloat(c.Base) +} + +// SettleTaskCost 测试方案:usage 携带秒数时按秒 × base 计,否则回退预扣口径 +func (testConfigScheme) SettleTaskCost(pricing *PricingResult, ratios map[string]any, usage *rcommon.TaskMaterialUsage) decimal.Decimal { + if usage != nil && usage.OutputSeconds > 0 { + var c struct { + Base float64 `json:"base"` + } + _ = json.Unmarshal(pricing.SchemeConfig, &c) + return NewFromFloat(c.Base * usage.OutputSeconds) + } + return testConfigScheme{}.EstimateTaskCost(pricing, ratios, nil) +} + +// TestSchemeRegistry 注册表基础行为:generic 默认注册、空名/未注册回落、SchemeRegistered 判定。 +func TestSchemeRegistry(t *testing.T) { + if !SchemeRegistered(SchemeGeneric) { + t.Fatal("generic scheme should be pre-registered") + } + if SchemeRegistered("test:not-registered") { + t.Fatal("unregistered name should report false") + } + + // 空名与未注册名都必须回落 generic(纯函数层防御) + if got := LookupScheme(""); got.Name() != SchemeGeneric { + t.Errorf("empty name should fall back to generic, got %s", got.Name()) + } + if got := LookupScheme("no:such-scheme"); got.Name() != SchemeGeneric { + t.Errorf("unknown name should fall back to generic, got %s", got.Name()) + } +} + +// TestGenericSchemeConfigValidation generic 方案不接受非空 scheme_config +// (拦截「空方案带配置」的错位数据;null 视同未配置)。 +func TestGenericSchemeConfigValidation(t *testing.T) { + g := GenericScheme{} + if err := g.ValidateSchemeConfig(nil); err != nil { + t.Errorf("nil config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage("null")); err != nil { + t.Errorf("null config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage(`{"x":1}`)); err == nil { + t.Error("non-empty config should be rejected for generic scheme") + } +} + +// TestSchemeDispatch 特殊方案分发:pricing.Scheme 命中注册方案后估算走方案实现, +// 且通用层统一应用附加乘数、minCost 钳制(方案实现不得自行处理,全站口径收口)。 +func TestSchemeDispatch(t *testing.T) { + RegisterScheme(testConfigScheme{}) + + pricing := &PricingResult{ + BillingMode: BillingModeSpecial, // 方案模型的标准配对形态;分发只看 Scheme,billing_mode 是参考字段 + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":2.0}`), + } + + // 分发命中:方案读 scheme_config 返回 2.0,无附加乘数时原样(高于 minCost 不触发钳制) + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 2.0, "scheme dispatch base") + + // 通用层应用附加乘数:2.0 × 0.5 = 1.0(方案实现未乘,证明 applyRatioMultipliers 收口在骨架) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"video_input": 0.5}, nil), 1.0, "scheme with ratio multiplier") + + // spec.* 事实键不参与乘法(通用层跳过) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.resolution": "720p"}, nil), 2.0, "spec facts not multiplied") + + // 通用层 minCost 钳制:方案低返回值(0.001)被钳到 0.01 + lowPricing := &PricingResult{ + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":0.001}`), + } + assertDecimal(t, estimateTaskCost(lowPricing, nil, nil), 0.01, "scheme result clamped by minCost") +} + +// TestSchemeDispatchGenericEquivalence 未声明 scheme 的定价走 generic 引擎, +// 结果与 billing_mode 直接计算一致(重构等价性的分发层验证,具体口径见 task_billing_test)。 +func TestSchemeDispatchGenericEquivalence(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.05, + TenantMultiplier: 1.0, + } + if got := LookupScheme(pricing.Scheme).Name(); got != SchemeGeneric { + t.Errorf("empty scheme should resolve to generic, got %s", got) + } + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "generic equivalence per_request") +} + +// TestSchemeRegisteredNameUniqueness 重名注册 panic(注册期快死优于带病运行)。 +func TestSchemeRegisteredNameUniqueness(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Error("duplicate registration should panic") + } else if !strings.Contains(fmt.Sprint(r), "duplicate scheme") { + t.Errorf("panic message should mention duplicate scheme, got %v", r) + } + }() + RegisterScheme(GenericScheme{}) +} diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 6f2a522..4792bee 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -123,10 +123,11 @@ func executeSettlement(ctx context.Context, p settlementTxParams) (int64, error) _, err = dao.BilTransactions.Ctx(ctx).Data(p.buildTransaction(billingID, InexactFloat64(balanceAfterD), InexactFloat64(frozenAfterD))).Insert() if err != nil { - // 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费) - if _, _, creditErr := CreditWalletRedis(ctx, p.tenantID, actualCostD); creditErr != nil { + // 逆转扣款 + 删除计费记录,回到「未结算」状态(预扣已随认领释放,本单免费)。 + // 用 ReverseConsumeRedis 而非 CreditWalletRedis:余额与累计消费必须同一原子回退 + if reverseErr := ReverseConsumeRedis(ctx, p.tenantID, actualCostD); reverseErr != nil { g.Log().Errorf(ctx, "%s: compensate reverse charge failed: tenant=%d request=%v cost=%.6f: %v", - p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, creditErr) + p.logPrefix, p.tenantID, p.predeductRequestIDs, p.actualCost, reverseErr) } if delErr := deleteBillingRecord(ctx, billingID); delErr != nil { g.Log().Errorf(ctx, "%s: compensate delete billing record %d failed: %v", p.logPrefix, billingID, delErr) diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 61b95f6..84b4cee 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -26,6 +26,12 @@ type BillingSnapshotPricing struct { EffectiveOutputPrice float64 `json:"effective_output_price"` BillingMode string `json:"billing_mode"` BillingSource string `json:"billing_source"` + // Scheme 命中的特殊计费方案名(pricing JSONB 顶层 scheme 键);空 = 通用引擎。 + // 供账单事后追溯该笔走的计费方案,摘要据此追加「计费方案」行 + Scheme string `json:"scheme,omitempty"` + // PerSecondPrices 按秒单价矩阵(仅 per_second / special 模式填充,其余模式 omitted): + // special 的矩阵是输出生成组件的定价依据,快照携带供账单解释 + PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` } // BillingSnapshotMultipliers 倍率信息 @@ -88,6 +94,8 @@ func GenerateBillingSnapshot( EffectiveOutputPrice: pricing.OutputPrice, BillingMode: pricing.BillingMode, BillingSource: pricing.BillingSource, + Scheme: pricing.Scheme, + PerSecondPrices: pricing.PerSecondPrices, }, Multipliers: BillingSnapshotMultipliers{ ModelMultiplier: pricing.ModelMultiplier, @@ -197,9 +205,11 @@ func GenerateBillingSummary(ctx context.Context, snapshot *BillingSnapshot) stri // 计费模式中文映射 modeMap := map[string]string{ - "token": "按量计费", - "per_request": "按次计费", - "tiered": "阶梯计费", + "token": "按量计费", + "per_request": "按次计费", + "tiered": "阶梯计费", + "per_second": "按秒计费", + BillingModeSpecial: "特殊计费", } mode := modeMap[snapshot.Pricing.BillingMode] if mode == "" { @@ -214,6 +224,11 @@ func GenerateBillingSummary(ctx context.Context, snapshot *BillingSnapshot) stri lines := make([]string, 0, 15) lines = append(lines, fmt.Sprintf("模型: %s | 计费模式: %s | 价格来源: %s", modelName, mode, source)) + // 特殊计费方案行:方案名直出(custom:minimax-material 等),便于账单事后追溯; + // 不做后端中文映射表——新增方案不应要求改动本文件(与 scheme 注册表设计一致) + if snapshot.Pricing.Scheme != "" { + lines = append(lines, fmt.Sprintf("计费方案: %s", snapshot.Pricing.Scheme)) + } lines = append(lines, "---") // 按次计费特殊处理 diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index 61ac69c..57b412a 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -275,6 +275,90 @@ func TestGenerateBillingSummary_Nil(t *testing.T) { } } +// TestGenerateBillingSnapshot_SchemeField 特殊计费方案:scheme + 按秒矩阵落快照 Pricing 节, +// 供账单事后追溯该笔走的计费方案与输出单价依据 +func TestGenerateBillingSnapshot_SchemeField(t *testing.T) { + pricing := &PricingResult{ + BillingMode: BillingModeSpecial, + BillingSource: "base", + TenantMultiplier: 1.0, + PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6}, + Scheme: SchemeMiniMaxMaterial, + } + snapshot := GenerateBillingSnapshot(pricing, &CostBreakdown{TotalCost: 2.1}, nil, nil, nil) + if snapshot == nil { + t.Fatal("expected non-nil snapshot") + } + if snapshot.Pricing.Scheme != SchemeMiniMaxMaterial { + t.Errorf("Pricing.Scheme = %q, want %q", snapshot.Pricing.Scheme, SchemeMiniMaxMaterial) + } + if snapshot.Pricing.BillingMode != BillingModeSpecial { + t.Errorf("Pricing.BillingMode = %q, want %q", snapshot.Pricing.BillingMode, BillingModeSpecial) + } + if snapshot.Pricing.PerSecondPrices["768P"] != 0.35 { + t.Errorf("Pricing.PerSecondPrices lost: %+v", snapshot.Pricing.PerSecondPrices) + } + + // JSON 往返保留(bil_usage_logs.billing_snapshot 的真实存取路径) + var parsed BillingSnapshot + if err := json.Unmarshal([]byte(SnapshotToJSON(snapshot)), &parsed); err != nil { + t.Fatalf("roundtrip unmarshal: %v", err) + } + if parsed.Pricing.Scheme != SchemeMiniMaxMaterial { + t.Errorf("roundtrip Scheme lost: %q", parsed.Pricing.Scheme) + } + if parsed.Pricing.PerSecondPrices["2K"] != 0.6 { + t.Errorf("roundtrip PerSecondPrices lost: %+v", parsed.Pricing.PerSecondPrices) + } + + // token 模式快照不带矩阵(omitempty,旧结构零变化) + tokenSnapshot := GenerateBillingSnapshot(&PricingResult{ + BillingMode: "token", InputPrice: 0.5, OutputPrice: 1.5, TenantMultiplier: 1.0, + }, &CostBreakdown{}, nil, nil, nil) + if tokenSnapshot.Pricing.PerSecondPrices != nil { + t.Errorf("token snapshot should not carry per_second_prices: %+v", tokenSnapshot.Pricing.PerSecondPrices) + } +} + +// TestGenerateBillingSummary_SchemeAndPerSecond 摘要模式中文映射补全: +// special 显示「特殊计费」并追加「计费方案」行;per_second 显示「按秒计费」 +// (此前两值均直出英文原始串) +func TestGenerateBillingSummary_SchemeAndPerSecond(t *testing.T) { + // special + 方案:含「特殊计费」标签与「计费方案:」行 + special := &BillingSnapshot{ + Pricing: BillingSnapshotPricing{ + BillingMode: BillingModeSpecial, + BillingSource: "base", + Scheme: SchemeMiniMaxMaterial, + }, + Multipliers: BillingSnapshotMultipliers{TenantMultiplier: 1.0}, + Settlement: BillingSnapshotSettlement{PreDeductAmount: 2.1, ActualCost: 2.1}, + RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "MiniMax-H3"}, + } + text := GenerateBillingSummary(context.Background(), special) + if !strings.Contains(text, "特殊计费") { + t.Error("summary should contain 特殊计费") + } + if !strings.Contains(text, "计费方案: "+SchemeMiniMaxMaterial) { + t.Error("summary should contain scheme line") + } + + // per_second:中文标签不再直出原始串 + perSecond := &BillingSnapshot{ + Pricing: BillingSnapshotPricing{ + BillingMode: "per_second", + BillingSource: "base", + }, + Multipliers: BillingSnapshotMultipliers{TenantMultiplier: 1.0}, + Settlement: BillingSnapshotSettlement{PreDeductAmount: 4.0, ActualCost: 4.0}, + RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "kling-v2"}, + } + text = GenerateBillingSummary(context.Background(), perSecond) + if !strings.Contains(text, "按秒计费") { + t.Error("summary should contain 按秒计费") + } +} + func TestGenerateBillingSummary_PerRequest(t *testing.T) { snapshot := &BillingSnapshot{ Pricing: BillingSnapshotPricing{ diff --git a/internal/logic/billing/sweeper.go b/internal/logic/billing/sweeper.go index 5708fb9..d3e0952 100644 --- a/internal/logic/billing/sweeper.go +++ b/internal/logic/billing/sweeper.go @@ -80,7 +80,7 @@ func materializeDirtyWallets(ctx context.Context) { // materializeOneWallet 物化单个租户钱包;成功或 hash 缺失时清除脏标记 func materializeOneWallet(ctx context.Context, tenantID int64) { walletRedisKey := walletHashKey(tenantID) - balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID) + balanceMicro, frozenMicro, consumedMicro, hasConsumed, exists, err := readWalletHash(ctx, tenantID) if err != nil { // Redis 故障:保留脏标记,下 tick 重试 g.Log().Warningf(ctx, "[WALLET MATERIALIZER] read wallet hash failed: tenant=%d: %v", tenantID, err) @@ -92,10 +92,27 @@ func materializeOneWallet(ctx context.Context, tenantID int64) { return } - // decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen 全量覆盖 + // 自愈兜底(三层补种之③层):hash 存在但缺 total_consumed 字段(boot 种子后新出现的 + // 字段缺失 hash,如灾后经充值路径重建),从 DB 物化副本补种历史基线——否则下方全量 + // 覆盖会把回填好的历史值写回 0 + if !hasConsumed { + dbConsumed, qErr := queryTotalConsumed(ctx, tenantID) + if qErr != nil { + // DB 故障:保留脏标记,下 tick 重试 + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] query total_consumed for seed failed: tenant=%d: %v", tenantID, qErr) + return + } + if sErr := seedTotalConsumedIfMissing(ctx, tenantID, ToMicro(dbConsumed)); sErr != nil { + g.Log().Warningf(ctx, "[WALLET MATERIALIZER] seed total_consumed failed: tenant=%d: %v", tenantID, sErr) + return + } + consumedMicro = ToMicro(dbConsumed) + } + + // decimal 直传 NUMERIC(driver.Valuer 精确字符串),balance/frozen/total_consumed 全量覆盖 _, err = g.DB().Ctx(ctx).Exec(ctx, - "UPDATE bil_wallets SET balance = ?, frozen_balance = ?, updated_at = NOW() WHERE tenant_id = ?", - FromMicro(balanceMicro), FromMicro(frozenMicro), tenantID) + "UPDATE bil_wallets SET balance = ?, frozen_balance = ?, total_consumed = ?, updated_at = NOW() WHERE tenant_id = ?", + FromMicro(balanceMicro), FromMicro(frozenMicro), FromMicro(consumedMicro), tenantID) if err != nil { // DB 故障:保留脏标记,下 tick 重试 g.Log().Warningf(ctx, "[WALLET MATERIALIZER] update db wallet failed: tenant=%d: %v", tenantID, err) diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 67dd1cf..962c63c 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "strings" "time" + "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" @@ -24,13 +26,32 @@ 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 } - return estimateTaskCost(pricing, ratios), nil + // 特殊计费方案 fail-closed:定价引用了未注册的方案(方案代码缺失/被回滚)时 + // 引擎无从得知正确价格,静默回落 generic 会错价,拒绝任务提交与「未配价模型 + // 不得放行」的同步路径口径一致 + if pricing.Scheme != "" && !SchemeRegistered(pricing.Scheme) { + return Zero, gerror.Newf("billing scheme %q not registered (model=%s)", pricing.Scheme, modelName) + } + // special 必须与方案配对(写侧已双向校验,此处兜底防手工改库):裸 special 会落 + // generic 分发走 default 分支,计费口径不可预期,拒绝任务提交 + if pricing.BillingMode == BillingModeSpecial && pricing.Scheme == "" { + return Zero, gerror.Newf("billing_mode special requires scheme (model=%s)", modelName) + } + // 参数倍率求值:命中注入 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, taskBody), nil } // imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。 @@ -38,77 +59,72 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID // 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。 const imagePlaceholderPreDeduct = 0.1 -// estimateTaskCost 纯函数:根据定价与计费比率估算任务预扣费用,不依赖数据库/缓存,便于单测。 -// -// 计费口径按任务类型分流: -// - per_request(按次计费,图片/音乐等):直接取按次单价; -// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 -// 10000 tokens/s × duration × resolution 预估 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 { +// estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。 +// 骨架只做两件事:按 pricing.Scheme 分发到计费方案实现(未声明走 generic 通用引擎), +// 再统一应用附加比率与 minCost 钳制——方案实现只负责组件费用(含租户/时段乘数), +// 附加乘数、钳制、舍入全站收口于此,保证各方案结算口径一致。 +func estimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal { if pricing == nil { return NewFromFloat(0.01) } - var costD decimal.Decimal - switch { - 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 { - duration = d - } - resolutionMul := 2.25 // 默认 720p - if r, ok := ratios["resolution"]; ok && r > 0 { - resolutionMul = r - } - - // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier - // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 - // 修复视频预扣三连乘:全程 decimal 避免链式误差 - baseTokensPerSec := decimal.NewFromInt(10000) - durationD := NewFromFloat(duration) - resolutionMulD := NewFromFloat(resolutionMul) - million := decimal.NewFromInt(1_000_000) - - costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). - Div(million). - Mul(NewFromFloat(pricing.OutputPrice)). - Mul(NewFromFloat(pricing.TenantMultiplier)) - default: - // 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣, - // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 - // (见 sync_image_worker.settleSyncImageSuccess)。 - if pricing.PerRequestPrice > 0 { - costD = NewFromFloat(pricing.PerRequestPrice) - } else { - costD = NewFromFloat(imagePlaceholderPreDeduct) - } - } - - // 应用附加比率(video_input 折扣等) - for k, ratio := range ratios { - if k == "duration" || k == "resolution" { - continue - } - costD = costD.Mul(NewFromFloat(ratio)) - } - - minCost := NewFromFloat(0.01) - if costD.LessThan(minCost) { - costD = minCost - } - return RoundMoney(costD) + costD := LookupScheme(pricing.Scheme).EstimateTaskCost(pricing, ratios, taskBody) + return wrapSchemeCost(pricing, ratios, 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 +168,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 +350,16 @@ func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualC return bd } + // per_second / special(特殊方案)模式:无 token 语义,费用整体记 BaseCost(折扣前成本), + // 与 per_request 快照口径一致。命中档的每秒价在预扣时已消费,此处不重复记录 + // (CostBreakdown 无单价字段先例);special 若漏此分支会落 token 路径,生成 + // 「N tokens × $0」的自相矛盾快照行 + if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial { + 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 体现。 @@ -400,11 +426,32 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI return newEstimatedCost, nil } +// RecalculateByMaterials 按上游 usage 的素材计量重算任务费用(素材计费方案的结算依据)。 +// 模型未配置素材方案 / 取价失败 / usage 为 nil 时 ok=false,调用方保持既有结算来源。 +// billAt 为任务受理时刻(时段定价按该时刻评估,与预扣口径一致)。 +func (b *TaskBillingProviderImpl) RecalculateByMaterials(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, usage *common.TaskMaterialUsage, billAt time.Time) (decimal.Decimal, bool, error) { + if usage == nil { + return Zero, false, nil + } + pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt) + if err != nil || pricing == nil { + // 取价失败不阻断结算(调用方回落预扣金额),仅返回 ok=false + return Zero, false, err + } + // 仅显式配置了计费方案的模型适用:generic 任务保持「预扣即终价」不被重估 + // (素材计费语义由方案声明,未声明素材的模型不应因 usage 附带字段改变结算来源) + if pricing.Scheme == "" { + return Zero, false, nil + } + costD := LookupScheme(pricing.Scheme).SettleTaskCost(pricing, ratios, usage) + return wrapSchemeCost(pricing, ratios, costD), true, nil +} + // RecalculateByTokens 根据上游返回的 total_tokens 重算费用。 // 公式: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 +461,13 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan return Zero, nil } + // per_second / special(特殊方案)模式按秒计价,token 数与费用无关:返回 0 让结算 + // 保持预扣金额,防止上游附带返回 token 用量时把按秒任务重算成 token 价 + // (special 若漏此分支,租户 custom_output_price 覆盖 + 上游返回 token 时会被错误重算) + if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial { + return Zero, nil + } + // 需要 output_price > 0 才能做 token 重算 if pricing.OutputPrice <= 0 { return Zero, nil @@ -427,13 +481,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..b8ce809 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -165,7 +165,39 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) { OutputPrice: 30.0, // 即使配了 output_price 也不应走 token 估算 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request cost") +} + +// TestEstimateTaskCost_PerRequestTenantTimeDiscount 按次任务预扣即终价(结算无 token 重算 +// 信号),租户乘数与时段乘数必须在预扣阶段就生效,与 chat 路径 computeCost 的按次口径一致。 +func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + // 0.10 × 0.5 × 0.8 = 0.04 + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request tenant × time discount") + + // token 模式但仅配按次价的图片任务(default 分支回退)同样生效 + p2 := &PricingResult{ + BillingMode: "token", + PerRequestPrice: 0.10, + OutputPrice: 30.0, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + assertDecimal(t, estimateTaskCost(p2, nil, nil), 0.04, "image per-request fallback discount") + + // 时段乘数零值(旧缓存条目缺字段)兜底 1.0,不得把预扣清零 + p3 := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0, + } + assertDecimal(t, estimateTaskCost(p3, nil, nil), 0.05, "zero time multiplier fallback") } // TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution) @@ -176,8 +208,8 @@ func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) { OutputPrice: 30.0, TenantMultiplier: 1.0, } - ratios := map[string]float64{"duration": 5, "resolution": 2.25} - assertDecimal(t, estimateTaskCost(pricing, ratios), 3.375, "video cost") + ratios := map[string]any{"duration": 5.0, "resolution": 2.25} + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 3.375, "video cost") } // TestEstimateTaskCost_ImageNoDurationSignal 图片任务(token 模式、有 output_price、但 ratios 无 @@ -190,7 +222,7 @@ func TestEstimateTaskCost_ImageNoDurationSignal(t *testing.T) { PerRequestPrice: 0, // 未配按次价 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.1, "image placeholder pre-deduct") } // TestEstimateTaskCost_PerRequestBelowPlaceholder 已配置的真实按次单价(低于占位值)必须被尊重, @@ -201,7 +233,7 @@ func TestEstimateTaskCost_PerRequestBelowPlaceholder(t *testing.T) { PerRequestPrice: 0.04, // DALL·E 类真实单价,低于 0.1 占位 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "configured per_request price respected") } // TestEstimateTaskCost_ImageUsesPerRequestPrice 图片模型即便 billing_mode 仍是 token,只要配了 @@ -213,7 +245,7 @@ func TestEstimateTaskCost_ImageUsesPerRequestPrice(t *testing.T) { PerRequestPrice: 0.05, TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "image per_request cost") } // TestEstimateTaskCost_VideoInputDiscount 附加比率(video_input 折扣)在时长估算之上叠加。 @@ -223,14 +255,14 @@ 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") + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 1.6875, "video discounted cost") } // TestEstimateTaskCost_NilPricing 定价缺失时按最低消费兜底。 func TestEstimateTaskCost_NilPricing(t *testing.T) { - assertDecimal(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor") + assertDecimal(t, estimateTaskCost(nil, nil, nil), 0.01, "nil pricing floor") } // TestHasDurationSignal 区分视频(有 duration/resolution)与图片(nil)。 @@ -238,13 +270,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", + }, nil), 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", + }, nil), 4.0, "per_second wildcard fallback") + + // 无时长信号 → 缺省 5s × "*" 0.5 = 2.5 + assertDecimal(t, estimateTaskCost(pricing, nil, 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, + }, nil), 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, + }, nil), 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}, nil), 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/billing/wallet.go b/internal/logic/billing/wallet.go index c219282..58e9b96 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -31,7 +31,7 @@ const ( ) // walletHashKey 钱包 Redis hash key。 -// v2:balance / frozen_balance 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 +// v2:balance / frozen_balance / total_consumed 以整数 micro-USD 存储;旧版 float key 随 TTL 过期。 // // 钱包 hash 是资金状态的【唯一实时权威】(Redis 权威化架构): // - 所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)全部通过 Lua 原子作用于它; @@ -56,6 +56,7 @@ type WalletInfo struct { TenantID int64 Balance float64 FrozenBalance float64 + TotalConsumed float64 WarningThreshold float64 Currency string LowBalanceNotified bool @@ -101,7 +102,7 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { return nil, err } - balance, frozen, err := getWalletRedisState(ctx, tenantID) + balance, frozen, totalConsumed, err := getWalletRedisState(ctx, tenantID) if err != nil { return nil, err } @@ -111,44 +112,51 @@ func GetWallet(ctx context.Context, tenantID int64) (*WalletInfo, error) { TenantID: tenantID, Balance: balance, FrozenBalance: frozen, + TotalConsumed: totalConsumed, WarningThreshold: static.WarningThreshold, Currency: static.Currency, LowBalanceNotified: static.LowBalanceNotified, }, nil } -// getWalletRedisState 读取 Redis 权威余额/冻结(USD float)。hash 缺失时先灾难恢复重建。 -func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen float64, err error) { - balanceMicro, frozenMicro, exists, err := readWalletHash(ctx, tenantID) +// getWalletRedisState 读取 Redis 权威余额/冻结/累计消费(USD float)。hash 缺失时先灾难恢复重建。 +func getWalletRedisState(ctx context.Context, tenantID int64) (balance, frozen, totalConsumed float64, err error) { + balanceMicro, frozenMicro, consumedMicro, _, exists, err := readWalletHash(ctx, tenantID) if err != nil { - return 0, 0, err + return 0, 0, 0, err } if !exists { if err = rebuildWalletFromDB(ctx, tenantID); err != nil { - return 0, 0, err + return 0, 0, 0, err } - balanceMicro, frozenMicro, _, err = readWalletHash(ctx, tenantID) + balanceMicro, frozenMicro, consumedMicro, _, _, err = readWalletHash(ctx, tenantID) if err != nil { - return 0, 0, err + return 0, 0, 0, err } } - return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), nil + return InexactFloat64(FromMicro(balanceMicro)), InexactFloat64(FromMicro(frozenMicro)), InexactFloat64(FromMicro(consumedMicro)), nil } -// readWalletHash 读取钱包 hash 的 balance/frozen_balance(micro-USD)。 +// readWalletHash 读取钱包 hash 的 balance/frozen_balance/total_consumed(micro-USD)。 // exists=false 表示 hash 不存在(灾难恢复场景)。 -func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro int64, exists bool, err error) { +// hasConsumed=false 表示 hash 存在但缺 total_consumed 字段(部署前的老 hash / 灾后仅含 +// balance 的重建 hash),调用方(物化器)据此从 DB 补种历史基线,避免计数从 0 起算丢历史。 +func readWalletHash(ctx context.Context, tenantID int64) (balanceMicro, frozenMicro, consumedMicro int64, hasConsumed, exists bool, err error) { res, err := g.Redis().Do(ctx, "HGETALL", walletHashKey(tenantID)) if err != nil { - return 0, 0, false, gerror.Wrapf(err, "read wallet hash") + return 0, 0, 0, false, false, gerror.Wrapf(err, "read wallet hash") } if res.IsNil() || res.IsEmpty() { - return 0, 0, false, nil + return 0, 0, 0, false, false, nil } m := res.Map() balanceMicro = gconv.Int64(m["balance"]) frozenMicro = gconv.Int64(m["frozen_balance"]) - return balanceMicro, frozenMicro, true, nil + if raw, ok := m["total_consumed"]; ok { + consumedMicro = gconv.Int64(raw) + hasConsumed = true + } + return balanceMicro, frozenMicro, consumedMicro, hasConsumed, true, nil } // rebuildWalletFromDB 灾难恢复:钱包 hash 缺失时从 DB 物化值重建。 @@ -174,11 +182,12 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error { } var w *struct { - Balance decimal.Decimal `json:"balance"` + Balance decimal.Decimal `json:"balance"` + TotalConsumed decimal.Decimal `json:"total_consumed"` } err = dao.BilWallets.Ctx(ctx). Where("tenant_id", tenantID). - Fields("balance"). + Fields("balance, total_consumed"). Scan(&w) if err != nil { return gerror.Wrapf(err, "rebuild wallet: query db") @@ -190,18 +199,19 @@ func doRebuildWalletFromDB(ctx context.Context, tenantID int64) error { // 幸存预扣明细重算 frozen(不取 DB frozen_balance:其中可能含已丢失预扣的残留) frozenMicro := sumSurvivingPrededucts(ctx, tenantID) - // 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖 + // 「检查不存在 + 写入」原子完成,避免并发重建互相覆盖。 + // total_consumed 从 DB 物化副本恢复历史基线(丢失的只是物化间隔内的增量,与余额同类风险) rebuildLua := ` local key = KEYS[1] if redis.call("EXISTS", key) == 1 then return 0 end -redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "ver", 0) -redis.call("SADD", "wallet_dirty_tenants", ARGV[3]) +redis.call("HSET", key, "balance", ARGV[1], "frozen_balance", ARGV[2], "total_consumed", ARGV[3], "ver", 0) +redis.call("SADD", "wallet_dirty_tenants", ARGV[4]) return 1 ` ret, err := g.Redis().Do(ctx, "EVAL", rebuildLua, 1, walletRedisKey, - ToMicro(w.Balance), frozenMicro, tenantID) + ToMicro(w.Balance), frozenMicro, ToMicro(w.TotalConsumed), tenantID) if err != nil { return gerror.Wrapf(err, "rebuild wallet: redis") } @@ -421,6 +431,8 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string) (d // 返回值:{claimed_micro, balance_after_micro, frozen_after_micro} // 预扣 hash 不存在(已释放/过期/丢失)时按 claimed=0 处理:只扣 balance 不动 frozen, // 与既有「track 丢失只扣 balance」语义一致(调用方据此打告警)。 +// total_consumed 与扣款同一原子递增(累计消费唯一正向变动点;字段缺失按 0 起算—— +// 正常路径由 boot 种子/灾备重建/物化器自愈三层机制保证字段已含历史基线)。 const settleClaimLua = ` local wallet_key = KEYS[1] local cost = tonumber(ARGV[1]) @@ -444,6 +456,7 @@ if frozen < 0 then frozen = 0 end local balance = tonumber(redis.call("HINCRBY", wallet_key, "balance", -cost)) +redis.call("HINCRBY", wallet_key, "total_consumed", cost) redis.call("HINCRBY", wallet_key, "ver", 1) redis.call("SADD", "wallet_dirty_tenants", tenant_id) return {claimed, balance, frozen} @@ -502,7 +515,8 @@ redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) return {balance, frozen} ` -// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/结算补偿逆转等场景。 +// CreditWalletRedis 钱包加款(Redis 提交点):充值/兑换/人工充值/调账等场景。 +// 注意:结算补偿逆转走 ReverseConsumeRedis(需同步回退累计消费),不得用本函数。 // 返回加款后余额与当前冻结快照(供流水 balance_after/frozen_after)。 // hash 缺失时 HINCRBY 会新建仅含 balance 的 hash——仅应发生在「新租户首充」等合法场景; // 其他场景说明发生了灾难丢失,由后续 rebuild/物化流程收敛。 @@ -537,6 +551,32 @@ func DebitWalletRedis(ctx context.Context, tenantID int64, amount decimal.Decima return FromMicro(vals[0]), FromMicro(vals[1]), true, nil } +// 结算补偿逆转 Lua:balance 与 total_consumed 同一原子回退。 +// 专用场景:executeSettlement 第 c 步(记流水)失败时逆转扣款——本单最终免费,不构成消费, +// 累计消费必须与余额同步回退,防止只逆转余额导致计数虚增。 +// 不复用 creditWalletLua:那是充值/调账通用入口,不感知累计消费计数(充值不得动计数)。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = cost_micro(正数) +// ARGV[2] = tenant_id +const reverseConsumeLua = ` +redis.call("HINCRBY", KEYS[1], "balance", ARGV[1]) +redis.call("HINCRBY", KEYS[1], "total_consumed", -tonumber(ARGV[1])) +redis.call("HINCRBY", KEYS[1], "ver", 1) +redis.call("SADD", "wallet_dirty_tenants", ARGV[2]) +return 1 +` + +// ReverseConsumeRedis 结算补偿逆转(executeSettlement 记流水失败时调用): +// 余额与累计消费在同一 Lua 内原子回退。失败由调用方记 Error 日志告警人工对账。 +func ReverseConsumeRedis(ctx context.Context, tenantID int64, cost decimal.Decimal) error { + _, err := g.Redis().Do(ctx, "EVAL", reverseConsumeLua, 1, + walletHashKey(tenantID), ToMicro(cost), tenantID) + if err != nil { + return gerror.Wrapf(err, "reverse consume: redis") + } + return nil +} + // InvalidateWalletStaticCache 清除钱包静态字段缓存(预警阈值、低余额标记等低频字段变更后调用)。 // 注意:只清进程内静态缓存,【绝不】清 Redis 钱包 hash——hash 是权威余额(非可失效缓存), // 删除会导致下次从滞后的 DB 重建而回滚未物化增量。 diff --git a/internal/logic/billing/wallet_lua_test.go b/internal/logic/billing/wallet_lua_test.go index eee53fd..2eeefba 100644 --- a/internal/logic/billing/wallet_lua_test.go +++ b/internal/logic/billing/wallet_lua_test.go @@ -42,7 +42,7 @@ func testCtx() context.Context { return context.Background() } // readWallet 读取租户钱包余额/冻结(micro → decimal) func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) { t.Helper() - b, f, exists, err := readWalletHash(testCtx(), tenantID) + b, f, _, _, exists, err := readWalletHash(testCtx(), tenantID) if err != nil { t.Fatalf("read wallet hash: %v", err) } @@ -52,6 +52,22 @@ func readWallet(t *testing.T, tenantID int64) (balance, frozen decimal.Decimal) return FromMicro(b), FromMicro(f) } +// readWalletConsumed 读取租户累计消费计数(micro → decimal;hash 缺字段按 0) +func readWalletConsumed(t *testing.T, tenantID int64) decimal.Decimal { + t.Helper() + _, _, c, hasConsumed, exists, err := readWalletHash(testCtx(), tenantID) + if err != nil { + t.Fatalf("read wallet hash: %v", err) + } + if !exists { + t.Fatalf("wallet hash missing for tenant %d", tenantID) + } + if !hasConsumed { + return decimal.Zero + } + return FromMicro(c) +} + // seedWallet 用加款创建指定余额的钱包 hash(顺带校验 CreditWalletRedis 自身) func seedWallet(t *testing.T, tenantID int64, balance decimal.Decimal) { t.Helper() @@ -249,6 +265,7 @@ func TestWalletConcurrency(t *testing.T) { bal, frozen := readWallet(t, tenant) assertDecimalEqual(t, bal, NewFromFloat(100).Sub(NewFromFloat(0.5).Mul(NewFromFloat(n))), "balance conserved (100 - n*0.5)") assertDecimalEqual(t, frozen, decimal.Zero, "frozen converges to zero") + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(0.5).Mul(NewFromFloat(n)), "total_consumed conserved (n*0.5)") } // TestPredeductSweep_RecomputesFrozen 孤儿清扫重算:某预扣 hash 丢失(TTL 过期/崩溃残留)后, @@ -297,3 +314,103 @@ func TestCleanupPreDeduct_RemovesHash(t *testing.T) { t.Fatal("prededuct hash should be cleaned") } } + +// TestSettleClaim_IncrementsTotalConsumed 累计消费随结算扣款同一原子递增; +// 重复结算(预扣已认领、claimed=0 只补扣)时计数仍按实际扣款额累计。 +func TestSettleClaim_IncrementsTotalConsumed(t *testing.T) { + tenant := int64(11) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + + // 首次结算:新 hash 无 total_consumed 字段,HINCRBY 从 0 起算(正常路径由三层补种保证含基线) + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1.5), []string{"req-tc1"}); err != nil { + t.Fatalf("settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(1.5), "total_consumed after first settle") + + // 重复结算走补扣路径:计数继续累计实际扣款 + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(0.5), []string{"req-tc1"}); err != nil { + t.Fatalf("second settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(2), "total_consumed accumulates supplement") +} + +// TestReverseConsumeRedis_ReversesBalanceAndCount 结算补偿逆转:余额与累计消费同一原子回退 +func TestReverseConsumeRedis_ReversesBalanceAndCount(t *testing.T) { + tenant := int64(12) + seedWallet(t, tenant, NewFromFloat(10)) + if ok, _ := PreDeduct(testCtx(), tenant, 4, "req-tc2", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(3), []string{"req-tc2"}); err != nil { + t.Fatalf("settle claim: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(3), "total_consumed after settle") + + // 模拟 executeSettlement 第 c 步失败补偿:本单免费,计数与余额同步回退 + if err := ReverseConsumeRedis(testCtx(), tenant, NewFromFloat(3)); err != nil { + t.Fatalf("reverse consume: %v", err) + } + bal, _ := readWallet(t, tenant) + assertDecimalEqual(t, bal, NewFromFloat(10), "balance restored after reverse") + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "total_consumed reversed with balance") +} + +// TestNonConsumeOps_DoNotTouchTotalConsumed 回归保护: +// 预扣/解冻/扣款(退款调账)/加款(充值)均不得触碰累计消费计数。 +func TestNonConsumeOps_DoNotTouchTotalConsumed(t *testing.T) { + tenant := int64(13) + seedWallet(t, tenant, NewFromFloat(10)) + + if ok, _ := PreDeduct(testCtx(), tenant, 2, "req-nc1", "gpt-4o"); !ok { + t.Fatal("prededuct failed") + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "prededuct must not touch total_consumed") + + if _, err := UnfreezePreDeduct(testCtx(), tenant, "req-nc1"); err != nil { + t.Fatalf("unfreeze: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "unfreeze must not touch total_consumed") + + if _, _, ok, err := DebitWalletRedis(testCtx(), tenant, NewFromFloat(3)); err != nil || !ok { + t.Fatalf("debit failed: ok=%v err=%v", ok, err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "debit (refund/adjust) must not touch total_consumed") + + if _, _, err := CreditWalletRedis(testCtx(), tenant, NewFromFloat(5)); err != nil { + t.Fatalf("credit: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), decimal.Zero, "credit (recharge) must not touch total_consumed") +} + +// TestSeedTotalConsumedIfMissing 三层补种之①③层共用 Lua 的行为: +// hash 存在且缺字段 → 写入基线;字段已存在 → 不覆盖(幂等);hash 不存在 → no-op。 +func TestSeedTotalConsumedIfMissing(t *testing.T) { + tenant := int64(14) + seedWallet(t, tenant, NewFromFloat(10)) // creditLua 创建的 hash 不含 total_consumed 字段 + + if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil { + t.Fatalf("seed total_consumed: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(7.25), "seeded from db baseline") + + // 字段已存在后发生结算 → 重复种子不得回退结算增量 + if _, _, _, err := SettleClaim(testCtx(), tenant, NewFromFloat(1), nil); err != nil { + t.Fatalf("settle claim: %v", err) + } + if err := seedTotalConsumedIfMissing(testCtx(), tenant, ToMicro(NewFromFloat(7.25))); err != nil { + t.Fatalf("re-seed: %v", err) + } + assertDecimalEqual(t, readWalletConsumed(t, tenant), NewFromFloat(8.25), "re-seed must not overwrite post-settle value") + + // hash 不存在的租户 → no-op(不得凭空创建 hash,由 rebuild 流程负责) + missing := int64(15) + if err := seedTotalConsumedIfMissing(testCtx(), missing, ToMicro(NewFromFloat(9))); err != nil { + t.Fatalf("seed on missing hash: %v", err) + } + if _, _, _, hasConsumed, exists, err := readWalletHash(testCtx(), missing); err != nil || exists || hasConsumed { + t.Fatalf("seed must not create hash: exists=%v hasConsumed=%v err=%v", exists, hasConsumed, err) + } +} diff --git a/internal/logic/billing/wallet_seed.go b/internal/logic/billing/wallet_seed.go new file mode 100644 index 0000000..0b5e351 --- /dev/null +++ b/internal/logic/billing/wallet_seed.go @@ -0,0 +1,93 @@ +package billing + +import ( + "context" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/shopspring/decimal" + + "github.com/qianfree/team-api/internal/dao" +) + +// seedTotalConsumedLua 补种 Lua:仅当 hash 存在且缺 total_consumed 字段时写入历史基线 +// (HSET-if-missing 语义,幂等)。 +// KEYS[1] = wallet:v2:{tenant_id} +// ARGV[1] = total_consumed_micro +const seedTotalConsumedLua = ` +if redis.call("EXISTS", KEYS[1]) == 1 and redis.call("HEXISTS", KEYS[1], "total_consumed") == 0 then + redis.call("HSET", KEYS[1], "total_consumed", ARGV[1]) + return 1 +end +return 0 +` + +// seedTotalConsumedIfMissing 为钱包 hash 补种累计消费基线(hash 不存在或字段已存在均为 no-op)。 +// 供 boot 种子与物化器自愈共用(三层补种机制的①③层)。 +func seedTotalConsumedIfMissing(ctx context.Context, tenantID int64, consumedMicro int64) error { + _, err := g.Redis().Do(ctx, "EVAL", seedTotalConsumedLua, 1, + walletHashKey(tenantID), consumedMicro) + if err != nil { + return gerror.Wrapf(err, "seed total_consumed: redis") + } + return nil +} + +// queryTotalConsumed 读 DB 物化副本中的累计消费(钱包行不存在时返回 0——物化 UPDATE +// 同样影响 0 行,无需区分)。 +func queryTotalConsumed(ctx context.Context, tenantID int64) (decimal.Decimal, error) { + var w *struct { + TotalConsumed decimal.Decimal `json:"total_consumed"` + } + err := dao.BilWallets.Ctx(ctx). + Where("tenant_id", tenantID). + Fields("total_consumed"). + Scan(&w) + if err != nil { + return Zero, gerror.Wrapf(err, "query total_consumed") + } + if w == nil { + return Zero, nil + } + return w.TotalConsumed, nil +} + +// SeedWalletTotalConsumed 启动种子(三层补种之①层):把 DB 物化副本中的累计消费历史基线 +// 种进已存在的钱包 hash。 +// +// 背景:total_consumed 字段上线时,存量钱包 hash(wallet:v2:*)不含该字段,结算 Lua 的 +// HINCRBY 会从 0 起算,丢失迁移回填的历史值。本函数在启动时(relay 流量进入前)为所有 +// total_consumed > 0 的钱包补种,幂等可重跑: +// - hash 已含字段(已种过/已发生结算)→ no-op; +// - hash 不存在(该租户尚无 Redis 钱包)→ 跳过,由 rebuildWalletFromDB 在首次资金操作时补种(②层); +// - 部署后新出现的字段缺失 hash 由物化器自愈兜底(③层)。 +// +// 不 SADD 脏集合——种子值与 DB 相同,无需再物化。 +// DB/Redis 故障时记 Warning 放行(Redis 故障随后由预扣/结算 fail-closed 机制接管,不阻塞启动)。 +// 租户量级为千级以内时逐条 EVAL 足够(秒级完成);量级显著增长时可改 pipeline。 +func SeedWalletTotalConsumed(ctx context.Context) { + var rows []struct { + TenantId int64 `json:"tenant_id"` + TotalConsumed decimal.Decimal `json:"total_consumed"` + } + err := dao.BilWallets.Ctx(ctx). + Fields("tenant_id, total_consumed"). + Where("total_consumed > 0"). + Scan(&rows) + if err != nil { + g.Log().Warningf(ctx, "[WALLET SEED] query wallets for total_consumed seed failed: %v", err) + return + } + + seeded := 0 + for _, row := range rows { + if err := seedTotalConsumedIfMissing(ctx, row.TenantId, ToMicro(row.TotalConsumed)); err != nil { + g.Log().Warningf(ctx, "[WALLET SEED] seed total_consumed failed: tenant=%d: %v", row.TenantId, err) + continue + } + seeded++ + } + if len(rows) > 0 { + g.Log().Infof(ctx, "[WALLET SEED] total_consumed baseline seeded: %d/%d wallets", seeded, len(rows)) + } +} diff --git a/internal/logic/common/tenant_model.go b/internal/logic/common/tenant_model.go index a2f06b2..ee1c014 100644 --- a/internal/logic/common/tenant_model.go +++ b/internal/logic/common/tenant_model.go @@ -14,6 +14,7 @@ type AvailableModelItem struct { ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -24,13 +25,14 @@ type AvailableModelItem struct { // GetTenantAvailableModels 计算租户实际可用的所有模型(显式分配 + 分组来源,去重) // 返回模型基本信息列表,不含价格。可被管理后台和租户控制台共用。 -func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, search string) ([]AvailableModelItem, error) { +func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, vendor, search string) ([]AvailableModelItem, error) { // 阶段1:获取显式分配的模型 var explicitModels []struct { ModelDBID int64 `json:"model_db_id"` ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -47,12 +49,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea if category != "" { explicitQuery = explicitQuery.Where("m.category", category) } + if vendor != "" { + explicitQuery = explicitQuery.Where("m.vendor", vendor) + } if search != "" { explicitQuery = explicitQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") } err := explicitQuery. - Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + Fields("mdl_tenant_models.model_id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). OrderAsc("m.category"). OrderAsc("m.model_id"). Scan(&explicitModels) @@ -69,6 +74,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId: r.ModelId, ModelName: r.ModelName, Category: r.Category, + Vendor: r.Vendor, MaxContextTokens: r.MaxContextTokens, MaxOutputTokens: r.MaxOutputTokens, Description: r.Description, @@ -84,6 +90,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId string `json:"model_id"` ModelName string `json:"model_name"` Category string `json:"category"` + Vendor string `json:"vendor"` MaxContextTokens int `json:"max_context_tokens"` MaxOutputTokens int `json:"max_output_tokens"` Description string `json:"description"` @@ -110,12 +117,15 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea if category != "" { groupQuery = groupQuery.Where("m.category", category) } + if vendor != "" { + groupQuery = groupQuery.Where("m.vendor", vendor) + } if search != "" { groupQuery = groupQuery.Where("m.model_id LIKE ? OR m.model_name LIKE ?", "%"+search+"%", "%"+search+"%") } _ = groupQuery. - Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). + Fields("DISTINCT m.id AS model_db_id, m.model_id, m.model_name, m.category, m.vendor, m.max_context_tokens, m.max_output_tokens, m.description, m.tags, m.capabilities"). OrderAsc("m.category"). OrderAsc("m.model_id"). Scan(&groupModels) @@ -126,6 +136,7 @@ func GetTenantAvailableModels(ctx context.Context, tenantID int64, category, sea ModelId: gm.ModelId, ModelName: gm.ModelName, Category: gm.Category, + Vendor: gm.Vendor, MaxContextTokens: gm.MaxContextTokens, MaxOutputTokens: gm.MaxOutputTokens, Description: gm.Description, diff --git a/internal/logic/docs/openapi.go b/internal/logic/docs/openapi.go index f8c92a8..aec9279 100644 --- a/internal/logic/docs/openapi.go +++ b/internal/logic/docs/openapi.go @@ -202,6 +202,115 @@ func buildOpenAPISpec(ctx context.Context) map[string]any { }, }, }, + "/v1/videos": map[string]any{ + "post": map[string]any{ + "summary": "Create Video", + "description": "创建视频生成任务(OpenAI Videos 协议,兼容 openai-python / openai-node SDK 的 client.videos.create)。请求体支持 multipart/form-data(官方 SDK 形态,input_reference 可为文件 part)与 application/json 两种编码。返回官方 Video 对象(status: queued),轮询 GET /v1/videos/{video_id} 直到 completed,再经 GET /v1/videos/{video_id}/content 获取视频二进制。", + "operationId": "createVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "multipart/form-data": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"model", "prompt"}, + "properties": map[string]any{ + "model": map[string]any{"type": "string", "example": "kling-v2-master"}, + "prompt": map[string]any{"type": "string"}, + "seconds": map[string]any{"type": "string", "enum": []string{"4", "8", "12"}, "default": "4"}, + "size": map[string]any{"type": "string", "enum": []string{"720x1280", "1280x720", "1024x1792", "1792x1024"}, "default": "720x1280"}, + "input_reference": map[string]any{"type": "string", "format": "binary", "description": "可选参考图(图生视频),文件上传或 {\"image_url\": \"...\"} 引用对象;file_id 形态不支持"}, + }, + }, + }, + "application/json": map[string]any{ + "schema": map[string]any{ + "type": "object", + "required": []string{"model", "prompt"}, + "properties": map[string]any{ + "model": map[string]any{"type": "string"}, + "prompt": map[string]any{"type": "string"}, + "seconds": map[string]any{"type": "string"}, + "size": map[string]any{"type": "string"}, + "input_reference": map[string]any{"type": "object", "properties": map[string]any{"image_url": map[string]any{"type": "string"}}}, + }, + }, + }, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "Video 对象(object: video,status: queued)"}, + }, + }, + }, + "/v1/videos/{video_id}": map[string]any{ + "get": map[string]any{ + "summary": "Retrieve Video", + "description": "查询视频生成任务,返回官方 Video 对象。status: queued / in_progress / completed / failed;非终态响应携带 openai-poll-after-ms 轮询间隔建议头。生成失败以 status=failed + error 对象表达(HTTP 仍为 200)。", + "operationId": "retrieveVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "Video 对象(含 progress 百分比、completed_at、失败时的 error)"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + }, + }, + "delete": map[string]any{ + "summary": "Delete Video", + "description": "删除已完成或已失败的视频任务(软删除,计费与审计记录保留),删除后查询与下载均返回 404。进行中的任务不可删除。", + "operationId": "deleteVideo", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "删除确认(object: video.deleted,deleted: true)"}, + "400": map[string]any{"description": "任务非终态或计费未结算,不可删除"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + }, + }, + }, + "/v1/videos/{video_id}/content": map[string]any{ + "get": map[string]any{ + "summary": "Download Video Content", + "description": "下载已生成完成的视频二进制流(mp4)。仅 status=completed 的任务可下载;variant 仅支持默认 video(thumbnail / spritesheet 暂不支持)。", + "operationId": "downloadVideoContent", + "security": []map[string]any{{"BearerAuth": []any{}}}, + "parameters": []map[string]any{ + { + "name": "video_id", + "in": "path", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + { + "name": "variant", + "in": "query", + "required": false, + "schema": map[string]any{"type": "string", "enum": []string{"video"}, "default": "video"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{"description": "视频二进制流(video/mp4)"}, + "400": map[string]any{"description": "任务未完成或无可用内容"}, + "404": map[string]any{"description": "视频不存在或已被删除"}, + "502": map[string]any{"description": "上游内容拉取失败"}, + }, + }, + }, "/v1/messages": map[string]any{ "post": map[string]any{ "summary": "Claude Messages", 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..9cf6d53 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"` } @@ -344,15 +344,44 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co return } - // 查询上游状态 + // 查询上游状态(model 供供应商适配器按模型分派协议端点,如 MiniMax v1/v2; + // 其余适配器按结构体解析 taskData,自动忽略未知键) + upstreamModel := task.UpstreamModel + if upstreamModel == "" { + upstreamModel = task.ModelName + } taskData, _ := json.Marshal(map[string]any{ "task_id": pd.UpstreamTaskID, "use_proxy": parseChannelUseProxy(channel.Settings), + "model": upstreamModel, }) - resp, err := adaptor.FetchTask(channel.BaseURL, channel.ApiKey, taskData) + // 渠道调试日志(轮询记录,仅段2/段3——后台轮询无客户端交互): + // 节流规则 = 只在「状态/进度相对上一次轮询发生变化」时落库(任务行 CAS 前的值即上一次 + // 持久化状态,无需额外存储),防止 15s 轮询周期灌爆 chn_debug_logs; + // fetch/parse 错误无「上一次」可对比且是轮询排查的主要目标,必记。 + // request_id 复用提交请求原 ID,管理后台按 request_id 筛选可看到提交 + 全部状态转移。 + var dbgSess *common.DebugSession + var dbgAttempt *common.DebugAttempt + if pollDebugLogEnabled(channel.Settings, task) { + dbgSess = common.NewDebugSession(task.RequestID, task.TenantID, task.UserID, task.ApiKeyID, "task_poll:"+task.PublicTaskID) + dbgAttempt = dbgSess.BeginTaskAttempt(&common.ChannelMeta{ + ChannelID: channel.ID, + ChannelName: channel.Name, + ChannelType: channel.Type, + UpstreamModelName: upstreamModel, + }, task.ModelName, task.Platform, 0) + } + pollCtx := ctx + if dbgAttempt != nil { + pollCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture) + } + + resp, err := adaptor.FetchTask(pollCtx, channel.BaseURL, channel.ApiKey, taskData) if err != nil { g.Log().Warningf(ctx, "poll: fetch task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } defer resp.Body.Close() @@ -360,6 +389,8 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co body, err := io.ReadAll(resp.Body) if err != nil { g.Log().Warningf(ctx, "poll: read response for task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } @@ -367,9 +398,17 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co taskInfo, err := adaptor.ParseTaskResult(body) if err != nil { g.Log().Warningf(ctx, "poll: parse result for task %s: %v", task.PublicTaskID, err) + dbgAttempt.MarkFinal(err) + dbgSess.FinalizeAndSubmit(0, 0) return } + // 状态变化判定 + 调试记录提交(CAS 更新 task.Status 之前,task 行内即上一次轮询值) + if pollStateChanged(taskInfo, task) { + dbgAttempt.MarkFinal(nil) + dbgSess.FinalizeAndSubmit(0, 0) + } + // CAS 更新状态 oldStatus := task.Status task.Status = string(taskInfo.Status) @@ -421,6 +460,14 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co // 优先用上游返回的 ActualCost if taskInfo.ActualCost > 0 { actualCost = billing.NewFromFloat(taskInfo.ActualCost) + } else if taskInfo.MaterialUsage != nil { + // 素材计费方案:按官方 usage 计量(输入视频时长/图片张数/输出秒数)重算。 + // 链接素材的时长提交时不可知,此为最终计费依据;模型未配置素材方案时 ok=false 保持预扣 + if materialCost, ok, mErr := taskBilling.RecalculateByMaterials(ctx, task.TenantID, task.ModelName, pd.BillingContext.Ratios, taskInfo.MaterialUsage, task.CreatedAt); mErr != nil { + g.Log().Warningf(ctx, "poll: recalculate by materials task %s: %v", task.PublicTaskID, mErr) + } else if ok { + actualCost = materialCost + } } else if taskInfo.TotalTokens > 0 && pd.BillingContext.Ratios != nil { // 用上游 total_tokens + 保存的 ratios 重算 if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios, task.CreatedAt); err == nil && tokenCost.GreaterThan(billing.Zero) { @@ -492,6 +539,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 +580,8 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s PreDeductAmount: billing.InexactFloat64(task.PreDeductAmount), BillingSource: "task", TaskID: task.PublicTaskID, + + DurationSeconds: durationSeconds, } // 从结算结果填充计费快照 @@ -590,3 +655,19 @@ func parseChannelUseProxy(settings json.RawMessage) bool { } return s.UseProxy } + +// pollDebugLogEnabled 判定轮询渠道是否开启调试日志且匹配任务的租户/成员/密钥过滤 +// (ChannelBasicInfo.Settings 为 JSONB 原文,按需解析;解析失败视为未开启) +func pollDebugLogEnabled(settings json.RawMessage, task *common.AsyncTask) bool { + var s common.ChannelSettings + if len(settings) == 0 || json.Unmarshal(settings, &s) != nil { + return false + } + return s.DebugLogEnabled && s.DebugTargetMatch(task.TenantID, task.UserID, task.ApiKeyID) +} + +// pollStateChanged 判定本次轮询结果相对任务行当前持久化状态是否发生变化(状态或进度任一变化)。 +// 独立成纯函数以便回归测试;在 CAS 更新 task 之前调用,task 内即上一次轮询持久化的值 +func pollStateChanged(taskInfo *common.TaskInfo, task *common.AsyncTask) bool { + return string(taskInfo.Status) != task.Status || taskInfo.Progress != task.Progress +} diff --git a/internal/logic/task/async_polling_test.go b/internal/logic/task/async_polling_test.go index 9bb67f1..3a6d44c 100644 --- a/internal/logic/task/async_polling_test.go +++ b/internal/logic/task/async_polling_test.go @@ -1,6 +1,7 @@ package task import ( + "encoding/json" "testing" "time" @@ -33,3 +34,52 @@ func TestBuildTimeoutFailure_UsesOriginalStatusAsCASPredicate(t *testing.T) { } } } + +// TestPollStateChanged 状态变化判定(轮询调试记录的节流依据): +// 状态或进度任一相对任务行当前持久化值变化才算变化,其余忽略 +func TestPollStateChanged(t *testing.T) { + task := &common.AsyncTask{Status: "IN_PROGRESS", Progress: "50%"} + + // 完全一致 → 未变化 + if pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "50%"}, task) { + t.Error("状态与进度均一致时不应视为变化") + } + // 状态变化(终态转移) + if !pollStateChanged(&common.TaskInfo{Status: "SUCCESS", Progress: "50%"}, task) { + t.Error("状态变化应视为变化") + } + // 仅进度变化 + if !pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "60%"}, task) { + t.Error("进度变化应视为变化") + } + // 空进度 → 有进度也算变化(提交后首次轮询) + if !pollStateChanged(&common.TaskInfo{Status: "IN_PROGRESS", Progress: "10%"}, + &common.AsyncTask{Status: "SUBMITTED", Progress: ""}) { + t.Error("空进度到有进度应视为变化") + } +} + +// TestPollDebugLogEnabled 轮询调试开关判定:JSONB 解析 + 目标过滤 AND 组合 +func TestPollDebugLogEnabled(t *testing.T) { + task := &common.AsyncTask{TenantID: 1, UserID: 2, ApiKeyID: 3} + + // 空/非法 settings 视为未开启 + if pollDebugLogEnabled(nil, task) || pollDebugLogEnabled([]byte("not-json"), task) { + t.Error("空或非法 settings 应视为未开启") + } + // 未开启 + off, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: false}) + if pollDebugLogEnabled(off, task) { + t.Error("DebugLogEnabled=false 应不开启") + } + // 开启且无过滤 → 匹配 + on, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: true}) + if !pollDebugLogEnabled(on, task) { + t.Error("开启且无过滤应匹配") + } + // 开启但租户不匹配 → 拒绝 + filtered, _ := json.Marshal(common.ChannelSettings{DebugLogEnabled: true, DebugLogTenantID: 99}) + if pollDebugLogEnabled(filtered, task) { + t.Error("租户过滤不匹配应拒绝") + } +} diff --git a/internal/logic/task/async_provider.go b/internal/logic/task/async_provider.go index 1b584a9..7183dba 100644 --- a/internal/logic/task/async_provider.go +++ b/internal/logic/task/async_provider.go @@ -53,6 +53,8 @@ func asyncTaskFromEntity(row *entity.TskModelTasks) *common.AsyncTask { FinishTime: timePtrFromGTime(row.FinishTime), CreatedAt: timeFromGTime(row.CreatedAt), UpdatedAt: timeFromGTime(row.UpdatedAt), + Deleted: row.Deleted, + DeletedAt: timePtrFromGTime(row.DeletedAt), } } @@ -187,11 +189,12 @@ func (p *AsyncProvider) UpdateTaskCAS(ctx context.Context, task *common.AsyncTas return nil } -// GetTaskByPublicID 根据公开任务 ID 查询 +// GetTaskByPublicID 根据公开任务 ID 查询(软删除的任务视为不存在) func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID string) (*common.AsyncTask, error) { var row *entity.TskModelTasks err := dao.TskModelTasks.Ctx(ctx). Where("public_task_id", publicTaskID). + Where("deleted", false). Scan(&row) if err != nil { return nil, gerror.Wrapf(err, "query async task failed: public_id=%s", publicTaskID) @@ -199,13 +202,14 @@ func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID stri return asyncTaskFromEntity(row), nil } -// GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询 +// GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询(软删除的任务视为不存在) func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTaskID string, userID int64, tenantID int64) (*common.AsyncTask, error) { var row *entity.TskModelTasks err := dao.TskModelTasks.Ctx(ctx). Where("public_task_id", publicTaskID). Where("user_id", userID). Where("tenant_id", tenantID). + Where("deleted", false). Scan(&row) if err != nil { return nil, gerror.Wrapf(err, "query async task failed: public_id=%s user_id=%d", publicTaskID, userID) @@ -262,6 +266,30 @@ func (p *AsyncProvider) GetUnsettledTasks(ctx context.Context, limit int) ([]*co return asyncTasksFromEntities(rows), nil } +// SoftDeleteTask 软删除任务(DELETE /v1/videos/{id})。 +// 仅标记 deleted,不动 status/计费字段:调用方保证只有终态且已结算的任务可删, +// 轮询 cron 的 GetNonTerminalTasks/GetUnsettledTasks 查询不感知 deleted 列。 +func (p *AsyncProvider) SoftDeleteTask(ctx context.Context, task *common.AsyncTask) error { + result, err := dao.TskModelTasks.Ctx(ctx). + Where("id", task.ID). + Where("tenant_id", task.TenantID). + Update(map[string]any{ + "deleted": true, + "deleted_at": gtime.Now(), + "result_url": "", // 清掉成品直链,避免软删后内部路径仍可寻址 + "updated_at": time.Now(), + }) + if err != nil { + return gerror.Wrapf(err, "soft delete async task failed: id=%d", task.ID) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("soft delete no rows affected: id=%d", task.ID) + } + g.Log().Infof(ctx, "[AsyncProvider] SoftDeleteTask: public_id=%s, tenant=%d", task.PublicTaskID, task.TenantID) + return nil +} + // GetChannelByID 获取渠道基本信息(含从 chn_channel_keys 解密的 API Key) func (p *AsyncProvider) GetChannelByID(ctx context.Context, channelID int64) (*common.ChannelBasicInfo, error) { var row *struct { diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index 155e7de..3ea6ce7 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 } @@ -447,6 +447,10 @@ func processSyncImageJob(job *SyncImageJob) { defer sess.Finish(context.WithoutCancel(ctx), false, 0) lastErr := "no available channel" + // 渠道调试日志:镜像 RelayHandler 重试循环的接线——首个开启调试且匹配过滤的尝试创建 + // 会话(段1 = 客户端提交的原始请求体;后台执行无客户端交互,段4 留空)。中间失败尝试 + // Submit 立即落库(is_final=false),成功尝试 MarkFinal 后提交最终记录 + var dbgSess *common.DebugSession for attempt := 0; attempt < syncImageMaxAttempts; attempt++ { d := sess.Next(ctx) if d == nil { @@ -465,15 +469,32 @@ func processSyncImageJob(job *SyncImageJob) { continue } - ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel) + var dbgAttempt *common.DebugAttempt + if sel.Settings.DebugLogEnabled && sel.Settings.DebugTargetMatch(job.TenantID, job.UserID, job.ApiKeyID) { + if dbgSess == nil { + dbgSess = common.NewDebugSession(job.RequestID, job.TenantID, job.UserID, job.ApiKeyID, "/v1/images/generations/async") + dbgSess.CaptureClientRequest(http.Header{"Content-Type": []string{"application/json"}}, job.RequestBody) + } + dbgAttempt = dbgSess.BeginTaskAttempt(&common.ChannelMeta{ + ChannelID: sel.ChannelID, + ChannelName: sel.ChannelName, + ChannelType: sel.ChannelType, + UpstreamModelName: sel.UpstreamModelName, + }, job.Model, "images_generations", attempt) + } + + ok, memW, perr := runImagePipelineWithRelease(ctx, job, sel, dbgAttempt) if ok { sess.Finish(context.WithoutCancel(ctx), true, 0) settleSyncImageSuccess(ctx, job, sel, memW) + dbgAttempt.MarkFinal(nil) + dbgSess.FinalizeAndSubmit(0, 0) return } // 失败:上报健康并按 FSM 决策原地重试/换渠道/终止(绑定不删除) lastErr = perr + dbgAttempt.Submit(errors.New(perr)) dec, backoff := sess.Report(ctx, 0, errors.New(perr), dispatch.DeliveryResponseReceived, 0, 0) monitor.TrackDispatchRetry(dispatch.Classify(0, errors.New(perr), dispatch.DeliveryResponseReceived).String(), dec.String()) if dec == dispatch.DecisionAbort { @@ -491,11 +512,12 @@ func processSyncImageJob(job *SyncImageJob) { } // runImagePipelineWithRelease 管线执行期间保活调度租约(租约获取/释放由调度会话负责)。 -func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (ok bool, memW *memResponseWriter, failReason string) { +// dbg 为当前尝试的调试捕获器(nil = 渠道未开启调试),注入 ctx 供传输层镜像段2/3。 +func runImagePipelineWithRelease(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (ok bool, memW *memResponseWriter, failReason string) { stopHeartbeat := make(chan struct{}) go refreshSyncImageChannelLease(sel.ChannelID, job.RequestID, stopHeartbeat) defer close(stopHeartbeat) - return runImagePipeline(ctx, job, sel) + return runImagePipeline(ctx, job, sel, dbg) } func refreshSyncImageChannelLease(channelID int64, requestID string, stop <-chan struct{}) { @@ -514,9 +536,14 @@ func refreshSyncImageChannelLease(channelID int64, requestID string, stop <-chan } // runImagePipeline 复刻 RelayHandler 的最小管线,把上游响应捕获到内存 writer。 -func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection) (bool, *memResponseWriter, string) { +func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.ChannelSelection, dbg *common.DebugAttempt) (bool, *memResponseWriter, string) { + // 调试捕获器注入 ctx(nil 时透传),同步适配器经 NewRequestWithContext 携带到传输层 + pipeCtx := ctx + if dbg != nil { + pipeCtx = common.WithDebugAttempt(ctx, dbg.Capture) + } info := &common.RelayInfo{ - Context: ctx, + Context: pipeCtx, TenantID: job.TenantID, UserID: job.UserID, ApiKeyID: job.ApiKeyID, @@ -552,6 +579,7 @@ func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.Channe return false, nil, fmt.Sprintf("no adaptor for channelType %d", sel.ChannelType) } adaptor.Init(info) + dbg.CaptureProtocol(info) reader, err := adaptor.ConvertRequest(ctx, info, job.RequestBody) if err != nil { @@ -559,7 +587,8 @@ func runImagePipeline(ctx context.Context, job *SyncImageJob, sel *common.Channe } // 上游长连接用后台 context(不随停机被斩断),阻塞 10–60s。 - resp, err := adaptor.DoRequest(context.WithoutCancel(ctx), info, reader) + // WithoutCancel 保留 ctx values,调试捕获器随请求到达传输层 + resp, err := adaptor.DoRequest(context.WithoutCancel(pipeCtx), info, reader) if err != nil { return false, nil, fmt.Sprintf("do request: %v", err) } diff --git a/internal/logic/tenant/marketplace.go b/internal/logic/tenant/marketplace.go index 5052c0c..141c5a5 100644 --- a/internal/logic/tenant/marketplace.go +++ b/internal/logic/tenant/marketplace.go @@ -2,9 +2,9 @@ package tenant import ( "context" - "encoding/json" "fmt" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/util/gconv" @@ -14,20 +14,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 获取模型广场列表(从默认模型分组加载) @@ -48,24 +42,32 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: 0, Page: req.Page, PageSize: req.PageSize, + Vendors: []string{}, }, nil } - // 2. 构建查询:通过分组关联查询模型 - m := dao.MdlModels.Ctx(ctx). - LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id"). - Where("mdl_group_models.group_id", defaultGroup.Id). - Where("mdl_models.status", "active") - - // 搜索关键词 - if req.Keyword != "" { - keyword := "%" + req.Keyword + "%" - m = m.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword) + // 2. 构建查询:通过分组关联查询模型(厂商 facet 与主列表共用同一套基础条件, + // 用闭包各自构建链路,避免 gf Model 链式分支共享底层状态的歧义) + newQuery := func() *gdb.Model { + q := dao.MdlModels.Ctx(ctx). + LeftJoin("mdl_group_models", "mdl_group_models.model_id = mdl_models.id"). + Where("mdl_group_models.group_id", defaultGroup.Id). + Where("mdl_models.status", "active") + if req.Keyword != "" { + keyword := "%" + req.Keyword + "%" + q = q.Where("(mdl_models.model_id LIKE ? OR mdl_models.model_name LIKE ? OR mdl_models.description LIKE ?)", keyword, keyword, keyword) + } + if req.Category != "" { + q = q.Where("mdl_models.category", req.Category) + } + return q } - // 分类筛选 - if req.Category != "" { - m = m.Where("mdl_models.category", req.Category) + m := newQuery() + + // 厂商筛选 + if req.Vendor != "" { + m = m.Where("mdl_models.vendor", req.Vendor) } // 排序:按类别、模型名称 @@ -77,6 +79,23 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) return nil, err } + // 3.1 厂商 facet:在当前关键词/分类条件下(忽略厂商筛选本身)有模型的厂商去重列表, + // 前端据此只渲染实际有模型的厂商筛选项,保证点任一厂商都有结果 + var vendorRows []struct { + Vendor string `json:"vendor"` + } + err = newQuery(). + Where("mdl_models.vendor <> ''"). + Fields("DISTINCT mdl_models.vendor"). + Scan(&vendorRows) + if err != nil { + return nil, err + } + vendors := make([]string, 0, len(vendorRows)) + for _, r := range vendorRows { + vendors = append(vendors, r.Vendor) + } + // 4. 分页查询模型 var models []*entity.MdlModels err = m.Fields("mdl_models.*"). @@ -93,6 +112,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: 0, Page: req.Page, PageSize: req.PageSize, + Vendors: vendors, }, nil } @@ -104,7 +124,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 @@ -128,6 +147,7 @@ func (s *sTenant) GetModelList(ctx context.Context, req *v1.MarketplaceListReq) Total: total, Page: req.Page, PageSize: req.PageSize, + Vendors: vendors, }, nil } @@ -162,11 +182,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 @@ -186,6 +205,7 @@ func (s *sTenant) convertToMarketplaceItem(ctx context.Context, model *entity.Md ModelId: model.ModelId, ModelName: model.ModelName, Category: model.Category, + Vendor: model.Vendor, Description: model.Description, MaxContextTokens: model.MaxContextTokens, MaxOutputTokens: model.MaxOutputTokens, @@ -215,14 +235,21 @@ 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(展示用);special 的矩阵是 + // 输出生成组件的参考单价(素材组件单价在方案配置中,不随广场下发) + if (billingMode == "per_second" || billingMode == billing.BillingModeSpecial) && blob != nil { + item.PerSecondPrices = blob.Prices } if pricing.DiscountLabel != nil { item.DiscountLabel = *pricing.DiscountLabel @@ -231,16 +258,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..20d74e9 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,13 +107,39 @@ 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 获取租户可用的模型列表 func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsReq) (*v1.TenantAvailableModelsRes, error) { tenantID := middleware.GetTenantID(ctx) - models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Search) + models, err := lcommon.GetTenantAvailableModels(ctx, tenantID, req.Category, req.Vendor, req.Search) if err != nil { return nil, err } @@ -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 } } @@ -300,6 +282,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, Description: m.Description, @@ -320,9 +303,14 @@ 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]) } + // 按秒/特殊计费:矩阵来自平台定价(租户不逐格覆盖,倍率在计费时作用); + // special 的矩阵是输出生成组件的参考单价(素材组件单价在方案配置中) + if effectiveBillingMode == "per_second" || effectiveBillingMode == billing.BillingModeSpecial { + 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 { @@ -353,6 +341,7 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab ModelId: m.ModelId, ModelName: m.ModelName, Category: m.Category, + Vendor: m.Vendor, MaxContext: m.MaxContextTokens, MaxOutput: m.MaxOutputTokens, Description: m.Description, @@ -370,8 +359,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" || billingMode == billing.BillingModeSpecial) && 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) } } @@ -484,8 +476,13 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab return &v1.TenantAvailableModelsRes{List: list}, nil } -// resolveBillingMode 解析有效计费模式 +// resolveBillingMode 解析有效计费模式。 +// 特殊计费方案模型的模式由平台方案决定(special),租户级覆盖无效 +// (与 billing.GetModelPriceAt 的读侧守卫、admin.UpdateTenantModel 的写侧守卫同口径) func resolveBillingMode(tenantMode *string, baseMode string) string { + if baseMode == billing.BillingModeSpecial { + return billing.BillingModeSpecial + } if tenantMode != nil && *tenantMode != "" { return *tenantMode } @@ -506,8 +503,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 +522,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 +555,13 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string, tierInput = &tiers[0].InputPrice tierOutput = &tiers[0].OutputPrice } + // per_second / special 模式换算基准:兜底档 "*" 单价,无 "*" 取矩阵最低正价 + var perSecondBase *float64 + if billingMode == "per_second" || billingMode == billing.BillingModeSpecial { + if base := billing.LookupPerSecondPrice(perSecondPrices, "*"); base > 0 { + perSecondBase = &base + } + } result := make([]v1.TimePriceItem, 0, len(segments)) for _, seg := range segments { @@ -578,6 +579,10 @@ func buildTimePrices(segments []billing.TimeSegment, billingMode string, if perRequestPrice != nil { tp.PerRequestPrice = mulDisplayPrice(*perRequestPrice, seg.Multiplier) } + case "per_second", billing.BillingModeSpecial: + if perSecondBase != nil { + tp.PerSecondPrice = mulDisplayPrice(*perSecondBase, seg.Multiplier) + } case "tiered": if tierInput != nil { tp.InputPrice = mulDisplayPrice(*tierInput, seg.Multiplier) diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 2bc7a68..300d8ba 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -226,6 +226,9 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) "kling": "Kling", "midjourney": "Midjourney", "suno": "Suno", + "volcengine": "火山引擎", + "ali": "阿里", + "minimax": "MiniMax", } // 预扣/实际费用属 bil_ 记账层,币种 = 系统本位币 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/bil_wallets.go b/internal/model/do/bil_wallets.go index 67fdda5..e11122c 100644 --- a/internal/model/do/bil_wallets.go +++ b/internal/model/do/bil_wallets.go @@ -23,4 +23,5 @@ type BilWallets struct { UpdatedAt *gtime.Time // 更新时间 CumulativeRecharge any // 累计充值总额(USD) LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false) + TotalConsumed any // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减) } diff --git a/internal/model/do/mdl_models.go b/internal/model/do/mdl_models.go index 0a54954..5bc4e80 100644 --- a/internal/model/do/mdl_models.go +++ b/internal/model/do/mdl_models.go @@ -27,4 +27,5 @@ type MdlModels struct { DeprecatedAt *gtime.Time // 标记弃用的时间(NULL表示未弃用) SunsetDate *gtime.Time // 计划下线日期(到达后返回410 Gone,NULL表示未设置) ReplacementModel any // 推荐替代模型名(NULL表示无替代) + Vendor any // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类 } diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 608b74b..ed361fb 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -7,26 +7,19 @@ 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'tiers') +-- +goose StatementBegin +DELETE FROM mdl_pricing WHERE min_tokens <> 0; +-- +goose StatementEnd + +-- 5. 删除旧价格列与时段列(时段已并入 pricing 顶层) +-- +goose StatementBegin +ALTER TABLE mdl_pricing + DROP COLUMN IF EXISTS min_tokens, + DROP COLUMN IF EXISTS max_tokens, + DROP COLUMN IF EXISTS input_price, + DROP COLUMN IF EXISTS output_price, + DROP COLUMN IF EXISTS per_request_price, + DROP COLUMN IF EXISTS cache_read_price, + DROP COLUMN IF EXISTS cache_creation_price, + DROP COLUMN IF EXISTS time_segments; +-- +goose StatementEnd + +-- 6. 每模型一行唯一约束 +-- +goose StatementBegin +ALTER TABLE mdl_pricing ADD CONSTRAINT uk_mdl_pricing_model UNIQUE (model_id); +-- +goose StatementEnd + +-- 7. billing_mode 值域新增 special:pricing 顶层声明了 scheme 键的存量方案模型(如 MiniMax-H3 的 +-- custom:minimax-material 素材计费)从 per_second 改标 special,使用量日志/账单展示「特殊计费」 +-- 而非「按秒计费」。计费金额不受影响(计费分发只看 pricing JSONB 的 scheme 键,billing_mode 是 +-- 展示参考字段)。WHERE 带 billing_mode='per_second' 条件,可重复执行(幂等)。 +UPDATE mdl_pricing + SET billing_mode = 'special' + WHERE billing_mode = 'per_second' + AND pricing->>'scheme' IS NOT NULL + AND pricing->>'scheme' <> ''; + +COMMENT ON COLUMN mdl_pricing.billing_mode IS '计费模式:token=按量;per_request=按次;tiered=阶梯;per_second=按秒(视频);special=特殊计费(pricing JSONB 顶层 scheme 声明计费方案,如 custom:minimax-material)'; + +-- ── 三、bil_usage_logs:按秒计费任务时长 ── +-- +goose StatementBegin +ALTER TABLE bil_usage_logs ADD COLUMN IF NOT EXISTS duration_seconds INT; +-- +goose StatementEnd + +COMMENT ON COLUMN bil_usage_logs.duration_seconds IS '按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL'; + +-- ── 四、tnt_tenants 标签备注 + bil_wallets 累计消费(方案 A:Redis 权威 + DB 物化副本)── +-- +goose StatementBegin +ALTER TABLE tnt_tenants + ADD COLUMN tags JSONB NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN remark VARCHAR(1000) NOT NULL DEFAULT ''; +-- +goose StatementEnd + +COMMENT ON COLUMN tnt_tenants.tags IS '租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)'; +COMMENT ON COLUMN tnt_tenants.remark IS '管理员备注(运营备注,不暴露给租户控制台)'; + +-- +goose StatementBegin +ALTER TABLE bil_wallets + ADD COLUMN total_consumed NUMERIC(20,10) NOT NULL DEFAULT 0; +-- +goose StatementEnd + +COMMENT ON COLUMN bil_wallets.total_consumed IS '累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)'; + +-- 存量回填:从流水账重放历史消费(consume 流水 amount 为负值),幂等可重跑 +UPDATE bil_wallets w +SET total_consumed = COALESCE(( + SELECT SUM(-t.amount) FROM bil_transactions t + WHERE t.tenant_id = w.tenant_id AND t.type = 'consume' +), 0); + +-- ── 五、mdl_models:研发厂商字段 ── +-- 注意与 chn_channels.type(渠道供应商/托管方)是两个维度:研发厂商指模型的开发公司, +-- 例如 Bedrock 上的 Claude 厂商仍是 anthropic,硅基流动上的 Qwen 厂商仍是 alibaba。 +-- +goose StatementBegin +ALTER TABLE mdl_models + ADD COLUMN vendor VARCHAR(30) NOT NULL DEFAULT ''; +-- +goose StatementEnd + +COMMENT ON COLUMN mdl_models.vendor IS '研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类'; + +-- 存量回填:按 model_id 前缀推断厂商(仅回填 vendor 为空的行,幂等可重跑;推断不准可在管理后台修正) +UPDATE mdl_models SET vendor = 'openai' WHERE vendor = '' AND (model_id LIKE 'gpt-%' OR model_id LIKE 'chatgpt-%' OR model_id LIKE 'o1%' OR model_id LIKE 'o3%' OR model_id LIKE 'o4%' OR model_id LIKE 'codex-%' OR model_id LIKE 'sora-%' OR model_id LIKE 'dall-e-%' OR model_id LIKE 'gpt-image%' OR model_id LIKE 'whisper-%' OR model_id LIKE 'tts-%' OR model_id LIKE 'text-embedding-%'); +UPDATE mdl_models SET vendor = 'anthropic' WHERE vendor = '' AND model_id LIKE 'claude-%'; +UPDATE mdl_models SET vendor = 'google' WHERE vendor = '' AND (model_id LIKE 'gemini-%' OR model_id LIKE 'veo-%' OR model_id LIKE 'imagen-%'); +UPDATE mdl_models SET vendor = 'xai' WHERE vendor = '' AND model_id LIKE 'grok-%'; +UPDATE mdl_models SET vendor = 'mistral' WHERE vendor = '' AND (model_id LIKE 'mistral-%' OR model_id LIKE 'codestral-%' OR model_id LIKE 'pixtral-%' OR model_id LIKE 'magistral-%'); +UPDATE mdl_models SET vendor = 'cohere' WHERE vendor = '' AND (model_id LIKE 'command-%' OR model_id LIKE 'embed-%' OR model_id LIKE 'rerank-%'); +UPDATE mdl_models SET vendor = 'meta' WHERE vendor = '' AND model_id LIKE 'llama-%'; +UPDATE mdl_models SET vendor = 'alibaba' WHERE vendor = '' AND (model_id LIKE 'qwen%' OR model_id LIKE 'qwq%' OR model_id LIKE 'qvq%'); +UPDATE mdl_models SET vendor = 'bytedance' WHERE vendor = '' AND (model_id LIKE 'doubao-%' OR model_id LIKE 'seedream-%' OR model_id LIKE 'seedance-%' OR model_id LIKE 'jimeng-%'); +UPDATE mdl_models SET vendor = 'deepseek' WHERE vendor = '' AND model_id LIKE 'deepseek-%'; +UPDATE mdl_models SET vendor = 'zhipu' WHERE vendor = '' AND model_id LIKE 'glm-%'; +UPDATE mdl_models SET vendor = 'moonshot' WHERE vendor = '' AND (model_id LIKE 'kimi-%' OR model_id LIKE 'moonshot-%'); +UPDATE mdl_models SET vendor = 'minimax' WHERE vendor = '' AND (model_id LIKE 'minimax-%' OR model_id LIKE 'abab-%'); +UPDATE mdl_models SET vendor = 'baidu' WHERE vendor = '' AND (model_id LIKE 'ernie-%' OR model_id LIKE 'wenxin-%'); +UPDATE mdl_models SET vendor = 'tencent' WHERE vendor = '' AND model_id LIKE 'hunyuan-%'; +UPDATE mdl_models SET vendor = 'xunfei' WHERE vendor = '' AND model_id LIKE 'spark-%'; +UPDATE mdl_models SET vendor = 'kuaishou' WHERE vendor = '' AND model_id LIKE 'kling-%'; +UPDATE mdl_models SET vendor = 'midjourney' WHERE vendor = '' AND (model_id LIKE 'mj_%' OR model_id LIKE 'midjourney-%'); +UPDATE mdl_models SET vendor = 'suno' WHERE vendor = '' AND model_id LIKE 'suno-%'; + +-- +goose Down +-- 逆序回滚:厂商 → 租户/钱包 → 用量时长 → 定价旧结构重建 → 视频软删除列。 + +-- ── 五、mdl_models:移除厂商字段 ── +-- +goose StatementBegin +ALTER TABLE mdl_models + DROP COLUMN IF EXISTS vendor; +-- +goose StatementEnd + +-- ── 四、tnt_tenants / bil_wallets ── +-- +goose StatementBegin +ALTER TABLE tnt_tenants + DROP COLUMN IF EXISTS tags, + DROP COLUMN IF EXISTS remark; +ALTER TABLE bil_wallets + DROP COLUMN IF EXISTS total_consumed; +-- +goose StatementEnd + +-- ── 三、bil_usage_logs ── +-- +goose StatementBegin +ALTER TABLE bil_usage_logs DROP COLUMN IF EXISTS duration_seconds; +-- +goose StatementEnd + +-- ── 二、mdl_pricing:从 pricing JSONB 无损反向重建旧结构 ── +-- JSON 自迁移起为唯一被写的真相,覆盖其后的全部改价。 +-- special 反向还原:scheme 模型收回 per_second(旧代码完全兼容形态); +-- 迁移后通过方案编辑器新保存的模型 scheme 非空,同样被覆盖回 per_second。 +UPDATE mdl_pricing + SET billing_mode = 'per_second' + WHERE billing_mode = 'special' + AND pricing->>'scheme' IS NOT NULL + AND pricing->>'scheme' <> ''; + +COMMENT ON COLUMN mdl_pricing.billing_mode IS '计费模式:token=按量;per_request=按次;tiered=阶梯;per_second=按秒(视频)'; + +-- +goose StatementBegin +ALTER TABLE mdl_pricing DROP CONSTRAINT IF EXISTS uk_mdl_pricing_model; +ALTER TABLE mdl_pricing + ADD COLUMN IF NOT EXISTS min_tokens BIGINT DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS max_tokens BIGINT, + ADD COLUMN IF NOT EXISTS input_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS output_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS per_request_price NUMERIC(20,10), + ADD COLUMN IF NOT EXISTS cache_read_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS cache_creation_price NUMERIC(20,10) DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS time_segments JSONB; +-- +goose StatementEnd + +-- 锚点行标量列从 JSON 回写(tiered 取首档价,与旧行为一致) +-- +goose StatementBegin +UPDATE mdl_pricing SET + input_price = COALESCE((pricing->>'input_price')::numeric, (pricing->'tiers'->0->>'input_price')::numeric, 0), + output_price = COALESCE((pricing->>'output_price')::numeric, (pricing->'tiers'->0->>'output_price')::numeric, 0), + per_request_price = (pricing->>'price')::numeric, + cache_read_price = COALESCE((pricing->>'cache_read_price')::numeric, 0), + cache_creation_price = COALESCE((pricing->>'cache_creation_price')::numeric, 0), + time_segments = pricing->'time_segments' +WHERE pricing IS NOT NULL; +-- +goose StatementEnd + +-- tiered 档位行从 JSON tiers 数组展开重建(跳过首档——首档即锚点行 min_tokens=0) +-- +goose StatementBegin +INSERT INTO mdl_pricing (model_id, billing_mode, min_tokens, max_tokens, input_price, output_price, created_at, updated_at) +SELECT p.model_id, + p.billing_mode, + (t.tier->>'min_tokens')::bigint, + NULLIF(t.tier->>'max_tokens', '')::bigint, + COALESCE((t.tier->>'input_price')::numeric, 0), + COALESCE((t.tier->>'output_price')::numeric, 0), + now(), + now() +FROM mdl_pricing p + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.pricing->'tiers', '[]'::jsonb)) + WITH ORDINALITY AS t(tier, ord) +WHERE p.billing_mode = 'tiered' + AND p.pricing IS NOT NULL + AND (t.tier->>'min_tokens')::bigint <> 0 + AND t.ord > 1; +-- +goose StatementEnd + +-- JSON 列最后删除(上方旧结构重建均需读取 pricing) +-- +goose StatementBegin +ALTER TABLE mdl_pricing + DROP COLUMN IF EXISTS pricing, + DROP COLUMN IF EXISTS official_pricing; +-- +goose StatementEnd + +-- ── 一、tsk_model_tasks ── +-- +goose StatementBegin +ALTER TABLE tsk_model_tasks + DROP COLUMN IF EXISTS deleted, + DROP COLUMN IF EXISTS deleted_at; +-- +goose StatementEnd diff --git a/relay/channel/ali/converter_test.go b/relay/channel/ali/converter_test.go index 5d835d5..5283cfa 100644 --- a/relay/channel/ali/converter_test.go +++ b/relay/channel/ali/converter_test.go @@ -127,8 +127,8 @@ func TestConvertRequest_ThinkingBudgetStripping(t *testing.T) { // 不影响既有的 top_p 裁剪行为。 func TestConvertRequest_TopPCappingRegression(t *testing.T) { body := map[string]json.RawMessage{ - "model": json.RawMessage(`"qwen-plus"`), - "top_p": json.RawMessage(`1.5`), + "model": json.RawMessage(`"qwen-plus"`), + "top_p": json.RawMessage(`1.5`), "thinking_budget": json.RawMessage(`128`), } requestBody, _ := json.Marshal(body) diff --git a/relay/channel/volcengine/convert_test.go b/relay/channel/volcengine/convert_test.go index 337e921..e5de359 100644 --- a/relay/channel/volcengine/convert_test.go +++ b/relay/channel/volcengine/convert_test.go @@ -15,10 +15,10 @@ func TestOpenaiAdaptorStreamOptionsInjection(t *testing.T) { body := []byte(`{"model":"doubao-pro","messages":[{"role":"user","content":"hi"}],"stream":true}`) tests := []struct { - name string - mode constant.RelayMode - isStream bool - wantInj bool + name string + mode constant.RelayMode + isStream bool + wantInj bool }{ {"流式 chat 注入 stream_options", constant.RelayModeChatCompletions, true, true}, {"非流式 chat 不注入", constant.RelayModeChatCompletions, false, false}, diff --git a/relay/common/debug_session.go b/relay/common/debug_session.go index 14efa63..3b516f4 100644 --- a/relay/common/debug_session.go +++ b/relay/common/debug_session.go @@ -66,6 +66,24 @@ func (s *DebugSession) BeginAttempt(selection *ChannelSelection, modelName, rela } } +// BeginTaskAttempt 为异步任务提交创建捕获上下文。任务提交为单次尝试(无重试循环), +// isStream 恒 false,渠道元信息取 ChannelMeta(任务链路经 selectTaskChannel 物化, +// 无 ChannelSelection)。上游请求经适配器 DoRequest(ctx) 携带的捕获器镜像段2/3。 +func (s *DebugSession) BeginTaskAttempt(meta *ChannelMeta, modelName, relayModeStr string, retryIndex int) *DebugAttempt { + return &DebugAttempt{ + session: s, + Capture: &DebugAttemptCapture{}, + channelID: meta.ChannelID, + channelName: meta.ChannelName, + channelType: meta.ChannelType, + modelName: modelName, + upstreamModel: meta.UpstreamModelName, + relayMode: relayModeStr, + isStream: false, + retryIndex: retryIndex, + } +} + // DebugAttempt 单次尝试的调试上下文:会话引用 + 渠道元数据 + 传输层捕获器。 // Capture 经 WithDebugAttempt 挂入 ctx,由 DebugRoundTripper 填充段2/段3。 type DebugAttempt struct { diff --git a/relay/common/debug_session_test.go b/relay/common/debug_session_test.go index 34dd584..f7d4e8c 100644 --- a/relay/common/debug_session_test.go +++ b/relay/common/debug_session_test.go @@ -116,6 +116,50 @@ func TestDebugSessionNilSafe(t *testing.T) { a.MarkFinal(nil) // 不应 panic } +// 任务版构造:ChannelMeta 字段映射、isStream 恒 false; +// MarkFinal + FinalizeAndSubmit 后提交带段4 的最终记录(任务提交单次尝试形态) +func TestBeginTaskAttempt(t *testing.T) { + var records []*DebugLogRecord + origHook := SubmitDebugLog + SubmitDebugLog = func(ctx context.Context, r *DebugLogRecord) { records = append(records, r) } + defer func() { SubmitDebugLog = origHook }() + + s := NewDebugSession("req-task", 1, 2, 3, "/v1/videos") + meta := &ChannelMeta{ChannelID: 7, ChannelName: "ch7", ChannelType: int(constant.ProviderKling), UpstreamModelName: "kling-v2"} + + a := s.BeginTaskAttempt(meta, "kling-v2-master", "videos", 0) + if a.isStream { + t.Error("任务尝试 isStream 应恒为 false") + } + + // 段4 writer(任务提交响应,非流式 JSON) + rec := httptest.NewRecorder() + dw := NewDebugClientWriter(rec) + s.SetClientWriter(dw) + dw.WriteHeader(200) + _, _ = dw.Write([]byte(`{"id":"video_x"}`)) + + a.MarkFinal(nil) + s.FinalizeAndSubmit(88, 0) + + if len(records) != 1 { + t.Fatalf("应提交 1 条记录,实际 %d", len(records)) + } + r := records[0] + if r.ChannelID != 7 || r.ChannelName != "ch7" || r.ChannelType != int(constant.ProviderKling) { + t.Errorf("渠道元数据映射错误: %+v", r) + } + if r.UpstreamModel != "kling-v2" || r.ModelName != "kling-v2-master" || r.RelayMode != "videos" { + t.Errorf("模型/协议元数据错误: %+v", r) + } + if !r.IsFinal || r.IsStream { + t.Errorf("任务提交应为 is_final=true 且非流式: %+v", r) + } + if string(r.ClientRespBody) != `{"id":"video_x"}` || r.ClientStatusCode != 200 { + t.Errorf("段4 错误: %q %d", r.ClientRespBody, r.ClientStatusCode) + } +} + // 调试目标过滤:AND 组合,0 = 不限 func TestDebugTargetMatch(t *testing.T) { s := ChannelSettings{DebugLogEnabled: true} diff --git a/relay/common/provider.go b/relay/common/provider.go index ebe243c..3c8a36f 100644 --- a/relay/common/provider.go +++ b/relay/common/provider.go @@ -170,6 +170,9 @@ type UsageRecord struct { // 其他 token ImageOutputTokens int // 图像输出 token(DALL-E) + // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 0 + DurationSeconds int + // 请求元数据 RequestedModel string // 用户请求的模型名 UpstreamModel string // 上游实际模型名 diff --git a/relay/common/task_adaptor.go b/relay/common/task_adaptor.go index 387a869..64c162d 100644 --- a/relay/common/task_adaptor.go +++ b/relay/common/task_adaptor.go @@ -15,12 +15,16 @@ type TaskAdaptor interface { // ValidateRequest 校验请求参数 ValidateRequest(ctx context.Context, info *RelayInfo, body []byte) *TaskError - // EstimateBilling 估算任务费用(提交前) - // 返回计费比率 map,如 {"duration_ratio": 1.5, "resolution_ratio": 2.0} - EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]float64 + // EstimateBilling 估算任务费用(提交前)。 + // 返回计费上下文 map,两类键: + // - float64 乘数值:如 {"video_input": 0.6, "quality": 3.5},引擎按序连乘; + // - string 规格事实值:如 {"spec.duration": 8, "spec.resolution": "720p"}, + // per_second 计费模式据此查定价矩阵(spec.duration 为 float64 秒数,spec.resolution 为规格原值)。 + // 旧键 duration/resolution 保留 token 伪装估算路径使用,语义随各 adaptor 存量口径,勿混用。 + EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]any // AdjustBillingOnSubmit 提交后根据上游确认参数调整计费 - AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]float64 + AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]any // BuildRequestURL 构建上游请求 URL BuildRequestURL(info *RelayInfo) (string, error) @@ -37,8 +41,9 @@ type TaskAdaptor interface { // DoResponse 解析上游提交响应,返回上游任务 ID 和任务数据 DoResponse(ctx context.Context, resp *http.Response, info *RelayInfo) (upstreamTaskID string, taskData []byte, taskErr *TaskError) - // FetchTask 查询上游任务状态 - FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) + // FetchTask 查询上游任务状态。ctx 供超时控制与渠道调试日志捕获器传递 + //(轮询侧经 WithDebugAttempt 注入,未开启调试时透传无额外开销) + FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) // ParseTaskResult 解析上游任务查询结果 ParseTaskResult(body []byte) (*TaskInfo, error) @@ -60,12 +65,27 @@ type TaskInfo struct { SubTasks []SubTask // 子任务结果(如多图/多视频场景) ActualCost float64 // 上游返回的实际费用(0 表示未提供,使用预扣金额) + // 素材计量(部分任务上游 usage 返回,如 MiniMax H3 的 input_seconds/input_image_count): + // 「预扣时未知、生成后官方返回」的计费依据,素材计费方案(BillingScheme.SettleTaskCost)的结算输入。 + // nil = 上游未提供 + MaterialUsage *TaskMaterialUsage + // Token 用量(部分异步任务上游会返回,如火山引擎视频模型) PromptTokens int CompletionTokens int TotalTokens int } +// TaskMaterialUsage 上游任务 usage 中的素材计量。 +// 与 TaskInfo.TotalTokens 同级的结算信号:链接素材(视频/音频)的时长在提交时不可知, +// 以生成成功后官方返回为准;各字段零值表示该计量上游未返回(方案自行回退)。 +type TaskMaterialUsage struct { + InputVideoSeconds float64 // 输入视频时长(秒) + InputImageCount int // 输入图片张数 + InputAudioSeconds float64 // 输入音频时长(秒) + OutputSeconds float64 // 输出(生成)时长(秒) +} + // TaskStatusEnum 任务状态枚举 type TaskStatusEnum string diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index de7221f..051329d 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -8,10 +8,14 @@ import ( ) // TaskBillingProvider 异步任务计费接口 -// 异步任务计费基于时长/分辨率等比率,非 token 数 +// 异步任务计费基于时长/分辨率等比率,非 token 数。 +// ratios 键约定见 TaskAdaptor.EstimateBilling(float64 乘数 + spec.* 规格事实值) type TaskBillingProvider interface { - // EstimateTaskCost 估算任务费用 - EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error) + // EstimateTaskCost 估算任务费用。 + // taskBody 为归一化后的任务体(转换后通用格式):定价配置了参数倍率(param_multipliers)时, + // 实现方在函数内求值并把命中倍率就地注入 ratios(param_multiplier / param_matched 键, + // 随任务持久化保证预扣/结算/重算三口径同源);nil body = 不求值。 + EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error) // PreDeductTask 预扣任务费用 PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error) @@ -30,7 +34,7 @@ type TaskBillingProvider interface { // SettleTaskSuccess 任务成功结算(含计费快照)。billAt 为任务受理时刻(时段定价按该时刻评估, // 异步任务结算滞后数分钟~小时,按结算时刻会丢失受理时的时段价) - SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*SettlementResult, error) + SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*SettlementResult, error) // SettleTaskFailed 任务失败退还预扣 SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error @@ -43,6 +47,12 @@ type TaskBillingProvider interface { // RecalculateByTokens 根据上游返回的 total_tokens 重算费用(billAt 为任务受理时刻,时段定价用) // totalTokens: 上游返回的 token 计费单位 - // ratios: 提交时保存的计费比率(如 video_input 折扣) - RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error) + // ratios: 提交时保存的计费上下文(如 video_input 折扣) + RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error) + + // RecalculateByMaterials 按上游 usage 的素材计量重算任务费用(素材计费方案的结算依据)。 + // 仅模型配置了素材计费方案(pricing JSONB scheme)时适用:ok=true 返回按方案结算口径 + // 重算的最终费用;未配置方案、取价失败或 usage 为 nil 时 ok=false,调用方保持既有结算来源 + // (预扣金额 / token 重算)。billAt 为任务受理时刻(时段定价用),ratios 为提交时持久化的计费上下文 + RecalculateByMaterials(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, usage *TaskMaterialUsage, billAt time.Time) (cost decimal.Decimal, ok bool, err error) } diff --git a/relay/common/task_provider.go b/relay/common/task_provider.go index 29c7770..405277f 100644 --- a/relay/common/task_provider.go +++ b/relay/common/task_provider.go @@ -25,6 +25,10 @@ type TaskDataProvider interface { // GetTaskByPublicIDAndUser 根据公开任务 ID + 用户 ID 查询(客户端查询) GetTaskByPublicIDAndUser(ctx context.Context, publicTaskID string, userID int64, tenantID int64) (*AsyncTask, error) + // SoftDeleteTask 软删除任务(DELETE /v1/videos/{id}):置 deleted 标记, + // 计费与审计记录保留,后续客户端查询按不存在处理 + SoftDeleteTask(ctx context.Context, task *AsyncTask) error + // GetNonTerminalTasks 获取所有非终态任务(轮询用) GetNonTerminalTasks(ctx context.Context, limit int) ([]*AsyncTask, error) @@ -74,6 +78,10 @@ type AsyncTask struct { FinishTime *time.Time `json:"finish_time,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // 软删除标记(DELETE /v1/videos/{id}):true 后客户端查询/下载按 404 处理 + Deleted bool `json:"deleted,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` } // ChannelBasicInfo 渠道基本信息(轮询时查询) diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 46e1ee4..3c75711 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -31,6 +31,8 @@ const ( RelayModeMjFetch // GET /mj/task/:id/fetch RelayModeMjImage // GET /mj/image/:id RelayModeResponsesCompact // POST /v1/responses/compact + RelayModeVideos // OpenAI Videos 协议 /v1/videos(创建/查询/删除/内容下载) + RelayModeMiniMaxVideo // MiniMax 官方协议 /v2/video_generation(H3 v2 提交/查询/删除) ) // Path2RelayMode 根据请求路径判断 RelayMode @@ -101,6 +103,8 @@ func Path2RelayMode(path string) RelayMode { result = RelayModeVideoGenerations case strings.Contains(path, "/video/generations/"): result = RelayModeVideoFetch + case strings.HasSuffix(path, "/videos"), strings.Contains(path, "/videos/"): + result = RelayModeVideos case strings.HasSuffix(path, "/moderations"): result = RelayModeModerations default: @@ -151,6 +155,10 @@ func (m RelayMode) String() string { return "moderations" case RelayModeImagesEdits: return "images_edits" + case RelayModeVideos: + return "videos" + case RelayModeMiniMaxVideo: + return "minimax_video" case RelayModeMjSubmit: return "mj_submit" case RelayModeMjFetch: diff --git a/relay/constant/task.go b/relay/constant/task.go index ebea0cf..afbf577 100644 --- a/relay/constant/task.go +++ b/relay/constant/task.go @@ -13,6 +13,7 @@ const ( TaskPlatformVolcengine TaskPlatform = "volcengine" TaskPlatformAli TaskPlatform = "ali" TaskPlatformGemini TaskPlatform = "gemini" + TaskPlatformMiniMax TaskPlatform = "minimax" // TaskPlatformSyncImage 同步图片厂商异步化专用平台标记。 // 注意:故意不加入 ProviderTypeToTaskPlatform —— 它由 sync_image worker 池执行, @@ -94,6 +95,8 @@ func ProviderTypeToTaskPlatform(p ProviderType) (TaskPlatform, bool) { return TaskPlatformAli, true case ProviderGemini: return TaskPlatformGemini, true + case ProviderMiniMax: + return TaskPlatformMiniMax, true default: return "", false } diff --git a/relay/handler/minimax_video.go b/relay/handler/minimax_video.go new file mode 100644 index 0000000..8191ddb --- /dev/null +++ b/relay/handler/minimax_video.go @@ -0,0 +1,329 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + taskminimax "github.com/qianfree/team-api/relay/taskchannel/minimax" +) + +// MiniMax 官方视频协议(/v2)核心处理:查询回放、任务取消。 +// 协议规格见 docs/modeldocs/minimax/video/(02-端点参考.md、04-响应对象.md)。 +// 状态机:queued/running/succeeded/failed/cancelled(网关不主动产生 cancelled 任务; +// 上游任务被取消时经轮询映射为 FAILURE,retrieve 回放为 failed)。 +// 错误用 OpenAI 风格 OaiError(writeTaskError 输出同构,官方 v2 即此风格)。 +// +// DELETE 端点语义(有意与官方分歧): +// - 仅支持取消 queued 任务(取消结果以远程返回为准,上游确认才生效); +// - 不支持删除任务记录(官方对终态任务的 delete 记录能力网关不提供,软删也不做), +// 网关保留任务数据用于计费与审计; +// - 任务终态后 content.url 不再回源刷新(回放缓存的上游直链,有时效)。 + +// minimaxStatusFromTask 平台任务状态 → 官方状态机映射 +func minimaxStatusFromTask(status string) string { + switch status { + case "IN_PROGRESS": + return "running" + case "SUCCESS": + return "succeeded" + case "FAILURE": + return "failed" + default: + // NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued + return "queued" + } +} + +// isMiniMaxTerminalTask 判断是否终态(succeeded/failed 对应的平台态) +func isMiniMaxTerminalTask(status string) bool { + return status == string(common.TaskStatusSuccess) || status == string(common.TaskStatusFailure) +} + +// parseMiniMaxUpstreamTask 解析 task.Data 中缓存的上游单任务查询响应。 +// 提交后首拍轮询前 Data 是提交响应 {"task_id":...} 形态,解析不出 task 时返回 nil(静默降级)。 +func parseMiniMaxUpstreamTask(data []byte) *taskminimax.VideoTask { + if len(data) == 0 { + return nil + } + var resp taskminimax.TaskQueryResponse + if err := json.Unmarshal(data, &resp); err != nil || resp.Task == nil { + return nil + } + return resp.Task +} + +// buildMiniMaxVideoTask 平台任务 → 官方 VideoTask 对象。 +// 任务记录是 id/status/model/时间戳的真相源;usage/resolution/duration/ratio 从 +// 缓存的上游查询响应(task.Data)富集,不可解析时缺省不输出。 +func buildMiniMaxVideoTask(task *common.AsyncTask) *taskminimax.VideoTask { + obj := &taskminimax.VideoTask{ + ID: task.PublicTaskID, + Model: task.ModelName, + Status: minimaxStatusFromTask(task.Status), + CreatedAt: task.CreatedAt.Unix(), + UpdatedAt: task.UpdatedAt.Unix(), + TaskType: "generation", + Modality: "video", + } + + if obj.Status == "failed" { + message := task.FailReason + if message == "" { + message = "video generation failed" + } + obj.Error = &taskminimax.VideoTaskError{Code: "video_generation_failed", Message: message} + } + if task.ResultURL != "" { + obj.Content = &taskminimax.VideoTaskContent{URL: task.ResultURL} + } + + if upstream := parseMiniMaxUpstreamTask(task.Data); upstream != nil { + if upstream.Usage != nil { + obj.Usage = upstream.Usage + } + if upstream.Resolution != "" { + obj.Resolution = upstream.Resolution + } + if upstream.Duration > 0 { + obj.Duration = upstream.Duration + } + if upstream.Ratio != "" { + obj.Ratio = upstream.Ratio + } + // 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底 + if obj.Content == nil && upstream.Content != nil { + obj.Content = upstream.Content + } + } + + return obj +} + +// writeMiniMaxSubmitResponse 提交成功响应:官方 VideoGenerationV2Resp 形态 +func writeMiniMaxSubmitResponse(w http.ResponseWriter, publicTaskID string) { + writeJSON(w, http.StatusOK, map[string]any{"task_id": publicTaskID}) +} + +// fetchMiniMaxTaskById 查询任务并做租户双键隔离 + 平台门禁。 +// 平台门禁:/v2 端点只服务 minimax 平台任务,防止异构平台任务 ID 被塞进官方响应结构回放。 +func fetchMiniMaxTaskById( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) (*common.AsyncTask, bool) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, taskID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "MiniMaxVideo: query task failed, taskID=%s, err=%v", taskID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return nil, false + } + if task == nil || task.Platform != string(constant.TaskPlatformMiniMax) { + writeTaskError(rc.Writer, http.StatusNotFound, "No MiniMax video task found with id '"+taskID+"'.", "") + return nil, false + } + return task, true +} + +// HandleMiniMaxVideoRetrieve GET /v2/query/video_generation/{task_id} 查询管线。 +// 响应为官方 GetVideoGenerationV2Resp 形态({"task": {...}} 外层包装)。 +func HandleMiniMaxVideoRetrieve( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + writeJSON(rc.Writer, http.StatusOK, taskminimax.TaskQueryResponse{Task: buildMiniMaxVideoTask(task)}) +} + +// HandleMiniMaxVideoCancel DELETE /v2/video_generation/{task_id} 取消管线。 +// 仅支持取消排队中的任务,不支持删除任务记录(含软删,网关保留计费与审计数据)。 +// +// 取消结果以远程返回为准:调用上游 DELETE,上游确认 action=cancelled 才向客户端 +// 返回成功;上游拒绝(如任务已进入 running)时原样透传上游错误。 +// 本地任务状态不在此处改写——轮询循环下一拍从上游查询到 cancelled → FAILURE → 退款, +// 预扣退款与活跃任务计数等记账全部走既有轮询链路,避免在本端点重复实现结算。 +func HandleMiniMaxVideoCancel( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + + // 终态任务既不可取消也不可删除(删除能力网关不提供) + if isMiniMaxTerminalTask(task.Status) { + writeTaskError(rc.Writer, http.StatusBadRequest, + "task is already completed; only queued tasks can be cancelled", "task_not_cancellable") + return + } + // 运行中的任务上游不允许取消,前置拦截省一次上游调用(本地状态若滞后,由上游裁决兜底) + if task.Status == string(common.TaskStatusInProgress) { + writeTaskError(rc.Writer, http.StatusBadRequest, "running tasks cannot be cancelled", "task_not_cancellable") + return + } + // v1(Hailuo 系列)上游没有取消端点 + upstreamModel := task.UpstreamModel + if upstreamModel == "" { + upstreamModel = task.ModelName + } + if !taskminimax.IsV2Model(upstreamModel) { + writeTaskError(rc.Writer, http.StatusBadRequest, + "v1 video tasks cannot be cancelled; the MiniMax v1 API has no cancel endpoint", "task_not_cancellable") + return + } + + // 提取上游任务 ID + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + } + if err := json.Unmarshal(task.PrivateData, &pd); err != nil || pd.UpstreamTaskID == "" { + g.Log().Errorf(ctx, "MiniMaxVideoCancel: invalid private data, taskID=%s, err=%v", taskID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid task data", "") + return + } + + // 取渠道信息(上游地址与密钥),对齐 HandleMjImageProxy 的任务→渠道回查模式 + channel, err := dataProvider.GetChannelByID(ctx, task.ChannelID) + if err != nil || channel == nil { + g.Log().Errorf(ctx, "MiniMaxVideoCancel: channel not found, channelID=%d, err=%v", task.ChannelID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "channel not found", "") + return + } + + result, taskErr := taskminimax.CancelTask(channel.BaseURL, channel.ApiKey, pd.UpstreamTaskID, parseMiniMaxUseProxy(channel.Settings)) + if taskErr != nil { + // 远程取消失败(含上游判定运行中/已结束不可取消):以远程结论为准 + g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream rejected, taskID=%s, status=%d, message=%q", taskID, taskErr.StatusCode, taskErr.Message) + writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) + return + } + // 防御竞态:本地检查后任务已在上游完成时,上游会执行删记录(action=deleted)而非取消 + if result.Action != "cancelled" { + g.Log().Infof(ctx, "MiniMaxVideoCancel: upstream action=%s (not cancelled), taskID=%s", result.Action, taskID) + writeTaskError(rc.Writer, http.StatusBadRequest, + "task is no longer queued; only queued tasks can be cancelled", "task_not_cancellable") + return + } + + g.Log().Infof(ctx, "MiniMaxVideoCancel: task cancelled upstream, taskID=%s, upstreamTaskID=%s, tenant=%d, user=%d", + taskID, pd.UpstreamTaskID, rc.TenantID, rc.UserID) + + // 官方 DeleteVideoGenerationV2Resp 形态(取消分支) + writeJSON(rc.Writer, http.StatusOK, map[string]any{ + "task_id": task.PublicTaskID, + "action": "cancelled", + "status": "cancelled", + }) +} + +// parseMiniMaxUseProxy 从渠道 settings JSONB 解析代理开关 +func parseMiniMaxUseProxy(settings json.RawMessage) bool { + var s struct { + UseProxy bool `json:"use_proxy"` + } + if len(settings) > 0 { + _ = json.Unmarshal(settings, &s) + } + return s.UseProxy +} + +// ==================== v1(Hailuo 系列)官方协议 ==================== + +// minimaxV1StatusFromTask 平台任务状态 → v1 官方状态机映射 +// (Preparing/Queueing/Processing/Success/Fail) +func minimaxV1StatusFromTask(status string) string { + switch status { + case "QUEUED": + return "Queueing" + case "IN_PROGRESS": + return "Processing" + case "SUCCESS": + return "Success" + case "FAILURE": + return "Fail" + default: + // NOT_START / SUBMITTED 及未知早期状态 + return "Preparing" + } +} + +// writeMiniMaxV1SubmitResponse v1 提交成功响应:官方 VideoGenerationResp 形态 +// (含 base_resp 成功信封) +func writeMiniMaxV1SubmitResponse(w http.ResponseWriter, publicTaskID string) { + writeJSON(w, http.StatusOK, map[string]any{ + "task_id": publicTaskID, + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + }) +} + +// parseMiniMaxV1UpstreamData 解析 task.Data 中缓存的上游 v1 查询响应(含 FetchTask +// 二跳合并的 download_url)。非 v1 形态(v2 的 {"task":...} 包装或提交响应)返回 nil。 +func parseMiniMaxV1UpstreamData(data []byte) map[string]any { + if len(data) == 0 { + return nil + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil + } + if _, hasTask := m["task"]; hasTask { + return nil // v2 形态 + } + if _, hasStatus := m["status"]; !hasStatus { + return nil // 提交响应等非查询形态 + } + return m +} + +// HandleMiniMaxVideoV1Retrieve GET /v1/query/video_generation?task_id= 查询管线。 +// 响应为官方 QueryVideoGenerationTaskResp 形态;附加 download_url(FetchTask 二跳解析的 +// 限时直链,官方查询响应无此字段,属网关增补)与 error(失败原因)两个增补字段, +// 官方 SDK 忽略未知字段,不影响兼容。 +func HandleMiniMaxVideoV1Retrieve( + ctx context.Context, + taskID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, ok := fetchMiniMaxTaskById(ctx, taskID, rc, dataProvider) + if !ok { + return + } + + resp := map[string]any{ + "task_id": task.PublicTaskID, + "status": minimaxV1StatusFromTask(task.Status), + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + } + if task.Status == string(common.TaskStatusFailure) && task.FailReason != "" { + resp["error"] = task.FailReason + } + + // 从缓存的上游查询响应富集 file_id/video_width/video_height/download_url + if upstream := parseMiniMaxV1UpstreamData(task.Data); upstream != nil { + for _, key := range []string{"file_id", "video_width", "video_height", "download_url"} { + if v, has := upstream[key]; has { + resp[key] = v + } + } + } + // 成品直链以任务记录的 ResultURL 为准(轮询结算时写入),上游回放仅兜底 + if task.ResultURL != "" { + resp["download_url"] = task.ResultURL + } + + writeJSON(rc.Writer, http.StatusOK, resp) +} diff --git a/relay/handler/minimax_video_convert.go b/relay/handler/minimax_video_convert.go new file mode 100644 index 0000000..fb45845 --- /dev/null +++ b/relay/handler/minimax_video_convert.go @@ -0,0 +1,356 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// MiniMax 官方视频协议(POST /v2/video_generation)入站转换层。 +// 原生请求体解析/校验后转成任务框架通用请求体,复用任务提交管线 +// (selectTaskChannel 渠道调度 + HandleTaskSubmit 计费/提交/落库)。 +// +// 转换产物在保留原生字段(content[]/resolution/duration/ratio/aigc_watermark)之外, +// 双写通用词汇(prompt/seconds/images[]/metadata)——参数倍率规则的匹配源是归一化 +// 任务体(「规则跟语义走不跟入口协议走」,见 internal/logic/billing/param_multiplier.go), +// 双写保证按通用路径配置的倍率规则对官方协议入站同样生效。 + +// minimaxVideoProtocol TaskRelayContext.Protocol 的 MiniMax 官方视频协议标识 +const minimaxVideoProtocol = "minimax_native" + +// minimaxVideoCreateRequest MiniMax 官方创建请求的归一形态(JSON 解析后的产物) +type minimaxVideoCreateRequest struct { + Model string + Prompt string // content[] 中首个非空 text 项(派生,供计费倍率匹配与审计) + Content []any + Resolution string + Duration int + Ratio string + AigcWatermark *bool +} + +// minimaxVideoRequestEcho 提交时落 PrivateData.request_echo 的请求回显, +// retrieve 时用于回显 prompt/resolution/duration/ratio(任务未轮询到上游数据前的兜底) +type minimaxVideoRequestEcho struct { + Prompt string `json:"prompt"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` + Ratio string `json:"ratio,omitempty"` +} + +// jsonMiniMaxVideoCreateRequest JSON 编码的官方创建请求结构。 +// duration 兼容 number 与 string;content 保留 RawMessage 以校验数组形态。 +type jsonMiniMaxVideoCreateRequest struct { + Model string `json:"model"` + Content json.RawMessage `json:"content"` + Resolution string `json:"resolution"` + Duration json.RawMessage `json:"duration"` + Ratio string `json:"ratio"` + AigcWatermark *bool `json:"aigc_watermark"` +} + +// ParseMiniMaxVideoCreateRequest 解析官方创建请求。 +// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。 +func ParseMiniMaxVideoCreateRequest(body []byte) (*minimaxVideoCreateRequest, *common.TaskError) { + var req jsonMiniMaxVideoCreateRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + out := &minimaxVideoCreateRequest{ + Model: strings.TrimSpace(req.Model), + Resolution: strings.TrimSpace(req.Resolution), + Ratio: strings.TrimSpace(req.Ratio), + AigcWatermark: req.AigcWatermark, + } + + // content 必须是数组 + if len(req.Content) > 0 && string(req.Content) != "null" { + var content []any + if err := json.Unmarshal(req.Content, &content); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid content: expected an array"} + } + out.Content = content + } + + // duration 兼容 number(官方 schema)与 string(宽松兼容) + duration, terr := parseMiniMaxDuration(req.Duration) + if terr != nil { + return nil, terr + } + out.Duration = duration + + // 派生 prompt:content[] 中首个非空 text 项 + for _, item := range out.Content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + out.Prompt = text + break + } + } + } + + return out, nil +} + +// parseMiniMaxDuration 解析 duration 字段(integer 或数字字符串,须为正数) +func parseMiniMaxDuration(raw json.RawMessage) (int, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return 0, nil + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + if d, err := strconv.Atoi(n.String()); err == nil { + return d, nil + } + // 小数秒向下取整(官方档位均为整数,宽松处理) + if f, err := strconv.ParseFloat(n.String(), 64); err == nil && f > 0 { + return int(f), nil + } + } + return 0, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid duration field: must be a positive integer"} +} + +// ValidateMiniMaxVideoCreateRequest 请求必填校验(对齐官方 schema required: +// model / content(含非空 text 项)/ resolution / duration;数值档位差异交给上游裁决) +func ValidateMiniMaxVideoCreateRequest(req *minimaxVideoCreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if len(req.Content) == 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content is required"} + } + if req.Prompt == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"} + } + if req.Resolution == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "resolution is required"} + } + if req.Duration <= 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "duration is required"} + } + return nil +} + +// BuildMiniMaxVideoTaskBody 把官方创建请求转成任务框架通用请求体(JSON 字节)。 +// callback_url 不进入通用任务体(网关轮询计费,上游回调与网关 ID 体系不兼容)。 +func BuildMiniMaxVideoTaskBody(req *minimaxVideoCreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + "prompt": req.Prompt, + "content": req.Content, + "resolution": req.Resolution, + "seconds": strconv.Itoa(req.Duration), // 通用词汇双写(kling/volcengine 消费同一键) + "duration": req.Duration, + "metadata": map[string]any{ + "resolution": req.Resolution, + "duration": req.Duration, + }, + } + if req.Ratio != "" { + body["ratio"] = req.Ratio + body["metadata"].(map[string]any)["ratio"] = req.Ratio + } + if req.AigcWatermark != nil { + body["aigc_watermark"] = *req.AigcWatermark + } + // images[] 双写:content 中首帧/无 role 的 image_url 项提取 URL(图生视频判定与参数倍率用) + if images := extractMiniMaxFrameImages(req.Content); len(images) > 0 { + body["images"] = images + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal minimax video task body: %w", err) + } + return data, nil +} + +// extractMiniMaxFrameImages 从 content[] 提取图片 URL(role=first_frame 或未标 role 的单图) +func extractMiniMaxFrameImages(content []any) []string { + images := make([]string, 0, 2) + for _, item := range content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t != "image_url" { + continue + } + role, _ := m["role"].(string) + if role != "" && role != "first_frame" { + continue + } + if media, ok := m["image_url"].(map[string]any); ok { + if url, ok := media["url"].(string); ok && url != "" { + images = append(images, url) + } + } + } + return images +} + +// NewMiniMaxRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入, +// 提交时随 PrivateData 落库,retrieve 时在上游数据未就绪前回显) +func NewMiniMaxRequestEcho(req *minimaxVideoCreateRequest) minimaxVideoRequestEcho { + return minimaxVideoRequestEcho{ + Prompt: req.Prompt, + Resolution: req.Resolution, + Duration: req.Duration, + Ratio: req.Ratio, + } +} + +// ==================== v1(Hailuo 系列)官方协议入站 ==================== + +// minimaxVideoV1Protocol TaskRelayContext.Protocol 的 MiniMax v1 官方视频协议标识 +const minimaxVideoV1Protocol = "minimax_native_v1" + +// minimaxVideoV1CreateRequest v1 官方创建请求的归一形态(扁平字段协议,无 content[])。 +// resolution/duration 官方可选(有默认值),零值表示未指定。 +type minimaxVideoV1CreateRequest struct { + Model string + Prompt string + FirstFrameImage string + LastFrameImage string + SubjectReference json.RawMessage + Duration int + Resolution string + PromptOptimizer *bool + FastPretreatment *bool + AigcWatermark *bool +} + +// minimaxVideoV1RequestEcho v1 提交回显(任务未轮询到上游数据前的兜底) +type minimaxVideoV1RequestEcho struct { + Prompt string `json:"prompt"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` +} + +// ParseMiniMaxVideoV1CreateRequest 解析 v1 官方创建请求(POST /v1/video_generation,扁平字段)。 +// 文生/图生/首尾帧/主体参考四种形态共用端点,按字段自然区分,duration 兼容 number 与 string。 +func ParseMiniMaxVideoV1CreateRequest(body []byte) (*minimaxVideoV1CreateRequest, *common.TaskError) { + var raw struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + FirstFrameImage string `json:"first_frame_image"` + LastFrameImage string `json:"last_frame_image"` + SubjectReference json.RawMessage `json:"subject_reference"` + Duration json.RawMessage `json:"duration"` + Resolution string `json:"resolution"` + PromptOptimizer *bool `json:"prompt_optimizer"` + FastPretreatment *bool `json:"fast_pretreatment"` + AigcWatermark *bool `json:"aigc_watermark"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + duration, terr := parseMiniMaxDuration(raw.Duration) + if terr != nil { + return nil, terr + } + + return &minimaxVideoV1CreateRequest{ + Model: strings.TrimSpace(raw.Model), + Prompt: raw.Prompt, + FirstFrameImage: strings.TrimSpace(raw.FirstFrameImage), + LastFrameImage: strings.TrimSpace(raw.LastFrameImage), + SubjectReference: raw.SubjectReference, + Duration: duration, + Resolution: strings.TrimSpace(raw.Resolution), + PromptOptimizer: raw.PromptOptimizer, + FastPretreatment: raw.FastPretreatment, + AigcWatermark: raw.AigcWatermark, + }, nil +} + +// ValidateMiniMaxVideoV1CreateRequest 请求必填校验(对齐官方四形态约束): +// model 必填;prompt(文生)/ first_frame_image(图生)/ subject_reference(主体参考)至少其一。 +func ValidateMiniMaxVideoV1CreateRequest(req *minimaxVideoV1CreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if strings.TrimSpace(req.Prompt) == "" && req.FirstFrameImage == "" && len(req.SubjectReference) == 0 { + return &common.TaskError{StatusCode: http.StatusBadRequest, + Message: "one of prompt / first_frame_image / subject_reference is required"} + } + return nil +} + +// BuildMiniMaxVideoV1TaskBody 把 v1 官方创建请求转成任务框架通用请求体。 +// callback_url 不进入任务体(网关轮询计费,上游回调与网关 ID 体系不兼容); +// 同时双写通用词汇(seconds/images[]/metadata),保证按通用路径配置的参数倍率规则对 v1 入站同样生效。 +func BuildMiniMaxVideoV1TaskBody(req *minimaxVideoV1CreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + } + if strings.TrimSpace(req.Prompt) != "" { + body["prompt"] = req.Prompt + } + if req.FirstFrameImage != "" { + body["first_frame_image"] = req.FirstFrameImage + body["images"] = []string{req.FirstFrameImage} + } + if req.LastFrameImage != "" { + body["last_frame_image"] = req.LastFrameImage + } + if len(req.SubjectReference) > 0 && string(req.SubjectReference) != "null" { + body["subject_reference"] = req.SubjectReference + } + if req.Resolution != "" { + body["resolution"] = req.Resolution + } + if req.Duration > 0 { + body["duration"] = req.Duration + body["seconds"] = strconv.Itoa(req.Duration) + } + if req.PromptOptimizer != nil { + body["prompt_optimizer"] = *req.PromptOptimizer + } + if req.FastPretreatment != nil { + body["fast_pretreatment"] = *req.FastPretreatment + } + if req.AigcWatermark != nil { + body["aigc_watermark"] = *req.AigcWatermark + } + + // 通用词汇双写(metadata 键) + metadata := map[string]any{} + if req.Resolution != "" { + metadata["resolution"] = req.Resolution + } + if req.Duration > 0 { + metadata["duration"] = req.Duration + } + if req.FirstFrameImage != "" { + metadata["image"] = req.FirstFrameImage + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal minimax v1 video task body: %w", err) + } + return data, nil +} + +// NewMiniMaxV1RequestEcho 从归一请求构造 v1 提交回显 +func NewMiniMaxV1RequestEcho(req *minimaxVideoV1CreateRequest) minimaxVideoV1RequestEcho { + return minimaxVideoV1RequestEcho{ + Prompt: req.Prompt, + Resolution: req.Resolution, + Duration: req.Duration, + } +} diff --git a/relay/handler/minimax_video_convert_test.go b/relay/handler/minimax_video_convert_test.go new file mode 100644 index 0000000..ff3ee50 --- /dev/null +++ b/relay/handler/minimax_video_convert_test.go @@ -0,0 +1,147 @@ +package handler + +import ( + "encoding/json" + "testing" +) + +// MiniMax 官方创建请求的解析 / 校验 / 通用任务体转换单测。 + +func TestParseMiniMaxVideoCreateRequest_Full(t *testing.T) { + body := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石"}, + {"type": "image_url", "image_url": {"url": "https://cdn.example/a.png"}, "role": "first_frame"} + ], + "resolution": "2K", + "duration": 8, + "ratio": "16:9", + "aigc_watermark": true, + "callback_url": "https://example.com/cb" + }` + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(body)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.Model != "MiniMax-H3" || req.Prompt != "海浪拍打礁石" || req.Resolution != "2K" || req.Duration != 8 || req.Ratio != "16:9" { + t.Fatalf("parsed fields: %+v", req) + } + if req.AigcWatermark == nil || !*req.AigcWatermark { + t.Fatalf("aigc_watermark: %v", req.AigcWatermark) + } + if len(req.Content) != 2 { + t.Fatalf("content items: %d", len(req.Content)) + } +} + +func TestParseMiniMaxVideoCreateRequest_DurationString(t *testing.T) { + // duration 宽松兼容字符串数字 + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"6"}`)) + if taskErr != nil || req.Duration != 6 { + t.Fatalf("duration string parse: %+v err=%+v", req, taskErr) + } + // 非法 duration 报 400 + if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"768P","duration":"abc"}`)); taskErr == nil { + t.Fatal("expected error for invalid duration") + } +} + +func TestParseMiniMaxVideoCreateRequest_InvalidContent(t *testing.T) { + if _, taskErr := ParseMiniMaxVideoCreateRequest([]byte(`{"model":"MiniMax-H3","content":{"type":"text"},"resolution":"768P","duration":6}`)); taskErr == nil { + t.Fatal("expected error when content is not an array") + } +} + +func TestValidateMiniMaxVideoCreateRequest(t *testing.T) { + valid := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", Prompt: "hi", Content: []any{map[string]any{"type": "text", "text": "hi"}}, + Resolution: "768P", Duration: 6, + } + if taskErr := ValidateMiniMaxVideoCreateRequest(valid); taskErr != nil { + t.Fatalf("valid request should pass: %+v", taskErr) + } + + cases := map[string]*minimaxVideoCreateRequest{ + "missing model": {Prompt: "hi", Resolution: "768P", Duration: 6}, + "missing content": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P", Duration: 6}, + "missing text item": {Model: "MiniMax-H3", Resolution: "768P", Duration: 6}, + "missing resolution": {Model: "MiniMax-H3", Prompt: "hi", Duration: 6}, + "missing duration": {Model: "MiniMax-H3", Prompt: "hi", Resolution: "768P"}, + } + for name, req := range cases { + if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr == nil { + t.Errorf("%s: expected 400", name) + } + } +} + +func TestBuildMiniMaxVideoTaskBody(t *testing.T) { + req := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", + Prompt: "海浪拍打礁石", + Content: []any{ + map[string]any{"type": "text", "text": "海浪拍打礁石"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/a.png"}, "role": "first_frame"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/ref.png"}, "role": "reference_image"}, + }, + Resolution: "768P", + Duration: 6, + Ratio: "16:9", + } + body, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // 原生字段保留 + if m["model"] != "MiniMax-H3" || m["prompt"] != "海浪拍打礁石" || m["resolution"] != "768P" || m["duration"] != float64(6) || m["ratio"] != "16:9" { + t.Fatalf("native fields: %v", m) + } + if _, has := m["callback_url"]; has { + t.Fatal("callback_url must never enter the task body") + } + + // 通用词汇双写 + if m["seconds"] != "6" { + t.Fatalf("seconds dual-write: %v", m["seconds"]) + } + images, _ := m["images"].([]any) + // 只提取 first_frame / 无 role 的图片,reference_image 不算图生视频输入 + if len(images) != 1 || images[0] != "https://cdn.example/a.png" { + t.Fatalf("images dual-write: %v", m["images"]) + } + meta, _ := m["metadata"].(map[string]any) + if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["ratio"] != "16:9" { + t.Fatalf("metadata dual-write: %v", m["metadata"]) + } + // content 原样保留(3 项) + if content, _ := m["content"].([]any); len(content) != 3 { + t.Fatalf("content preserved: %v", m["content"]) + } +} + +func TestBuildMiniMaxVideoTaskBody_Minimal(t *testing.T) { + // 无 ratio / 无图片:ratio 与 images 键不产出 + req := &minimaxVideoCreateRequest{ + Model: "MiniMax-H3", Prompt: "hi", + Content: []any{map[string]any{"type": "text", "text": "hi"}}, + Resolution: "768P", Duration: 4, + } + body, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + _ = json.Unmarshal(body, &m) + if _, has := m["ratio"]; has { + t.Fatalf("ratio should be absent when not specified: %v", m) + } + if _, has := m["images"]; has { + t.Fatalf("images should be absent without image input: %v", m) + } +} diff --git a/relay/handler/minimax_video_pipeline_test.go b/relay/handler/minimax_video_pipeline_test.go new file mode 100644 index 0000000..bfa8d03 --- /dev/null +++ b/relay/handler/minimax_video_pipeline_test.go @@ -0,0 +1,332 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 端到端集成测试:MiniMax 官方协议(/v2)与 OpenAI Videos 协议(/v1/videos)→ 转换 → +// 任务提交管线(minimax 适配器 + mock 上游)→ {"task_id"} 响应 + minimax 平台任务落库 + +// 上游收到映射后的 v2 参数。 + +// newMiniMaxUpstream mock MiniMax v2 上游:记录提交请求体,返回官方提交响应 +func newMiniMaxUpstream(t *testing.T) (*httptest.Server, *map[string]any) { + t.Helper() + received := map[string]any{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/video_generation" { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer sk-test" { + t.Errorf("unexpected authorization: %q", auth) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629"}) + })) + return upstream, &received +} + +func miniMaxChannelMeta(baseURL string) *common.ChannelMeta { + return &common.ChannelMeta{ + ChannelID: 21, + ChannelType: int(constant.ProviderMiniMax), + ChannelName: "mock-minimax", + BaseURL: baseURL, + ApiKey: "sk-test", + UpstreamModelName: "MiniMax-H3", + } +} + +// TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline 官方协议全链路: +// 原生 content[] 透传、callback_url 剥离、{"task_id"} 响应、minimax 平台落库、请求回显持久化。 +func TestHandleTaskSubmit_MiniMaxNativeProtocol_FullPipeline(t *testing.T) { + upstream, received := newMiniMaxUpstream(t) + defer upstream.Close() + + // 官方请求 → 归一 → 通用任务体(与胶水层 HandleMiniMaxVideoSubmit 相同链路) + raw := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石,慢镜头。"}, + {"type": "image_url", "image_url": {"url": "https://cdn.example/first.png"}, "role": "first_frame"} + ], + "resolution": "768P", + "duration": 6, + "ratio": "16:9", + "callback_url": "https://evil.example/cb" + }` + req, taskErr := ParseMiniMaxVideoCreateRequest([]byte(raw)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if taskErr := ValidateMiniMaxVideoCreateRequest(req); taskErr != nil { + t.Fatalf("validate: %+v", taskErr) + } + taskBody, err := BuildMiniMaxVideoTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_native", + Writer: rec, + Protocol: minimaxVideoProtocol, + RelayMode: int(constant.RelayModeMiniMaxVideo), + RequestEcho: NewMiniMaxRequestEcho(req), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v2/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL)) + + // 客户端响应:官方 VideoGenerationV2Resp 形态 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if len(resp) != 1 { + t.Fatalf("submit response should only carry task_id: %s", rec.Body.String()) + } + taskID, _ := resp["task_id"].(string) + if !strings.HasPrefix(taskID, "task_") { + t.Fatalf("expected task_ prefixed id, got %q", taskID) + } + if rc.TaskID != taskID { + t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, taskID) + } + + // 落库任务:minimax 平台 + 上游任务 ID + 请求回显 + if data.created == nil { + t.Fatal("expected task record created") + } + if data.created.Platform != "minimax" { + t.Fatalf("expected platform minimax, got %q", data.created.Platform) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *minimaxVideoRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "424010985738629" { + t.Fatalf("upstream task id: %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Resolution != "768P" || pd.RequestEcho.Duration != 6 { + t.Fatalf("request echo persisted: %+v", pd.RequestEcho) + } + + // 上游收到:content 原样(2 项)、官方参数、无 callback_url + if (*received)["model"] != "MiniMax-H3" { + t.Fatalf("upstream model: %v", (*received)["model"]) + } + if (*received)["resolution"] != "768P" || (*received)["duration"] != float64(6) || (*received)["ratio"] != "16:9" { + t.Fatalf("upstream params: %v", *received) + } + if _, has := (*received)["callback_url"]; has { + t.Fatal("callback_url must be stripped before forwarding upstream") + } + content, _ := (*received)["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected content passthrough with 2 items, got %v", (*received)["content"]) + } + img, _ := content[1].(map[string]any) + if img["role"] != "first_frame" { + t.Fatalf("content roles preserved: %v", content[1]) + } +} + +// TestHandleTaskSubmit_OpenAIVideosToMiniMax OpenAI Videos 协议 → minimax 适配器: +// size 原值透传(调用方直接传 MiniMax 原生档位词汇)、seconds → duration、multipart 链路复用。 +func TestHandleTaskSubmit_OpenAIVideosToMiniMax(t *testing.T) { + upstream, received := newMiniMaxUpstream(t) + defer upstream.Close() + + // OpenAI multipart 创建请求:size 直传 MiniMax 原生档位 768P + 8 秒 + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "MiniMax-H3", + "prompt": "a cat surfing", + "seconds": "8", + "size": "768P", + }, "", "", nil) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_openai", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, miniMaxChannelMeta(upstream.URL)) + + // OpenAI 协议响应保持官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" { + t.Fatalf("openai video object: %s", rec.Body.String()) + } + if data.created == nil || data.created.Platform != "minimax" { + t.Fatalf("expected minimax task record, got %+v", data.created) + } + + // 上游收到映射后的 v2 参数:size 原值透传、seconds→duration、t2v 默认 ratio + if (*received)["resolution"] != "768P" { + t.Fatalf("upstream resolution passthrough: %v", (*received)["resolution"]) + } + if (*received)["duration"] != float64(8) { + t.Fatalf("upstream duration: %v", (*received)["duration"]) + } + if (*received)["ratio"] != "16:9" { + t.Fatalf("upstream ratio: %v", (*received)["ratio"]) + } + content, _ := (*received)["content"].([]any) + if len(content) != 1 { + t.Fatalf("text-only content expected, got %v", (*received)["content"]) + } + text, _ := content[0].(map[string]any) + if text["text"] != "a cat surfing" { + t.Fatalf("text item: %v", content[0]) + } +} + +// TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline v1 官方协议全链路: +// 扁平字段透传、callback_url 剥离、{task_id, base_resp} 响应、minimax 平台落库。 +func TestHandleTaskSubmit_MiniMaxV1NativeProtocol_FullPipeline(t *testing.T) { + // mock MiniMax v1 上游:记录提交请求体,返回官方提交响应 + received := map[string]any{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/video_generation" { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "task_id": "v1-up-777", + "base_resp": map[string]any{"status_code": 0, "status_msg": "success"}, + }) + })) + defer upstream.Close() + + raw := `{ + "model": "MiniMax-Hailuo-2.3", + "prompt": "一只猫在月光下的屋顶上行走", + "first_frame_image": "https://cdn.example/f.png", + "duration": 10, + "resolution": "768P", + "callback_url": "https://evil.example/cb" + }` + req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(raw)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil { + t.Fatalf("validate: %+v", taskErr) + } + taskBody, err := BuildMiniMaxVideoV1TaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_mm_v1_native", + Writer: rec, + Protocol: minimaxVideoV1Protocol, + RelayMode: int(constant.RelayModeMiniMaxVideo), + RequestEcho: NewMiniMaxV1RequestEcho(req), + } + data := &stubVideosSubmitProvider{} + channelMeta := &common.ChannelMeta{ + ChannelID: 22, + ChannelType: int(constant.ProviderMiniMax), + ChannelName: "mock-minimax", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "MiniMax-Hailuo-2.3", + } + + HandleTaskSubmit(context.Background(), taskBody, "/v1/video_generation", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta) + + // 客户端响应:官方 VideoGenerationResp 形态(含 base_resp 成功信封) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskID, _ := resp["task_id"].(string) + if !strings.HasPrefix(taskID, "task_") { + t.Fatalf("expected task_ prefixed id, got %q", taskID) + } + baseResp, _ := resp["base_resp"].(map[string]any) + if baseResp == nil || baseResp["status_code"] != float64(0) { + t.Fatalf("base_resp success envelope: %s", rec.Body.String()) + } + + // 落库任务:minimax 平台 + 上游任务 ID + 请求回显 + if data.created == nil || data.created.Platform != "minimax" { + t.Fatalf("expected minimax task record, got %+v", data.created) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *minimaxVideoV1RequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "v1-up-777" { + t.Fatalf("upstream task id: %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Duration != 10 || pd.RequestEcho.Resolution != "768P" { + t.Fatalf("request echo persisted: %+v", pd.RequestEcho) + } + + // 上游收到:v1 扁平字段、无 content[]、无 callback_url + if received["model"] != "MiniMax-Hailuo-2.3" || received["prompt"] != "一只猫在月光下的屋顶上行走" { + t.Fatalf("upstream flat fields: %v", received) + } + if received["first_frame_image"] != "https://cdn.example/f.png" || received["duration"] != float64(10) || received["resolution"] != "768P" { + t.Fatalf("upstream params: %v", received) + } + if _, has := received["callback_url"]; has { + t.Fatal("callback_url must be stripped before forwarding upstream") + } + if _, has := received["content"]; has { + t.Fatal("v1 request must not carry content[]") + } +} diff --git a/relay/handler/minimax_video_test.go b/relay/handler/minimax_video_test.go new file mode 100644 index 0000000..f6eb679 --- /dev/null +++ b/relay/handler/minimax_video_test.go @@ -0,0 +1,427 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" +) + +// MiniMax 官方协议查询/删除处理器单测(stub TaskDataProvider)。 + +// stubMiniMaxTaskProvider 返回预置任务并回查渠道 +type stubMiniMaxTaskProvider struct { + common.TaskDataProvider + + task *common.AsyncTask + channel *common.ChannelBasicInfo +} + +func (s *stubMiniMaxTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.task != nil && s.task.PublicTaskID == publicTaskID { + return s.task, nil + } + return nil, nil +} + +func (s *stubMiniMaxTaskProvider) GetChannelByID(_ context.Context, _ int64) (*common.ChannelBasicInfo, error) { + return s.channel, nil +} + +// newMiniMaxTask 构造 minimax 平台任务记录(带上游任务 ID,供取消链路使用) +func newMiniMaxTask(status string) *common.AsyncTask { + now := time.Now() + return &common.AsyncTask{ + PublicTaskID: "task_mm_1", + Platform: "minimax", + Status: status, + ModelName: "MiniMax-H3", + TenantID: 1, + UserID: 2, + ChannelID: 21, + PrivateData: json.RawMessage(`{"upstream_task_id":"424010985738629"}`), + CreatedAt: now, + UpdatedAt: now, + } +} + +func TestMiniMaxStatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "queued", + "SUBMITTED": "queued", + "QUEUED": "queued", + "IN_PROGRESS": "running", + "SUCCESS": "succeeded", + "FAILURE": "failed", + "WHATEVER": "queued", + } + for in, want := range cases { + if got := minimaxStatusFromTask(in); got != want { + t.Errorf("minimaxStatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestHandleMiniMaxVideoRetrieve_EnrichFromUpstreamData(t *testing.T) { + task := newMiniMaxTask("SUCCESS") + task.ResultURL = "https://video.cdn.example/out.mp4" + task.Data = json.RawMessage(`{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}`) + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj == nil { + t.Fatalf("expected {\"task\": {...}} wrapper: %s", rec.Body.String()) + } + // id 用网关公开 ID(不透出上游 ID) + if taskObj["id"] != "task_mm_1" || taskObj["status"] != "succeeded" { + t.Fatalf("task identity: %s", rec.Body.String()) + } + content, _ := taskObj["content"].(map[string]any) + if content["url"] != "https://video.cdn.example/out.mp4" { + t.Fatalf("content.url: %v", taskObj["content"]) + } + usage, _ := taskObj["usage"].(map[string]any) + if usage["total_tokens"] != float64(273890) { + t.Fatalf("usage replay: %v", taskObj["usage"]) + } + if taskObj["resolution"] != "2K" || taskObj["duration"] != float64(5) || taskObj["ratio"] != "16:9" { + t.Fatalf("enriched specs: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade 提交后首拍轮询前 Data 是提交响应形态, +// 查询必须静默降级(缺失字段不输出,状态以任务记录为准)。 +func TestHandleMiniMaxVideoRetrieve_SubmitShapedDataDegrade(t *testing.T) { + task := newMiniMaxTask("SUBMITTED") + task.Data = json.RawMessage(`{"task_id":"424010985738629"}`) + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj["status"] != "queued" { + t.Fatalf("status from record: %s", rec.Body.String()) + } + if _, has := taskObj["usage"]; has { + t.Fatalf("usage must be absent for submit-shaped data: %s", rec.Body.String()) + } + if _, has := taskObj["resolution"]; has { + t.Fatalf("resolution must be absent for submit-shaped data: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoRetrieve_PlatformGate 平台门禁:异构平台任务 ID 打 /v2 端点按 404 处理 +func TestHandleMiniMaxVideoRetrieve_PlatformGate(t *testing.T) { + task := newMiniMaxTask("SUCCESS") + task.Platform = "kling" + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for non-minimax platform, got %d", rec.Code) + } +} + +// TestHandleMiniMaxVideoRetrieve_FailedTask 失败任务回放官方 error 对象 +func TestHandleMiniMaxVideoRetrieve_FailedTask(t *testing.T) { + task := newMiniMaxTask("FAILURE") + task.FailReason = "content policy violation" + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec} + HandleMiniMaxVideoRetrieve(context.Background(), "task_mm_1", rc, &stubMiniMaxTaskProvider{task: task}) + + resp := decodeVideosJSON(t, rec) + taskObj, _ := resp["task"].(map[string]any) + if taskObj["status"] != "failed" { + t.Fatalf("failed status: %s", rec.Body.String()) + } + errObj, _ := taskObj["error"].(map[string]any) + if errObj["message"] != "content policy violation" { + t.Fatalf("error object: %s", rec.Body.String()) + } +} + +// newMiniMaxCancelUpstream mock MiniMax v2 取消端点(DELETE /v2/video_generation/{id}): +// 记录路径与方法,按脚本返回成功 / 上游拒绝 / 竞态删记录三种形态。 +func newMiniMaxCancelUpstream(t *testing.T, script string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("cancel must use DELETE, got %s", r.Method) + } + if r.URL.Path != "/v2/video_generation/424010985738629" { + t.Errorf("unexpected cancel path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch script { + case "ok": + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "cancelled", "status": "cancelled"}) + case "running": + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"type": "invalid_request_error", "message": "running task cannot be cancelled"}}) + case "deleted": + _ = json.NewEncoder(w).Encode(map[string]any{"task_id": "424010985738629", "action": "deleted", "status": "deleted"}) + } + })) +} + +func TestHandleMiniMaxVideoCancel(t *testing.T) { + // 终态任务:不可取消、不可删除(不做软删),且不触达上游 + upstream := newMiniMaxCancelUpstream(t, "ok") + defer upstream.Close() + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("SUCCESS"), + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("terminal task must not be cancellable/deletable: code=%d body=%s", rec.Code, rec.Body.String()) + } + + // 运行中任务:前置拦截,不可取消 + provider.task = newMiniMaxTask("IN_PROGRESS") + rec = httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("running task must not be cancellable: code=%d body=%s", rec.Code, rec.Body.String()) + } + + // 排队中任务:上游确认取消 → 官方 cancelled 响应 + provider.task = newMiniMaxTask("QUEUED") + rec = httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + if rec.Code != http.StatusOK { + t.Fatalf("queued cancel should succeed: code=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["task_id"] != "task_mm_1" || resp["action"] != "cancelled" || resp["status"] != "cancelled" { + t.Fatalf("cancel response shape: %s", rec.Body.String()) + } + // 本地状态不由取消端点改写(轮询循环对账收敛) + if provider.task.Status != "QUEUED" { + t.Fatalf("cancel endpoint must not mutate local status directly: %s", provider.task.Status) + } +} + +// TestHandleMiniMaxVideoCancel_UpstreamRejected 远程取消失败:上游判定运行中, +// 错误原样透传(本地状态滞后时由上游裁决兜底)。 +func TestHandleMiniMaxVideoCancel_UpstreamRejected(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "running") + defer upstream.Close() + + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("QUEUED"), // 本地状态滞后:本地仍是排队中 + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("upstream rejection must relay: code=%d body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + errObj, _ := resp["error"].(map[string]any) + if errObj["message"] != "running task cannot be cancelled" { + t.Fatalf("upstream message must be relayed: %s", rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoCancel_UpstreamDeletedRace 竞态:本地检查后任务已在上游完成, +// 上游执行的是删记录(action=deleted)而非取消——不能向客户端谎报取消成功。 +func TestHandleMiniMaxVideoCancel_UpstreamDeletedRace(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "deleted") + defer upstream.Close() + + provider := &stubMiniMaxTaskProvider{ + task: newMiniMaxTask("QUEUED"), + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("deleted action must not be reported as cancelled: code=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestHandleMiniMaxVideoCancel_V1Rejected v1(Hailuo 系列)任务:上游无取消端点,直接拒绝 +func TestHandleMiniMaxVideoCancel_V1Rejected(t *testing.T) { + upstream := newMiniMaxCancelUpstream(t, "ok") // 上游即使可达也不应被调用 + defer upstream.Close() + + task := newMiniMaxTask("QUEUED") + task.ModelName = "MiniMax-Hailuo-2.3" // UpstreamModel 为空时回落 ModelName 分派 + provider := &stubMiniMaxTaskProvider{ + task: task, + channel: &common.ChannelBasicInfo{ID: 21, BaseURL: upstream.URL, ApiKey: "sk-test"}, + } + rec := httptest.NewRecorder() + HandleMiniMaxVideoCancel(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("v1 cancel must be rejected: code=%d body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "v1 API has no cancel endpoint") { + t.Fatalf("reject message: %s", rec.Body.String()) + } +} + +// ==================== v1(Hailuo 系列)官方协议 ==================== + +func TestMiniMaxV1StatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "Preparing", + "SUBMITTED": "Preparing", + "QUEUED": "Queueing", + "IN_PROGRESS": "Processing", + "SUCCESS": "Success", + "FAILURE": "Fail", + } + for in, want := range cases { + if got := minimaxV1StatusFromTask(in); got != want { + t.Errorf("minimaxV1StatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParseAndValidateMiniMaxVideoV1CreateRequest(t *testing.T) { + // 图生视频形态(prompt 可选) + req, taskErr := ParseMiniMaxVideoV1CreateRequest([]byte(`{ + "model": "MiniMax-Hailuo-2.3", + "first_frame_image": "https://cdn.example/f.png", + "prompt": "让画面动起来", + "duration": "10", + "resolution": "768P", + "prompt_optimizer": false, + "callback_url": "https://example.com/cb" + }`)) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + if req.Model != "MiniMax-Hailuo-2.3" || req.FirstFrameImage != "https://cdn.example/f.png" || req.Duration != 10 || req.Resolution != "768P" { + t.Fatalf("parsed fields: %+v", req) + } + if req.PromptOptimizer == nil || *req.PromptOptimizer { + t.Fatalf("prompt_optimizer: %v", req.PromptOptimizer) + } + if taskErr := ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil { + t.Fatalf("i2v form should pass: %+v", taskErr) + } + + // 必填校验:model 必填;prompt/first_frame_image/subject_reference 至少其一 + noModel, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"prompt":"hi"}`)) + if taskErr := ValidateMiniMaxVideoV1CreateRequest(noModel); taskErr == nil { + t.Fatal("expected model required") + } + empty, _ := ParseMiniMaxVideoV1CreateRequest([]byte(`{"model":"MiniMax-Hailuo-2.3","resolution":"768P"}`)) + if taskErr := ValidateMiniMaxVideoV1CreateRequest(empty); taskErr == nil { + t.Fatal("expected one-of-prompt/image/subject required") + } +} + +func TestBuildMiniMaxVideoV1TaskBody(t *testing.T) { + req := &minimaxVideoV1CreateRequest{ + Model: "MiniMax-Hailuo-2.3", + Prompt: "海浪", + FirstFrameImage: "https://cdn.example/f.png", + Duration: 6, + Resolution: "768P", + } + body, err := BuildMiniMaxVideoV1TaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // v1 扁平字段保留 + if m["model"] != "MiniMax-Hailuo-2.3" || m["prompt"] != "海浪" || m["first_frame_image"] != "https://cdn.example/f.png" { + t.Fatalf("flat fields: %v", m) + } + if m["duration"] != float64(6) || m["resolution"] != "768P" { + t.Fatalf("duration/resolution: %v/%v", m["duration"], m["resolution"]) + } + if _, has := m["callback_url"]; has { + t.Fatal("callback_url must never enter the task body") + } + // 通用词汇双写 + if m["seconds"] != "6" { + t.Fatalf("seconds dual-write: %v", m["seconds"]) + } + images, _ := m["images"].([]any) + if len(images) != 1 || images[0] != "https://cdn.example/f.png" { + t.Fatalf("images dual-write: %v", m["images"]) + } + meta, _ := m["metadata"].(map[string]any) + if meta["resolution"] != "768P" || meta["duration"] != float64(6) || meta["image"] != "https://cdn.example/f.png" { + t.Fatalf("metadata dual-write: %v", m["metadata"]) + } +} + +func TestHandleMiniMaxVideoV1Retrieve(t *testing.T) { + // 成功任务:官方 QueryVideoGenerationTaskResp 形态 + Data 富集 + ResultURL 直链 + task := newMiniMaxTask("SUCCESS") + task.ModelName = "MiniMax-Hailuo-2.3" + task.ResultURL = "https://cdn.example/out.mp4" + task.Data = json.RawMessage(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"download_url":"https://cdn.example/out.mp4","base_resp":{"status_code":0}}`) + + rec := httptest.NewRecorder() + HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: task}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["task_id"] != "task_mm_1" || resp["status"] != "Success" { + t.Fatalf("official shape: %s", rec.Body.String()) + } + if resp["file_id"] != "9001" || resp["video_width"] != float64(1280) || resp["video_height"] != float64(720) { + t.Fatalf("enriched fields: %s", rec.Body.String()) + } + if resp["download_url"] != "https://cdn.example/out.mp4" { + t.Fatalf("download_url: %s", rec.Body.String()) + } + baseResp, _ := resp["base_resp"].(map[string]any) + if baseResp["status_code"] != float64(0) { + t.Fatalf("base_resp success envelope: %s", rec.Body.String()) + } + + // 失败任务:状态 Fail + 增补 error 字段;v2 形态 Data 不富集 + failTask := newMiniMaxTask("FAILURE") + failTask.ModelName = "MiniMax-Hailuo-2.3" + failTask.FailReason = "content violates policy" + failTask.Data = json.RawMessage(`{"task":{"id":"up-1","status":"failed"}}`) // v2 形态:跳过富集 + rec = httptest.NewRecorder() + HandleMiniMaxVideoV1Retrieve(context.Background(), "task_mm_1", &TaskRelayContext{TenantID: 1, UserID: 2, Writer: rec}, &stubMiniMaxTaskProvider{task: failTask}) + + resp = decodeVideosJSON(t, rec) + if resp["status"] != "Fail" || resp["error"] != "content violates policy" { + t.Fatalf("failed replay: %s", rec.Body.String()) + } + if _, has := resp["file_id"]; has { + t.Fatalf("v2-shaped data must not enrich v1 response: %s", rec.Body.String()) + } +} diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index e933ffd..f402efb 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -1320,6 +1320,8 @@ func relayModeString(mode constant.RelayMode) string { return "mj_image" case constant.RelayModeVideoGenerations: return "video_generations" + case constant.RelayModeVideos: + return "videos" default: return "" } diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index ae180ff..edb5401 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -22,6 +22,7 @@ import ( _ "github.com/qianfree/team-api/relay/taskchannel/gemini" _ "github.com/qianfree/team-api/relay/taskchannel/kling" "github.com/qianfree/team-api/relay/taskchannel/midjourney" + _ "github.com/qianfree/team-api/relay/taskchannel/minimax" _ "github.com/qianfree/team-api/relay/taskchannel/sora" _ "github.com/qianfree/team-api/relay/taskchannel/suno" _ "github.com/qianfree/team-api/relay/taskchannel/volcengine" @@ -44,6 +45,20 @@ type TaskRelayContext struct { KeyTotalQuota float64 KeyUsedQuota float64 ForwardingTrace *common.ForwardingTrace + + // Protocol 入站协议标识:"" 默认(legacy 任务响应格式,task_ 前缀 ID); + // videosProtocolOpenAI 切换为 OpenAI Videos 协议(官方 Video 对象响应,video_ 前缀 ID)。 + Protocol string + // RelayMode 任务 relay 模式:0 = 默认 RelayModeVideoGenerations(现有端点), + // OpenAI Videos 入站传 RelayModeVideos 以区分审计与监控统计。 + RelayMode int + // RequestEcho 协议层请求回显(OpenAI Videos 的 prompt/seconds/size), + // 提交时随 PrivateData 落库,retrieve 时回显给客户端;nil = 无回显。 + RequestEcho any + + // Debug 渠道调试日志会话;nil = 渠道未开启调试(所有方法 nil-safe)。 + // 由 HandleTaskSubmit 在提交前创建,响应写完后经 FinalizeAndSubmit 补段4并提交 + Debug *common.DebugSession } func checkTaskIPWhitelist(whitelist string, clientIP string) bool { @@ -92,6 +107,13 @@ func HandleTaskSubmit( billingProvider common.TaskBillingProvider, channelMeta *common.ChannelMeta, ) { + start := time.Now() + // 各出口的响应同步写完后走到 defer:补段4并提交调试记录(幂等、nil-safe)。 + // 必须用闭包——直接 defer 方法调用会在注册时求值 latency 与 rc.Debug(恒为 0/nil) + defer func() { + rc.Debug.FinalizeAndSubmit(time.Since(start).Milliseconds(), 0) + }() + // 0. QPS 限流检查(前置:只依赖认证上下文,超限请求在解析请求体之前被拒绝) allowed, limitLevel, _, _, _ := billingProvider.CheckRateLimit(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, rc.KeyRateLimitQps) if !allowed { @@ -154,13 +176,37 @@ func HandleTaskSubmit( g.Log().Debugf(ctx, "HandleTaskSubmit: modelName=%s, platform=%s, channelID=%d, channelType=%d, baseURL=%s, upstreamModel=%s", modelName, platform, channelMeta.ChannelID, channelMeta.ChannelType, channelMeta.BaseURL, channelMeta.UpstreamModelName) // 4. 构建 RelayInfo + relayMode := rc.RelayMode + if relayMode == 0 { + relayMode = int(constant.RelayModeVideoGenerations) + } + + // 渠道调试日志:开关开启且匹配目标过滤时创建会话(捕获段1 + 包装段4 writer), + // 提交为单次尝试(retry_index=0),捕获器经 attemptCtx 注入传输层镜像段2/3。 + // writer 层级:审计 capture(外层,入口胶水包)← DebugClientWriter(内层),与同步链路同构 + var dbgAttempt *common.DebugAttempt + if channelMeta.Settings.DebugLogEnabled && + channelMeta.Settings.DebugTargetMatch(rc.TenantID, rc.UserID, rc.ApiKeyID) { + rc.Debug = common.NewDebugSession(rc.RequestID, rc.TenantID, rc.UserID, rc.ApiKeyID, path) + rc.Debug.CaptureClientRequest(headers, body) + dw := common.NewDebugClientWriter(rc.Writer) + rc.Debug.SetClientWriter(dw) + rc.Writer = dw + dbgAttempt = rc.Debug.BeginTaskAttempt(channelMeta, modelName, + relayModeString(constant.RelayMode(relayMode)), 0) + } + attemptCtx := ctx + if dbgAttempt != nil { + attemptCtx = common.WithDebugAttempt(ctx, dbgAttempt.Capture) + } + info := &common.RelayInfo{ - Context: ctx, + Context: attemptCtx, TenantID: rc.TenantID, UserID: rc.UserID, ApiKeyID: rc.ApiKeyID, RequestID: rc.RequestID, - RelayMode: int(constant.RelayModeVideoGenerations), + RelayMode: relayMode, OriginModelName: modelName, RequestURLPath: path, RequestHeaders: headers, @@ -168,6 +214,7 @@ func HandleTaskSubmit( ChannelMeta: channelMeta, } adaptor.Init(info) + dbgAttempt.CaptureProtocol(info) // 5. 校验请求 if taskErr := adaptor.ValidateRequest(ctx, info, body); taskErr != nil { @@ -176,9 +223,10 @@ func HandleTaskSubmit( return } - // 6. 估算计费 + 预扣 + // 6. 估算计费 + 预扣(ratios 为计费上下文:float64 乘数 + spec.* 规格事实值; + // body 同传给 EstimateTaskCost 供参数倍率按归一化任务体匹配,命中注入 ratios) ratios := adaptor.EstimateBilling(ctx, info, body) - estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios) + estimatedCost, err := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, ratios, body) if err != nil { g.Log().Errorf(ctx, "HandleTaskSubmit: estimate cost failed, model=%s, err=%v", modelName, err) writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "") @@ -205,11 +253,12 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Errorf(ctx, "HandleTaskSubmit: build request failed, model=%s, err=%v", modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusInternalServerError, "build request failed: "+err.Error(), "") return } - resp, err := adaptor.DoRequest(ctx, info, requestBody) + resp, err := adaptor.DoRequest(attemptCtx, info, requestBody) if err != nil { if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil { g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) @@ -217,6 +266,7 @@ func HandleTaskSubmit( // 上游请求失败(网络/超时/拒绝等)属预期内运营事件,非代码 bug: // 用 Warningf 避免 glog 对 ERROR+ 自动打印调用栈污染日志(与 :229 upstream response error 一致)。 g.Log().Warningf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusBadGateway, helper.SafeUpstreamErrorMessage(err), "") return } @@ -231,6 +281,7 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Warningf(ctx, "HandleTaskSubmit: upstream response error, model=%s, status=%d, message=%q, body=%s", modelName, taskErr.StatusCode, taskErr.Message, string(taskData)) + dbgAttempt.MarkFinal(taskErr) writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode) return } @@ -238,7 +289,7 @@ func HandleTaskSubmit( // 9. 调整计费 adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData) if adjustedRatios != nil { - newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios) + newCost, _ := billingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, adjustedRatios, body) preDeductAmount, _ = billingProvider.AdjustTaskBilling(ctx, rc.TenantID, rc.RequestID, preDeductAmount, newCost) } @@ -248,7 +299,7 @@ func HandleTaskSubmit( if finalRatios == nil { finalRatios = adjustedRatios } else { - merged := make(map[string]float64, len(finalRatios)+len(adjustedRatios)) + merged := make(map[string]any, len(finalRatios)+len(adjustedRatios)) for k, v := range finalRatios { merged[k] = v } @@ -259,11 +310,14 @@ func HandleTaskSubmit( } } - // 10. 生成公开任务 ID 并创建记录 + // 10. 生成公开任务 ID 并创建记录(OpenAI Videos 协议用 video_ 前缀对齐官方形态) publicTaskID := generatePublicTaskID() + if rc.Protocol == videosProtocolOpenAI { + publicTaskID = generatePublicTaskIDWithPrefix("video") + } now := time.Now() - privateData, _ := json.Marshal(map[string]any{ + privateDataMap := map[string]any{ "upstream_task_id": upstreamTaskID, "task_type": platform, "billing_context": map[string]any{ @@ -274,7 +328,11 @@ func HandleTaskSubmit( // 进而 Ratios 也读不出、轮询判为「invalid private data」。故此处显式转 float64。 "pre_deduct": preDeductAmount.InexactFloat64(), }, - }) + } + if rc.RequestEcho != nil { + privateDataMap["request_echo"] = rc.RequestEcho + } + privateData, _ := json.Marshal(privateDataMap) task := &common.AsyncTask{ PublicTaskID: publicTaskID, @@ -300,14 +358,30 @@ func HandleTaskSubmit( g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err) } g.Log().Errorf(ctx, "HandleTaskSubmit: create task record failed, publicTaskID=%s, model=%s, err=%v", publicTaskID, modelName, err) + dbgAttempt.MarkFinal(err) writeTaskError(rc.Writer, http.StatusInternalServerError, "create task record failed: "+err.Error(), "") return } // 设置 TaskID 供外层审计使用 rc.TaskID = publicTaskID + // 调试日志:提交成功的最终尝试(段4 由 defer 的 FinalizeAndSubmit 补齐) + dbgAttempt.MarkFinal(nil) - // 11. 返回响应 + // 11. 返回响应(OpenAI Videos 协议返回官方 Video 对象;MiniMax 官方协议返回 {"task_id"} + //(v1 含 base_resp 信封);legacy 保持原格式) + if rc.Protocol == videosProtocolOpenAI { + writeVideosSubmitResponse(rc.Writer, publicTaskID, modelName, now, rc.RequestEcho) + return + } + if rc.Protocol == minimaxVideoProtocol { + writeMiniMaxSubmitResponse(rc.Writer, publicTaskID) + return + } + if rc.Protocol == minimaxVideoV1Protocol { + writeMiniMaxV1SubmitResponse(rc.Writer, publicTaskID) + return + } respBody := map[string]any{ "id": publicTaskID, "status": "SUBMITTED", @@ -410,6 +484,11 @@ func generatePublicTaskID() string { return fmt.Sprintf("task_%s", randomHex(32)) } +// generatePublicTaskIDWithPrefix 生成带指定前缀的公开任务 ID(如 OpenAI Videos 协议的 video_ 前缀) +func generatePublicTaskIDWithPrefix(prefix string) string { + return fmt.Sprintf("%s_%s", prefix, randomHex(32)) +} + func randomHex(n int) string { b := make([]byte, (n+1)/2) if _, err := rand.Read(b); err != nil { diff --git a/relay/handler/videos.go b/relay/handler/videos.go new file mode 100644 index 0000000..a221022 --- /dev/null +++ b/relay/handler/videos.go @@ -0,0 +1,305 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/relay/common" +) + +// OpenAI Videos 协议(/v1/videos)核心处理:Video 对象构建、retrieve / delete / content。 +// 协议规格见 docs/协议文档/OpenAI/OpenAI-Videos-API文档.md。 +// 注意状态机只有 queued/in_progress/completed/failed 四值(无 cancelled), +// 错误分两层:请求层用 HTTP 错误(writeTaskError,OpenAI 格式), +// 生成层失败用 200 + status:"failed" + 内嵌 error 对象表达。 + +// videosProtocolOpenAI TaskRelayContext.Protocol 的 OpenAI Videos 协议标识 +const videosProtocolOpenAI = "openai_videos" + +// videosPollAfterMs 非终态任务 retrieve 响应头 openai-poll-after-ms 的建议轮询间隔(毫秒)。 +// 官方 SDK poll() 优先读取该响应头,缺省时 SDK 自取 1000ms;视频任务耗时长,放慢到 2s。 +const videosPollAfterMs = "2000" + +// videosContentHTTPClient 成品内容回源拉流的 HTTP 客户端(大文件流式转发,整体超时兜底) +var videosContentHTTPClient = &http.Client{Timeout: 120 * time.Second} + +// videoErrorObject 官方 VideoCreateError 的简化形态(misalignment 为 Sora 内容安全特有,第三方上游无对应,不透出) +type videoErrorObject struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// videoObject 官方 Video 对象。可空字段按官方 schema 输出 null(非省略)。 +type videoObject struct { + ID string `json:"id"` + Object string `json:"object"` // 恒为 "video" + CreatedAt int64 `json:"created_at"` + CompletedAt *int64 `json:"completed_at"` + Status string `json:"status"` // queued / in_progress / completed / failed + Progress int `json:"progress"` + Model string `json:"model"` + Prompt *string `json:"prompt"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` + Error *videoErrorObject `json:"error"` + RemixedFromVideoID *string `json:"remixed_from_video_id"` + ExpiresAt *int64 `json:"expires_at"` +} + +// videosStatusFromTask 平台任务状态 → 官方四值状态机映射 +func videosStatusFromTask(status string) string { + switch status { + case "IN_PROGRESS": + return "in_progress" + case "SUCCESS": + return "completed" + case "FAILURE": + return "failed" + default: + // NOT_START / SUBMITTED / QUEUED 及一切未知中间态都归入 queued + return "queued" + } +} + +// videosProgressFromTask 平台进度("42%" 字符串)→ 官方整数百分比。 +// 解析失败时按状态给粗粒度值(第三方上游无进度信息时的兜底)。 +func videosProgressFromTask(task *common.AsyncTask) int { + if n, err := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(task.Progress, "%"))); err == nil { + if n < 0 { + return 0 + } + if n > 100 { + return 100 + } + return n + } + switch videosStatusFromTask(task.Status) { + case "completed": + return 100 + case "in_progress": + return 50 + default: + return 0 + } +} + +// videosEchoFromTask 从 PrivateData.request_echo 提取提交时的请求回显 +func videosEchoFromTask(task *common.AsyncTask) videosRequestEcho { + var echo videosRequestEcho + if len(task.PrivateData) == 0 { + return echo + } + var pd struct { + RequestEcho *videosRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(task.PrivateData, &pd); err == nil && pd.RequestEcho != nil { + echo = *pd.RequestEcho + } + return echo +} + +// buildVideoObject 平台任务 → 官方 Video 对象 +func buildVideoObject(task *common.AsyncTask) *videoObject { + obj := &videoObject{ + ID: task.PublicTaskID, + Object: "video", + CreatedAt: task.CreatedAt.Unix(), + Status: videosStatusFromTask(task.Status), + Progress: videosProgressFromTask(task), + Model: task.ModelName, + } + + echo := videosEchoFromTask(task) + prompt := echo.Prompt + obj.Prompt = &prompt + obj.Seconds = echo.Seconds + obj.Size = echo.Size + + if task.FinishTime != nil { + completedAt := task.FinishTime.Unix() + obj.CompletedAt = &completedAt + } + if obj.Status == "failed" { + message := task.FailReason + if message == "" { + message = "video generation failed" + } + obj.Error = &videoErrorObject{Code: "video_generation_failed", Message: message} + } + return obj +} + +// writeVideosSubmitResponse 提交成功响应:官方 Video 对象(初始 queued / progress 0) +func writeVideosSubmitResponse(w http.ResponseWriter, publicTaskID, modelName string, now time.Time, echo any) { + obj := &videoObject{ + ID: publicTaskID, + Object: "video", + CreatedAt: now.Unix(), + Status: "queued", + Progress: 0, + Model: modelName, + } + if echo != nil { + if e, ok := echo.(videosRequestEcho); ok { + prompt := e.Prompt + obj.Prompt = &prompt + obj.Seconds = e.Seconds + obj.Size = e.Size + } + } + writeJSON(w, http.StatusOK, obj) +} + +// HandleVideosRetrieve GET /v1/videos/{video_id} 查询管线。 +// 租户双键隔离(user_id + tenant_id)复用 GetTaskByPublicIDAndUser;软删除任务已被过滤,天然 404。 +func HandleVideosRetrieve( + ctx context.Context, + videoID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosRetrieve: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + obj := buildVideoObject(task) + // 非终态附带官方轮询提示头(SDK poll() 优先读取) + if obj.Status == "queued" || obj.Status == "in_progress" { + rc.Writer.Header().Set("openai-poll-after-ms", videosPollAfterMs) + } + writeJSON(rc.Writer, http.StatusOK, obj) +} + +// HandleVideosDelete DELETE /v1/videos/{video_id} 删除管线。 +// 对齐官方语义「永久删除已完成/失败的视频及其资产」:仅终态可删; +// 网关侧软删除保留计费与审计记录,删除后查询/下载均按 404 处理(幂等)。 +func HandleVideosDelete( + ctx context.Context, + videoID string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosDelete: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + // 仅终态(completed/failed)可删,进行中任务官方亦不允许删除 + if videosStatusFromTask(task.Status) != "completed" && videosStatusFromTask(task.Status) != "failed" { + writeTaskError(rc.Writer, http.StatusBadRequest, "only completed or failed videos can be deleted", "video_not_deletable") + return + } + // 未结算的终态任务删除会造成退款悬空:结算由轮询 cron 兜底完成后即可删除 + if !task.BillingSettled { + writeTaskError(rc.Writer, http.StatusBadRequest, "video billing is not settled yet, retry later", "billing_pending") + return + } + + if err := dataProvider.SoftDeleteTask(ctx, task); err != nil { + g.Log().Errorf(ctx, "HandleVideosDelete: soft delete failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "delete video failed", "") + return + } + g.Log().Infof(ctx, "HandleVideosDelete: video deleted, videoID=%s, tenant=%d, user=%d", videoID, rc.TenantID, rc.UserID) + + writeJSON(rc.Writer, http.StatusOK, map[string]any{ + "id": task.PublicTaskID, + "object": "video.deleted", + "deleted": true, + }) +} + +// HandleVideosContent GET /v1/videos/{video_id}/content 成品下载管线。 +// 从任务 ResultURL 回源拉流并转发二进制(不透出上游 CDN 直链)。 +// 仅支持官方默认 variant=video;thumbnail/spritesheet 第三方上游无此资产。 +func HandleVideosContent( + ctx context.Context, + videoID string, + variant string, + rc *TaskRelayContext, + dataProvider common.TaskDataProvider, +) { + if variant != "" && variant != "video" { + writeTaskError(rc.Writer, http.StatusBadRequest, "unsupported content variant: "+variant, "unsupported_variant") + return + } + + task, err := dataProvider.GetTaskByPublicIDAndUser(ctx, videoID, rc.UserID, rc.TenantID) + if err != nil { + g.Log().Errorf(ctx, "HandleVideosContent: query task failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "query task failed", "") + return + } + if task == nil { + writeTaskError(rc.Writer, http.StatusNotFound, "No video found with id '"+videoID+"'.", "") + return + } + + // 官方语义:仅 completed 可下载(未完成/失败均报错) + if task.Status != "SUCCESS" { + writeTaskError(rc.Writer, http.StatusBadRequest, "video content is not available until the video is completed", "video_not_ready") + return + } + // 部分上游(如官方 sora)不提供成品直链,需要二跳 content 拉取——当前不支持,明确报错 + if task.ResultURL == "" { + writeTaskError(rc.Writer, http.StatusBadRequest, "video content is not available for this task", "content_not_available") + return + } + + // ResultURL 来自上游响应持久化数据,仍做 scheme 白名单防异构值 + resultURL, err := url.Parse(task.ResultURL) + if err != nil || (resultURL.Scheme != "http" && resultURL.Scheme != "https") { + g.Log().Errorf(ctx, "HandleVideosContent: invalid result url, videoID=%s, url=%q, err=%v", videoID, task.ResultURL, err) + writeTaskError(rc.Writer, http.StatusInternalServerError, "invalid video result url", "") + return + } + + resp, err := videosContentHTTPClient.Get(task.ResultURL) + if err != nil { + // 上游拉流失败属预期内运营事件,用 Warningf 避免自动打栈(对齐任务提交链路日志分级) + g.Log().Warningf(ctx, "HandleVideosContent: fetch upstream failed, videoID=%s, err=%v", videoID, err) + writeTaskError(rc.Writer, http.StatusBadGateway, "failed to fetch video content", "") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + g.Log().Warningf(ctx, "HandleVideosContent: upstream status=%d, videoID=%s", resp.StatusCode, videoID) + writeTaskError(rc.Writer, http.StatusBadGateway, "failed to fetch video content", "") + return + } + + // 透传内容元信息后流式转发字节 + if ct := resp.Header.Get("Content-Type"); ct != "" { + rc.Writer.Header().Set("Content-Type", ct) + } + if cl := resp.Header.Get("Content-Length"); cl != "" { + rc.Writer.Header().Set("Content-Length", cl) + } + rc.Writer.WriteHeader(http.StatusOK) + if _, err := io.Copy(rc.Writer, resp.Body); err != nil { + // 响应头已发出,只能记录日志,无法再改写为错误响应 + g.Log().Warningf(ctx, "HandleVideosContent: stream copy failed, videoID=%s, err=%v", videoID, err) + } +} diff --git a/relay/handler/videos_convert.go b/relay/handler/videos_convert.go new file mode 100644 index 0000000..96f5747 --- /dev/null +++ b/relay/handler/videos_convert.go @@ -0,0 +1,269 @@ +package handler + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// OpenAI Videos 协议(/v1/videos)入站转换层。 +// 官方 SDK(openai-python / openai-node)对 POST /v1/videos 一律发送 multipart/form-data, +// 裸 HTTP 客户端常用 JSON——两种编码都要支持;解析后统一转成任务框架的通用请求体 +// {model, prompt, seconds, metadata{duration, aspect_ratio, resolution, image}}, +// 由各 taskchannel 适配器按需消费(kling 读顶层 seconds,volcengine 读 metadata.duration 等)。 + +// videosInputReferenceMaxBytes 参考图上传大小上限。 +// GoFrame server 默认 ClientMaxBodySize 8MB,base64 膨胀 4/3 后须留余量,取 6MB。 +const videosInputReferenceMaxBytes = 6 << 20 + +// videosCreateRequest OpenAI Videos 创建请求的归一形态(multipart/JSON 解析后的产物) +type videosCreateRequest struct { + Model string + Prompt string + Seconds string // 统一归一为字符串(官方协议为 "4"/"8"/"12");空 = 未指定 + Size string // 原样保留(如 "1280x720");空 = 未指定 + InputReference string // 参考图:data URL 或 http(s) URL;空 = 无 +} + +// videosRequestEcho 提交时落 PrivateData.request_echo 的请求回显, +// retrieve(GET /v1/videos/{id})时用于回显 prompt/seconds/size(上游响应不含这些字段)。 +type videosRequestEcho struct { + Prompt string `json:"prompt"` + Seconds string `json:"seconds,omitempty"` + Size string `json:"size,omitempty"` +} + +// jsonVideosCreateRequest JSON 编码的创建请求结构。 +// input_reference 保留 RawMessage 以区分 {file_id} / {image_url} 两种引用形态。 +type jsonVideosCreateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Seconds json.RawMessage `json:"seconds"` // string 或 number + Size string `json:"size"` + InputReference json.RawMessage `json:"input_reference"` +} + +// ParseVideosCreateRequest 解析 OpenAI Videos 创建请求(multipart 或 JSON)。 +// 返回的 TaskError 已携带面向客户端的 HTTP 状态码与错误信息。 +func ParseVideosCreateRequest(body []byte, contentType string) (*videosCreateRequest, *common.TaskError) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = contentType // 非法 Content-Type 按 JSON 尝试解析 + } + + if strings.HasPrefix(mediaType, "multipart/") { + return parseVideosCreateMultipart(body, params["boundary"]) + } + return parseVideosCreateJSON(body) +} + +// parseVideosCreateJSON 解析 JSON 编码的创建请求 +func parseVideosCreateJSON(body []byte) (*videosCreateRequest, *common.TaskError) { + var req jsonVideosCreateRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body: " + err.Error()} + } + + out := &videosCreateRequest{ + Model: strings.TrimSpace(req.Model), + Prompt: req.Prompt, + Size: strings.TrimSpace(req.Size), + } + + seconds, terr := normalizeVideosSeconds(req.Seconds) + if terr != nil { + return nil, terr + } + out.Seconds = seconds + + ref, terr := parseVideosInputReferenceJSON(req.InputReference) + if terr != nil { + return nil, terr + } + out.InputReference = ref + + return out, nil +} + +// parseVideosCreateMultipart 解析 multipart/form-data 编码的创建请求(官方 SDK 形态)。 +// input_reference 可能是文件 part(上传),也可能是字符串 part(引用对象 JSON)。 +func parseVideosCreateMultipart(body []byte, boundary string) (*videosCreateRequest, *common.TaskError) { + if boundary == "" { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "missing multipart boundary"} + } + + form, err := multipart.NewReader(bytes.NewReader(body), boundary).ReadForm(videosInputReferenceMaxBytes) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid multipart form: " + err.Error()} + } + defer form.RemoveAll() + + formValue := func(name string) string { + if vals := form.Value[name]; len(vals) > 0 { + return vals[0] + } + return "" + } + + out := &videosCreateRequest{ + Model: strings.TrimSpace(formValue("model")), + Prompt: formValue("prompt"), + Size: strings.TrimSpace(formValue("size")), + } + + seconds, terr := normalizeVideosSeconds(json.RawMessage(formValue("seconds"))) + if terr != nil { + return nil, terr + } + out.Seconds = seconds + + // input_reference:优先文件 part,其次字符串 part(引用对象 JSON) + if files := form.File["input_reference"]; len(files) > 0 { + fh := files[0] + f, err := fh.Open() + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "failed to open input_reference file"} + } + defer f.Close() + + raw, err := io.ReadAll(io.LimitReader(f, videosInputReferenceMaxBytes+1)) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "failed to read input_reference file"} + } + if len(raw) > videosInputReferenceMaxBytes { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "input_reference file too large, max 6MB"} + } + if len(raw) == 0 { + return nil, &common.TaskError{StatusCode: http.StatusBadRequest, Message: "input_reference file is empty"} + } + + // 内容类型探测优先于 part 声明的 Content-Type(客户端常误标 application/octet-stream) + ct := fh.Header.Get("Content-Type") + if ct == "" || ct == "application/octet-stream" { + ct = http.DetectContentType(raw) + } + out.InputReference = "data:" + ct + ";base64," + base64.StdEncoding.EncodeToString(raw) + } else if v := formValue("input_reference"); v != "" { + ref, terr := parseVideosInputReferenceJSON(json.RawMessage(v)) + if terr != nil { + return nil, terr + } + out.InputReference = ref + } + + return out, nil +} + +// normalizeVideosSeconds 归一 seconds 字段:接受字符串(官方形态)或数字(宽松兼容),统一转字符串。 +// 不做 4/8/12 白名单校验——第三方视频模型时长档位各异,透传给适配器自行对齐。 +func normalizeVideosSeconds(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 { + return "", nil + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s), nil + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + if _, convErr := strconv.ParseFloat(n.String(), 64); convErr == nil { + return n.String(), nil + } + } + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid seconds field: must be a string or number"} +} + +// parseVideosInputReferenceJSON 解析引用对象形态的 input_reference: +// - {"image_url": "https://... 或 data:..."} → 原样透传 +// - {"file_id": "..."} → 不支持(平台无 Files API),返回 400 引导改用 image_url/文件上传 +func parseVideosInputReferenceJSON(raw json.RawMessage) (string, *common.TaskError) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + + var ref struct { + FileID string `json:"file_id"` + ImageURL string `json:"image_url"` + } + if err := json.Unmarshal(raw, &ref); err != nil { + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid input_reference: expected file upload or {image_url}"} + } + switch { + case ref.ImageURL != "": + return ref.ImageURL, nil + case ref.FileID != "": + return "", &common.TaskError{ + StatusCode: http.StatusBadRequest, + Message: "input_reference.file_id is not supported; upload the file directly or use image_url instead", + ErrCode: "file_id_not_supported", + } + default: + return "", &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid input_reference: expected file upload or {image_url}"} + } +} + +// BuildVideosTaskBody 把归一后的创建请求转成任务框架通用请求体(JSON 字节)。 +// 顶层保留 seconds + images,metadata 写入 duration/size/image。 +// size 只借 OpenAI 协议的字段形态、不做任何档位换算:调用方直接传目标供应商的 +// 原生分辨率词汇(如 MiniMax 的 768P/2K),原值经 metadata.size 透传给适配器。 +func BuildVideosTaskBody(req *videosCreateRequest) ([]byte, error) { + body := map[string]any{ + "model": req.Model, + "prompt": req.Prompt, + } + metadata := map[string]any{} + + if req.Seconds != "" { + body["seconds"] = req.Seconds + if d, err := strconv.Atoi(req.Seconds); err == nil { + metadata["duration"] = d + } + } + if req.Size != "" { + metadata["size"] = req.Size + } + if req.InputReference != "" { + metadata["image"] = req.InputReference + // volcengine 图生视频读根级 images 数组(kling 亦有该兜底) + body["images"] = []string{req.InputReference} + } + if len(metadata) > 0 { + body["metadata"] = metadata + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal videos task body: %w", err) + } + return data, nil +} + +// ValidateVideosCreateRequest 请求必填校验(model/prompt 官方均为必填) +func ValidateVideosCreateRequest(req *videosCreateRequest) *common.TaskError { + if req.Model == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "model is required"} + } + if strings.TrimSpace(req.Prompt) == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"} + } + return nil +} + +// NewVideosRequestEcho 从归一请求构造提交回显(胶水层经 TaskRelayContext.RequestEcho 传入, +// 提交时随 PrivateData 落库,retrieve 时回显 prompt/seconds/size) +func NewVideosRequestEcho(req *videosCreateRequest) videosRequestEcho { + return videosRequestEcho{ + Prompt: req.Prompt, + Seconds: req.Seconds, + Size: req.Size, + } +} diff --git a/relay/handler/videos_convert_test.go b/relay/handler/videos_convert_test.go new file mode 100644 index 0000000..58fcbba --- /dev/null +++ b/relay/handler/videos_convert_test.go @@ -0,0 +1,225 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/textproto" + "strings" + "testing" +) + +// buildMultipartVideosBody 构造 multipart/form-data 的创建请求体(模拟官方 SDK 形态)。 +// 文件 part 的 Content-Type 统一声明为 application/octet-stream,以覆盖内容类型嗅探路径。 +func buildMultipartVideosBody(t *testing.T, fields map[string]string, fileField, fileName string, fileBytes []byte) ([]byte, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("write field %s: %v", k, err) + } + } + if fileField != "" { + h := textproto.MIMEHeader{} + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileField, fileName)) + h.Set("Content-Type", "application/octet-stream") + part, err := w.CreatePart(h) + if err != nil { + t.Fatalf("create file part: %v", err) + } + if _, err := part.Write(fileBytes); err != nil { + t.Fatalf("write file part: %v", err) + } + } + if err := w.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + return buf.Bytes(), w.FormDataContentType() +} + +func TestParseVideosCreateRequest_JSON(t *testing.T) { + body := `{"model":"kling-v2-master","prompt":"a cat","seconds":"8","size":"1280x720","input_reference":{"image_url":"https://example.com/ref.png"}}` + req, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "kling-v2-master" || req.Prompt != "a cat" || req.Seconds != "8" || req.Size != "1280x720" { + t.Fatalf("unexpected request: %+v", req) + } + if req.InputReference != "https://example.com/ref.png" { + t.Fatalf("unexpected input_reference: %s", req.InputReference) + } +} + +func TestParseVideosCreateRequest_JSONSecondsNumber(t *testing.T) { + body := `{"model":"m","prompt":"p","seconds":8}` + req, taskErr := ParseVideosCreateRequest([]byte(body), "application/json; charset=utf-8") + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Seconds != "8" { + t.Fatalf("expected seconds normalized to \"8\", got %q", req.Seconds) + } +} + +func TestParseVideosCreateRequest_JSONInvalidSeconds(t *testing.T) { + body := `{"model":"m","prompt":"p","seconds":true}` + _, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr == nil || taskErr.StatusCode != 400 { + t.Fatalf("expected 400 for bool seconds, got %+v", taskErr) + } +} + +func TestParseVideosCreateRequest_JSONFileIDRejected(t *testing.T) { + body := `{"model":"m","prompt":"p","input_reference":{"file_id":"file-abc"}}` + _, taskErr := ParseVideosCreateRequest([]byte(body), "application/json") + if taskErr == nil { + t.Fatal("expected file_id to be rejected") + } + if taskErr.StatusCode != 400 || taskErr.ErrCode != "file_id_not_supported" { + t.Fatalf("unexpected error: %+v", taskErr) + } +} + +func TestParseVideosCreateRequest_MultipartWithFile(t *testing.T) { + // PNG 魔数,DetectContentType 应识别为 image/png(part 声明为 octet-stream) + pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D} + body, ct := buildMultipartVideosBody(t, + map[string]string{"model": "kling-v2-master", "prompt": "a cat", "seconds": "12", "size": "720x1280"}, + "input_reference", "ref.png", pngBytes) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.Model != "kling-v2-master" || req.Seconds != "12" || req.Size != "720x1280" { + t.Fatalf("unexpected request: %+v", req) + } + if !strings.HasPrefix(req.InputReference, "data:image/png;base64,") { + t.Fatalf("expected data url with sniffed image/png, got %.40q", req.InputReference) + } +} + +func TestParseVideosCreateRequest_MultipartStringRef(t *testing.T) { + body, ct := buildMultipartVideosBody(t, + map[string]string{ + "model": "m", + "prompt": "p", + "input_reference": `{"image_url":"https://example.com/x.jpg"}`, + }, "", "", nil) + + req, taskErr := ParseVideosCreateRequest(body, ct) + if taskErr != nil { + t.Fatalf("unexpected error: %+v", taskErr) + } + if req.InputReference != "https://example.com/x.jpg" { + t.Fatalf("unexpected input_reference: %s", req.InputReference) + } +} + +// TestBuildVideosTaskBody_SizePassthrough size 只借协议字段形态、不做档位换算: +// 原值进 metadata.size,不派生 aspect_ratio/ratio/resolution(值词汇由调用方按目标供应商原生口径提供)。 +func TestBuildVideosTaskBody_SizePassthrough(t *testing.T) { + req := &videosCreateRequest{ + Model: "kling-v2-master", + Prompt: "a cat", + Seconds: "8", + Size: "768P", + InputReference: "https://example.com/ref.png", + } + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed["model"] != "kling-v2-master" || parsed["prompt"] != "a cat" { + t.Fatalf("unexpected body: %s", body) + } + if parsed["seconds"] != "8" { + t.Fatalf("expected top-level seconds \"8\", got %v", parsed["seconds"]) + } + + meta, ok := parsed["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object: %s", body) + } + if meta["duration"] != float64(8) { + t.Fatalf("expected metadata.duration 8, got %v", meta["duration"]) + } + if meta["size"] != "768P" { + t.Fatalf("expected raw size passthrough, got %v", meta["size"]) + } + // 不做换算:不从 size 派生任何其他参数键 + for _, key := range []string{"aspect_ratio", "ratio", "resolution"} { + if _, has := meta[key]; has { + t.Fatalf("size must not derive %s: %s", key, body) + } + } + if meta["image"] != "https://example.com/ref.png" { + t.Fatalf("expected metadata.image, got %v", meta["image"]) + } + images, ok := parsed["images"].([]any) + if !ok || len(images) != 1 || images[0] != "https://example.com/ref.png" { + t.Fatalf("expected root images array with reference, got %v", parsed["images"]) + } +} + +func TestBuildVideosTaskBody_Minimal(t *testing.T) { + req := &videosCreateRequest{Model: "m", Prompt: "p"} + body, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build: %v", err) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, ok := parsed["seconds"]; ok { + t.Fatal("expected no seconds field") + } + if _, ok := parsed["images"]; ok { + t.Fatal("expected no images field without input_reference") + } + if _, ok := parsed["metadata"]; ok { + t.Fatal("expected no metadata field") + } +} + +func TestBuildVideosTaskBody_UnknownSizeNoRatio(t *testing.T) { + req := &videosCreateRequest{Model: "m", Prompt: "p", Size: "1920x1080"} + body, _ := BuildVideosTaskBody(req) + var parsed map[string]any + json.Unmarshal(body, &parsed) + meta := parsed["metadata"].(map[string]any) + if _, ok := meta["aspect_ratio"]; ok { + t.Fatal("unknown size must not map aspect_ratio") + } + if _, ok := meta["ratio"]; ok { + t.Fatal("unknown size must not map ratio") + } + if _, ok := meta["resolution"]; ok { + t.Fatal("unknown size must not map resolution") + } + if meta["size"] != "1920x1080" { + t.Fatalf("expected raw size passthrough, got %v", meta["size"]) + } +} + +func TestValidateVideosCreateRequest(t *testing.T) { + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Model: "m", Prompt: "p"}); taskErr != nil { + t.Fatalf("unexpected: %+v", taskErr) + } + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Prompt: "p"}); taskErr == nil || taskErr.Message != "model is required" { + t.Fatalf("expected model required, got %+v", taskErr) + } + if taskErr := ValidateVideosCreateRequest(&videosCreateRequest{Model: "m", Prompt: " "}); taskErr == nil || taskErr.Message != "prompt is required" { + t.Fatalf("expected prompt required, got %+v", taskErr) + } +} diff --git a/relay/handler/videos_pipeline_test.go b/relay/handler/videos_pipeline_test.go new file mode 100644 index 0000000..998356a --- /dev/null +++ b/relay/handler/videos_pipeline_test.go @@ -0,0 +1,334 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/shopspring/decimal" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// 端到端集成测试:OpenAI Videos 协议(multipart)→ 转换 → 任务提交管线(kling 适配器 + mock 上游) +// → video_ 前缀 ID + 官方 Video 对象响应 + 请求回显落 PrivateData + 上游收到映射后的 kling 参数。 + +// stubVideosBillingProvider 提交路径的 TaskBillingProvider 桩(计费全放行,金额固定) +type stubVideosBillingProvider struct { + common.TaskBillingProvider +} + +func (s *stubVideosBillingProvider) EstimateTaskCost(context.Context, int64, string, map[string]any, []byte) (decimal.Decimal, error) { + return decimal.NewFromFloat(0.1), nil +} + +func (s *stubVideosBillingProvider) PreDeductTask(_ context.Context, _ int64, _ string, cost decimal.Decimal, _ string) (decimal.Decimal, error) { + return cost, nil +} + +func (s *stubVideosBillingProvider) CheckRateLimit(context.Context, int64, int64, int64, int) (bool, string, int, int, int64) { + return true, "", 0, 0, 0 +} + +func (s *stubVideosBillingProvider) AcquireApiKeyConcurrent(context.Context, int64, int) bool { + return true +} +func (s *stubVideosBillingProvider) ReleaseApiKeyConcurrent(context.Context, int64) {} +func (s *stubVideosBillingProvider) CheckApiKeyQuota(context.Context, int64, decimal.Decimal) error { + return nil +} + +func (s *stubVideosBillingProvider) AdjustTaskBilling(_ context.Context, _ int64, _ string, _, newCost decimal.Decimal) (decimal.Decimal, error) { + return newCost, nil +} + +// stubVideosSubmitProvider 提交路径的 TaskDataProvider 桩(记录创建的任务) +type stubVideosSubmitProvider struct { + common.TaskDataProvider + + created *common.AsyncTask +} + +func (s *stubVideosSubmitProvider) CreateTask(_ context.Context, task *common.AsyncTask) error { + s.created = task + return nil +} + +func (s *stubVideosSubmitProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.created != nil && s.created.PublicTaskID == publicTaskID { + return s.created, nil + } + return nil, nil +} + +func TestHandleTaskSubmit_OpenAIVideosProtocol_FullPipeline(t *testing.T) { + // 1. mock kling 上游:记录收到的请求体,返回标准提交成功响应 + var upstreamReceived map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/v1/videos/text2video") { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &upstreamReceived); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "message": "success", + "data": map[string]any{"task_id": "kling-up-1", "task_status": "submitted"}, + }) + })) + defer upstream.Close() + + // 2. multipart 创建请求 → 归一 → 通用任务体(与胶水层 HandleVideoCreate 相同链路) + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "kling-v2-master", + "prompt": "a cat surfing", + "seconds": "8", + "size": "1280x720", + }, "", "", nil) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + // 3. 跑完整提交管线(kling 适配器 + mock 上游 + 计费/存储桩) + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_videos_test", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + channelMeta := &common.ChannelMeta{ + ChannelID: 11, + ChannelType: int(constant.ProviderKling), + ChannelName: "mock-kling", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "kling-v2-master", + } + billing := &stubVideosBillingProvider{} + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, billing, channelMeta) + + // 4. 断言客户端响应:官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" || resp["progress"] != float64(0) { + t.Fatalf("unexpected video object: %s", rec.Body.String()) + } + videoID, _ := resp["id"].(string) + if !strings.HasPrefix(videoID, "video_") { + t.Fatalf("expected video_ prefixed id, got %q", videoID) + } + if resp["model"] != "kling-v2-master" || resp["seconds"] != "8" || resp["size"] != "1280x720" { + t.Fatalf("expected echo fields in submit response: %s", rec.Body.String()) + } + if prompt, _ := resp["prompt"].(string); prompt != "a cat surfing" { + t.Fatalf("expected prompt echo: %s", rec.Body.String()) + } + if rc.TaskID != videoID { + t.Fatalf("rc.TaskID=%q should match response id %q", rc.TaskID, videoID) + } + + // 5. 断言落库任务:video_ ID、kling 平台、PrivateData 含 request_echo + if data.created == nil { + t.Fatal("expected task record created") + } + if data.created.PublicTaskID != videoID { + t.Fatalf("created task id=%q", data.created.PublicTaskID) + } + if data.created.Platform != "kling" { + t.Fatalf("expected platform kling, got %q", data.created.Platform) + } + var pd struct { + UpstreamTaskID string `json:"upstream_task_id"` + RequestEcho *videosRequestEcho `json:"request_echo"` + } + if err := json.Unmarshal(data.created.PrivateData, &pd); err != nil { + t.Fatalf("private data unmarshal: %v", err) + } + if pd.UpstreamTaskID != "kling-up-1" { + t.Fatalf("expected upstream task id captured, got %q", pd.UpstreamTaskID) + } + if pd.RequestEcho == nil || pd.RequestEcho.Seconds != "8" || pd.RequestEcho.Size != "1280x720" { + t.Fatalf("expected request echo persisted: %+v", pd.RequestEcho) + } + + // 6. 断言上游收到映射后的 kling 参数(seconds→duration;size 不换算,kling 不消费该键) + if upstreamReceived["model_name"] != "kling-v2-master" { + t.Fatalf("upstream model_name: %v", upstreamReceived["model_name"]) + } + if upstreamReceived["duration"] != "8" { + t.Fatalf("upstream duration: %v", upstreamReceived["duration"]) + } + if upstreamReceived["prompt"] != "a cat surfing" { + t.Fatalf("upstream prompt: %v", upstreamReceived["prompt"]) + } +} + +func TestHandleTaskSubmit_OpenAIVideosProtocol_Seedance(t *testing.T) { + // mock 火山引擎(seedance)上游:提交端点返回任务 ID + var upstreamReceived map[string]any + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/v3/contents/generations/tasks") { + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &upstreamReceived); err != nil { + t.Errorf("upstream body not json: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-seedance-1"}) + })) + defer upstream.Close() + + // multipart 创建请求:图生视频(input_reference 文件)+ 高清横屏档 + pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + mBody, ct := buildMultipartVideosBody(t, map[string]string{ + "model": "doubao-seedance-2-0-260128", + "prompt": "小猫对着镜头打哈欠", + "seconds": "12", + "size": "1792x1024", + }, "input_reference", "ref.png", pngBytes) + req, taskErr := ParseVideosCreateRequest(mBody, ct) + if taskErr != nil { + t.Fatalf("parse: %+v", taskErr) + } + taskBody, err := BuildVideosTaskBody(req) + if err != nil { + t.Fatalf("build task body: %v", err) + } + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_seedance_test", + Writer: rec, + Protocol: videosProtocolOpenAI, + RelayMode: int(constant.RelayModeVideos), + RequestEcho: NewVideosRequestEcho(req), + } + channelMeta := &common.ChannelMeta{ + ChannelID: 12, + ChannelType: int(constant.ProviderVolcengine), + ChannelName: "mock-volcengine", + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "doubao-seedance-2-0-260128", + } + data := &stubVideosSubmitProvider{} + + HandleTaskSubmit(context.Background(), taskBody, "/v1/videos", http.Header{}, rc, data, &stubVideosBillingProvider{}, channelMeta) + + // 客户端响应:官方 Video 对象 + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["object"] != "video" || resp["status"] != "queued" { + t.Fatalf("unexpected video object: %s", rec.Body.String()) + } + if resp["seconds"] != "12" || resp["size"] != "1792x1024" { + t.Fatalf("expected echo fields: %s", rec.Body.String()) + } + if data.created == nil || data.created.Platform != "volcengine" { + t.Fatalf("expected volcengine task record, got %+v", data.created) + } + + // 上游收到的 seedance 参数:content 文本+首帧图、duration 对上; + // size 不再换算派生(resolution/ratio 由 volcengine 上游默认值兜底) + if upstreamReceived["model"] != "doubao-seedance-2-0-260128" { + t.Fatalf("upstream model: %v", upstreamReceived["model"]) + } + if upstreamReceived["duration"] != float64(12) { + t.Fatalf("upstream duration: %v (%T)", upstreamReceived["duration"], upstreamReceived["duration"]) + } + if _, has := upstreamReceived["resolution"]; has { + t.Fatalf("size must not derive resolution for volcengine: %v", upstreamReceived["resolution"]) + } + if _, has := upstreamReceived["ratio"]; has { + t.Fatalf("size must not derive ratio for volcengine: %v", upstreamReceived["ratio"]) + } + content, ok := upstreamReceived["content"].([]any) + if !ok || len(content) != 2 { + t.Fatalf("expected text + first_frame image content items, got %v", upstreamReceived["content"]) + } + textItem, _ := content[0].(map[string]any) + if textItem["type"] != "text" || textItem["text"] != "小猫对着镜头打哈欠" { + t.Fatalf("unexpected text item: %v", content[0]) + } + imgItem, _ := content[1].(map[string]any) + if imgItem["type"] != "image_url" { + t.Fatalf("expected image_url item, got %v", content[1]) + } + imgURL, _ := imgItem["image_url"].(map[string]any) + urlStr, _ := imgURL["url"].(string) + if !strings.HasPrefix(urlStr, "data:image/png;base64,") { + t.Fatalf("expected base64 data url as first frame, got %.40q", urlStr) + } +} + +func TestHandleTaskSubmit_LegacyProtocolUnchanged(t *testing.T) { + // 回归:Protocol 为空(legacy)时提交响应保持 {id: task_*, status: SUBMITTED} 旧格式 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "message": "success", + "data": map[string]any{"task_id": "kling-up-2", "task_status": "submitted"}, + }) + })) + defer upstream.Close() + + rec := httptest.NewRecorder() + rc := &TaskRelayContext{ + TenantID: 1, + UserID: 2, + ApiKeyID: 3, + RequestID: "req_legacy_test", + Writer: rec, + } + channelMeta := &common.ChannelMeta{ + ChannelID: 11, + ChannelType: int(constant.ProviderKling), + BaseURL: upstream.URL, + ApiKey: "sk-test", + UpstreamModelName: "kling-v2-master", + } + body := []byte(`{"model":"kling-v2-master","prompt":"legacy","metadata":{"duration":5}}`) + HandleTaskSubmit(context.Background(), body, "/v1/video/generations", http.Header{}, rc, &stubVideosSubmitProvider{}, &stubVideosBillingProvider{}, channelMeta) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + resp := decodeVideosJSON(t, rec) + if resp["status"] != "SUBMITTED" { + t.Fatalf("legacy submit must keep SUBMITTED status: %s", rec.Body.String()) + } + if id, _ := resp["id"].(string); !strings.HasPrefix(id, "task_") { + t.Fatalf("legacy submit must keep task_ prefix: %s", rec.Body.String()) + } + if _, hasObject := resp["object"]; hasObject { + t.Fatalf("legacy submit must not carry video object field: %s", rec.Body.String()) + } +} diff --git a/relay/handler/videos_test.go b/relay/handler/videos_test.go new file mode 100644 index 0000000..abe3269 --- /dev/null +++ b/relay/handler/videos_test.go @@ -0,0 +1,336 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/qianfree/team-api/relay/common" +) + +// stubVideosTaskProvider 查询/删除路径的 TaskDataProvider 桩: +// 嵌入接口零值,未覆盖的方法被调用时 panic(测试应只触达覆盖的方法)。 +type stubVideosTaskProvider struct { + common.TaskDataProvider + + task *common.AsyncTask + fetchErr error + deleteCalls int + deleteErr error +} + +func (s *stubVideosTaskProvider) GetTaskByPublicIDAndUser(_ context.Context, publicTaskID string, _ int64, _ int64) (*common.AsyncTask, error) { + if s.fetchErr != nil { + return nil, s.fetchErr + } + if s.task != nil && s.task.PublicTaskID == publicTaskID { + return s.task, nil + } + return nil, nil +} + +func (s *stubVideosTaskProvider) SoftDeleteTask(_ context.Context, task *common.AsyncTask) error { + s.deleteCalls++ + if s.deleteErr != nil { + return s.deleteErr + } + task.Deleted = true + return nil +} + +func newVideosTestTask() *common.AsyncTask { + finish := time.Unix(1760000432, 0) + privateData, _ := json.Marshal(map[string]any{ + "upstream_task_id": "up-1", + "task_type": "kling", + "request_echo": map[string]any{"prompt": "a cat", "seconds": "8", "size": "1280x720"}, + }) + return &common.AsyncTask{ + ID: 1, + PublicTaskID: "video_abc", + Status: "SUCCESS", + Progress: "100%", + ModelName: "kling-v2-master", + ResultURL: "https://cdn.example.com/v.mp4", + BillingSettled: true, + FinishTime: &finish, + CreatedAt: time.Unix(1760000000, 0), + PrivateData: privateData, + TenantID: 7, + UserID: 9, + } +} + +func videosTestRC(w http.ResponseWriter) *TaskRelayContext { + return &TaskRelayContext{TenantID: 7, UserID: 9, Writer: w} +} + +func decodeVideosJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode json: %v, body=%s", err, rec.Body.String()) + } + return m +} + +func TestVideosStatusFromTask(t *testing.T) { + cases := map[string]string{ + "NOT_START": "queued", + "SUBMITTED": "queued", + "QUEUED": "queued", + "IN_PROGRESS": "in_progress", + "SUCCESS": "completed", + "FAILURE": "failed", + "WEIRD": "queued", + } + for in, want := range cases { + if got := videosStatusFromTask(in); got != want { + t.Errorf("videosStatusFromTask(%q) = %q, want %q", in, got, want) + } + } +} + +func TestVideosProgressFromTask(t *testing.T) { + makeTask := func(status, progress string) *common.AsyncTask { + return &common.AsyncTask{Status: status, Progress: progress} + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "42%")); got != 42 { + t.Errorf("42%% → %d", got) + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "150%")); got != 100 { + t.Errorf("150%% should clamp to 100, got %d", got) + } + if got := videosProgressFromTask(makeTask("IN_PROGRESS", "")); got != 50 { + t.Errorf("fallback in_progress → 50, got %d", got) + } + if got := videosProgressFromTask(makeTask("SUCCESS", "")); got != 100 { + t.Errorf("fallback success → 100, got %d", got) + } +} + +func TestBuildVideoObject_SuccessWithEcho(t *testing.T) { + obj := buildVideoObject(newVideosTestTask()) + if obj.ID != "video_abc" || obj.Object != "video" || obj.Status != "completed" || obj.Progress != 100 { + t.Fatalf("unexpected object: %+v", obj) + } + if obj.Model != "kling-v2-master" || obj.Prompt == nil || *obj.Prompt != "a cat" { + t.Fatalf("unexpected model/prompt: %+v", obj) + } + if obj.Seconds != "8" || obj.Size != "1280x720" { + t.Fatalf("expected echo seconds/size, got %q/%q", obj.Seconds, obj.Size) + } + if obj.CompletedAt == nil || *obj.CompletedAt != 1760000432 { + t.Fatalf("unexpected completed_at: %v", obj.CompletedAt) + } + if obj.Error != nil || obj.RemixedFromVideoID != nil || obj.ExpiresAt != nil { + t.Fatalf("nullables must be nil: %+v", obj) + } +} + +func TestBuildVideoObject_Failure(t *testing.T) { + task := newVideosTestTask() + task.Status = "FAILURE" + task.Progress = "30%" + task.FailReason = "upstream exploded" + obj := buildVideoObject(task) + if obj.Status != "failed" || obj.Error == nil { + t.Fatalf("expected failed with error: %+v", obj) + } + if obj.Error.Code != "video_generation_failed" || obj.Error.Message != "upstream exploded" { + t.Fatalf("unexpected error object: %+v", obj.Error) + } +} + +func TestHandleVideosRetrieve_FoundAndPollHeader(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + task.Progress = "42%" + task.FinishTime = nil + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("openai-poll-after-ms"); got != videosPollAfterMs { + t.Fatalf("expected poll header %q, got %q", videosPollAfterMs, got) + } + m := decodeVideosJSON(t, rec) + if m["status"] != "in_progress" || m["progress"] != float64(42) || m["object"] != "video" { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestHandleVideosRetrieve_TerminalNoPollHeader(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosRetrieve(context.Background(), "video_abc", videosTestRC(rec), provider) + if got := rec.Header().Get("openai-poll-after-ms"); got != "" { + t.Fatalf("terminal task must not carry poll header, got %q", got) + } +} + +func TestHandleVideosRetrieve_NotFound(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{} + HandleVideosRetrieve(context.Background(), "video_missing", videosTestRC(rec), provider) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +func TestHandleVideosDelete_HappyPath(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if provider.deleteCalls != 1 { + t.Fatalf("expected 1 soft delete call, got %d", provider.deleteCalls) + } + m := decodeVideosJSON(t, rec) + if m["object"] != "video.deleted" || m["deleted"] != true || m["id"] != "video_abc" { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_NonTerminalRejected(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + if provider.deleteCalls != 0 { + t.Fatal("must not delete non-terminal task") + } + if !strings.Contains(rec.Body.String(), "video_not_deletable") { + t.Fatalf("expected video_not_deletable code, body=%s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_UnsettledRejected(t *testing.T) { + task := newVideosTestTask() + task.BillingSettled = false + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosDelete(context.Background(), "video_abc", videosTestRC(rec), provider) + + if rec.Code != http.StatusBadRequest || provider.deleteCalls != 0 { + t.Fatalf("expected 400 without delete, got %d calls=%d", rec.Code, provider.deleteCalls) + } + if !strings.Contains(rec.Body.String(), "billing_pending") { + t.Fatalf("expected billing_pending code, body=%s", rec.Body.String()) + } +} + +func TestHandleVideosDelete_NotFound(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{} + HandleVideosDelete(context.Background(), "video_gone", videosTestRC(rec), provider) + if rec.Code != http.StatusNotFound || provider.deleteCalls != 0 { + t.Fatalf("expected 404 without delete, got %d", rec.Code) + } +} + +func TestHandleVideosContent_StreamsBytes(t *testing.T) { + content := []byte("FAKE-MP4-BYTES") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "video/mp4") + w.Write(content) + })) + defer upstream.Close() + + task := newVideosTestTask() + task.ResultURL = upstream.URL + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "video", videosTestRC(rec), provider) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "video/mp4" { + t.Fatalf("expected video/mp4 passthrough, got %q", ct) + } + if rec.Body.String() != string(content) { + t.Fatalf("body mismatch: %q", rec.Body.String()) + } +} + +func TestHandleVideosContent_VariantUnsupported(t *testing.T) { + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: newVideosTestTask()} + HandleVideosContent(context.Background(), "video_abc", "thumbnail", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestHandleVideosContent_NotCompleted(t *testing.T) { + task := newVideosTestTask() + task.Status = "IN_PROGRESS" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "video_not_ready") { + t.Fatalf("expected 400 video_not_ready, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleVideosContent_NoResultURL(t *testing.T) { + task := newVideosTestTask() + task.ResultURL = "" + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "content_not_available") { + t.Fatalf("expected 400 content_not_available, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestHandleVideosContent_UpstreamFailure(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer upstream.Close() + + task := newVideosTestTask() + task.ResultURL = upstream.URL + + rec := httptest.NewRecorder() + provider := &stubVideosTaskProvider{task: task} + HandleVideosContent(context.Background(), "video_abc", "", videosTestRC(rec), provider) + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", rec.Code) + } +} + +func TestWriteVideosSubmitResponse(t *testing.T) { + rec := httptest.NewRecorder() + writeVideosSubmitResponse(rec, "video_new", "kling-v2-master", time.Unix(1760000000, 0), videosRequestEcho{Prompt: "p", Seconds: "8", Size: "720x1280"}) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + m := decodeVideosJSON(t, rec) + if m["id"] != "video_new" || m["object"] != "video" || m["status"] != "queued" || m["progress"] != float64(0) { + t.Fatalf("unexpected submit response: %s", rec.Body.String()) + } + if m["seconds"] != "8" || m["size"] != "720x1280" { + t.Fatalf("expected echo fields: %s", rec.Body.String()) + } +} diff --git a/relay/taskchannel/ali/adaptor.go b/relay/taskchannel/ali/adaptor.go index 7496bda..ed50fe8 100644 --- a/relay/taskchannel/ali/adaptor.go +++ b/relay/taskchannel/ali/adaptor.go @@ -140,25 +140,27 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod return nil } -func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 { - ratios := map[string]float64{"base": 1.0} +func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + ratios := map[string]any{"base": 1.0} var req map[string]any if json.Unmarshal(body, &req) != nil { return ratios } if a.isVideo { - // duration 影响计费 + // duration 影响计费:spec.duration 为真实秒数(per_second 用),duration 为存量信号 metadata := taskchannel.ExtractMetadata(req) var meta struct { Duration *int `json:"duration"` } if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 { ratios["duration"] = float64(*meta.Duration) + ratios["spec.duration"] = float64(*meta.Duration) } if seconds, ok := req["seconds"].(string); ok { if d, err := parseInt(seconds); err == nil && d > 0 { ratios["duration"] = float64(d) + ratios["spec.duration"] = float64(d) } } } else { @@ -171,7 +173,7 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod return ratios } -func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 { +func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { return nil } @@ -305,12 +307,12 @@ func (a *AliAdaptor) buildImageRequest(info *common.RelayInfo, req map[string]an return strings.NewReader(string(data)), nil } -func (a *AliAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *AliAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, url, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } @@ -363,7 +365,7 @@ func (a *AliAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *commo return result.Output.TaskID, body, nil } -func (a *AliAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *AliAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -373,7 +375,7 @@ func (a *AliAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.R } url := fmt.Sprintf("%s/api/v1/tasks/%s", strings.TrimRight(baseURL, "/"), data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } diff --git a/relay/taskchannel/gemini/adaptor.go b/relay/taskchannel/gemini/adaptor.go index 85b324f..b1d3e6b 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 } @@ -116,12 +124,12 @@ func (a *GeminiAdaptor) BuildRequestBody(_ context.Context, info *common.RelayIn return strings.NewReader(string(data)), nil } -func (a *GeminiAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *GeminiAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -163,7 +171,7 @@ func (a *GeminiAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *co return result.Name, body, nil } -func (a *GeminiAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *GeminiAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -173,7 +181,7 @@ func (a *GeminiAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*htt } url := fmt.Sprintf("%s/v1beta/%s", strings.TrimRight(baseURL, "/"), data.TaskID) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } diff --git a/relay/taskchannel/kling/adaptor.go b/relay/taskchannel/kling/adaptor.go index 1f86878..c7cdcf4 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 } @@ -271,7 +271,7 @@ func (a *KlingAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInf return strings.NewReader(string(data)), nil } -func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *KlingAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { baseURL := strings.TrimRight(info.ChannelMeta.BaseURL, "/") // 根据 taskType 决定 URL @@ -285,14 +285,15 @@ func (a *KlingAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requ reqURL = fmt.Sprintf("%s/v1/videos/text2video", baseURL) } - req, err := http.NewRequest(http.MethodPost, reqURL, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if err := a.BuildRequestHeader(req.Header, info); err != nil { return nil, fmt.Errorf("setup header: %w", err) } - client := &http.Client{Timeout: 120 * 1e9} + // 换共享连接池 client:超时语义等价(120s),并带上渠道代理与调试日志传输层包装 + client := common.NewPooledClient(120, info.ChannelMeta.Settings.UseProxy) return client.Do(req) } @@ -322,7 +323,7 @@ func (a *KlingAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *com return result.Data.TaskID, body, nil } -func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *KlingAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` TaskType string `json:"task_type"` @@ -338,7 +339,7 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http url := fmt.Sprintf("%s/v1/videos/%s/%s", strings.TrimRight(baseURL, "/"), taskType, data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } @@ -350,7 +351,8 @@ func (a *KlingAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 30 * 1e9} + // 换共享连接池 client:超时语义等价(30s),并带上调试日志传输层包装 + client := common.NewPooledClient(30, false) return client.Do(req) } diff --git a/relay/taskchannel/midjourney/adaptor.go b/relay/taskchannel/midjourney/adaptor.go index 1c382c2..ee1f4d7 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 } @@ -90,12 +90,12 @@ func (a *MjAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, return strings.NewReader(string(body)), nil } -func (a *MjAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *MjAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -147,7 +147,7 @@ func (a *MjAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *common return result.Result, body, nil } -func (a *MjAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *MjAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { UpstreamTaskID string `json:"upstream_task_id"` UseProxy bool `json:"use_proxy"` @@ -157,7 +157,7 @@ func (a *MjAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Re } url := fmt.Sprintf("%s/mj/task/%s/fetch", strings.TrimRight(baseURL, "/"), data.UpstreamTaskID) - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } diff --git a/relay/taskchannel/minimax/adaptor.go b/relay/taskchannel/minimax/adaptor.go new file mode 100644 index 0000000..febe884 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor.go @@ -0,0 +1,883 @@ +package minimax + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" + "github.com/qianfree/team-api/relay/taskchannel" +) + +// MiniMax 视频生成任务适配器,覆盖两代协议(按上游模型名分派,见 IsV2Model): +// - v2(MiniMax-H3 系列):POST /v2/video_generation 提交(content[] 多模态)→ +// GET /v2/query/video_generation/{task_id} 轮询,错误为 OpenAI 风格 OaiError; +// - v1(Hailuo 2.x 及旧型号):POST /v1/video_generation 提交(扁平字段)→ +// GET /v1/query/video_generation?task_id= 轮询,错误为 base_resp 信封 +// (HTTP 200 且 status_code != 0 即业务失败),成功后需二跳 +// GET /v1/files/retrieve?file_id= 换取 download_url(FetchTask 内链式完成)。 +// +// 请求体支持两种入站形态(由 BuildRequestBody 分流): +// - 原生形态:v2 顶层 content[] 数组 / v1 顶层 first_frame_image 等扁平字段(官方协议入站); +// - 通用形态:{model, prompt, seconds, images[], metadata{...}} 归一化任务体 +// (/v1/videos 与 /v1/video/generations 入站)。 + +func init() { + taskchannel.Register(constant.ProviderMiniMax, func() common.TaskAdaptor { + return &Adaptor{} + }) +} + +// ==================== 请求/响应结构体 ==================== + +// submitResponse v2 提交任务响应(VideoGenerationV2Resp) +type submitResponse struct { + TaskID string `json:"task_id"` +} + +// baseResp v1 接口的业务状态信封(HTTP 200 且 status_code != 0 表示业务失败) +type baseResp struct { + StatusCode int `json:"status_code"` + StatusMsg string `json:"status_msg"` +} + +// submitResponseV1 v1 提交任务响应(VideoGenerationResp) +type submitResponseV1 struct { + TaskID string `json:"task_id"` + BaseResp *baseResp `json:"base_resp"` +} + +// taskQueryResponseV1 v1 任务查询响应(QueryVideoGenerationTaskResp)。 +// DownloadURL 为 FetchTask 二跳 files/retrieve 后合并进来的字段(非上游查询接口原生字段)。 +type taskQueryResponseV1 struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + FileID string `json:"file_id"` + VideoWidth int `json:"video_width"` + VideoHeight int `json:"video_height"` + DownloadURL string `json:"download_url,omitempty"` + BaseResp *baseResp `json:"base_resp"` +} + +// fileRetrieveResponse v1 文件查询响应(RetrieveFileResp) +type fileRetrieveResponse struct { + File struct { + FileID int `json:"file_id"` + DownloadURL string `json:"download_url"` + } `json:"file"` + BaseResp *baseResp `json:"base_resp"` +} + +// oaiErrorBody v2 接口错误结构(OpenAI 风格顶层 error 对象) +type oaiErrorBody struct { + Error struct { + Type string `json:"type"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// IsV2Model 判断模型是否走 v2 H3 协议(content[] 多模态)。 +// 其余模型(Hailuo 2.x 及 T2V/I2V/S2V 旧型号)一律走 v1 扁平协议—— +// 官方 v2 接口明确「当前支持模型:MiniMax-H3」,未列型号默认按 v1 处理。 +func IsV2Model(model string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "minimax-h3") +} + +// ==================== Adaptor 实现 ==================== + +// Adaptor MiniMax H3 视频任务适配器 +type Adaptor struct { + info *common.RelayInfo +} + +func (a *Adaptor) Init(info *common.RelayInfo) { + a.info = info +} + +func (a *Adaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, body []byte) *common.TaskError { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "invalid request body"} + } + // v2 原生形态:官方约束每次请求必须包含一个非空 text 项 + if content, ok := req["content"].([]any); ok { + if !contentHasText(content) { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "content must contain a non-empty text item"} + } + return nil + } + // v1 原生形态:文生视频 prompt 必填、图生视频 first_frame_image 必填、主体参考 subject_reference 必填(三选一) + if hasAnyKey(req, "first_frame_image", "last_frame_image", "subject_reference") { + if !hasAnyKey(req, "prompt", "first_frame_image", "subject_reference") { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "one of prompt / first_frame_image / subject_reference is required"} + } + return nil + } + // 通用形态:prompt 必填(上游 text 项 / v1 prompt 的唯一来源) + if prompt, ok := req["prompt"].(string); !ok || strings.TrimSpace(prompt) == "" { + return &common.TaskError{StatusCode: http.StatusBadRequest, Message: "prompt is required"} + } + return nil +} + +// EstimateBilling 估算计费上下文。 +// 只写 spec.* 规格事实值(per_second 计费矩阵查价键 / 素材计费张数与输入视频存在性), +// 禁止附加 float 乘数键——ratios 中所有 float64 值会被计费引擎 applyRatioMultipliers 连乘, +// 混入非乘数键会改变费用(bool/字符串值不受影响,会被自动跳过)。 +// resolveDuration/resolveResolution 与 BuildRequestBody 共用,保证计费规格与实际生成规格一致。 +// spec.input_image_count 为提交可知的输入图片张数(素材计费预扣用;结算以官方 usage 为准); +// spec.has_input_video 标记提交含输入视频素材(时长未知,预扣按生成长度上限估,见 minimax-material 方案)。 +func (a *Adaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + req = nil + } + return map[string]any{ + "spec.duration": float64(resolveDuration(req)), + "spec.resolution": resolveResolution(req), + "spec.input_image_count": float64(countInputImages(req)), + "spec.has_input_video": hasInputVideo(req), + } +} + +// countInputImages 统计提交请求中的输入图片张数(与 buildGenericContent 的素材归并同源): +// v2 原生形态数 content[] 的 image_url 项;通用形态数 images[] + metadata.image + +// metadata.last_frame + metadata.content[] 的 image_url 项。图片张数不计音频/视频素材 +// (视频存在性另见 hasInputVideo,时长不可知由结算补)。 +func countInputImages(req map[string]any) int { + if req == nil { + return 0 + } + // v2 原生形态 + if content, ok := req["content"].([]any); ok { + count := 0 + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if t, _ := m["type"].(string); t == "image_url" { + count++ + } + } + } + return count + } + // 通用形态:与 buildGenericContent 相同的归并口径 + count := 0 + if images, ok := req["images"].([]any); ok { + count += len(images) + } + metadata := taskchannel.ExtractMetadata(req) + if _, ok := metadata["image"].(string); ok { + count++ + } + if _, ok := metadata["last_frame"].(string); ok { + count++ + } + var meta struct { + Content []map[string]any `json:"content"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + for _, item := range meta.Content { + if t, _ := item["type"].(string); t == "image_url" { + count++ + } + } + } + return count +} + +// hasInputVideo 判断提交请求是否含输入视频素材(与 buildGenericContent 的素材归并同源): +// v2 原生形态查 content[] 的 video_url 项;通用形态查 metadata.content[] 的 video_url 项。 +// 视频以 URL 提交、时长提交时不可知,素材计费据此在预扣时按生成长度上限冻结(15s,见 minimax-material 方案)。 +func hasInputVideo(req map[string]any) bool { + if req == nil { + return false + } + // v2 原生形态 + if content, ok := req["content"].([]any); ok { + for _, item := range content { + if m, ok := item.(map[string]any); ok { + if t, _ := m["type"].(string); t == "video_url" { + return true + } + } + } + return false + } + // 通用形态:metadata.content[] 透传项(多模态参考,与 countInputImages 同口径) + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Content []map[string]any `json:"content"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + for _, item := range meta.Content { + if t, _ := item["type"].(string); t == "video_url" { + return true + } + } + } + return false +} + +func (a *Adaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any { + return nil +} + +func (a *Adaptor) BuildRequestURL(info *common.RelayInfo) (string, error) { + base := strings.TrimRight(info.ChannelMeta.BaseURL, "/") + if IsV2Model(dispatchModel(info)) { + return base + "/v2/video_generation", nil + } + return base + "/v1/video_generation", nil +} + +// dispatchModel 协议分派用的模型名(上游映射名优先,缺省回落请求模型名) +func dispatchModel(info *common.RelayInfo) string { + if info.ChannelMeta.UpstreamModelName != "" { + return info.ChannelMeta.UpstreamModelName + } + return info.OriginModelName +} + +func (a *Adaptor) BuildRequestHeader(header http.Header, info *common.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ChannelMeta.ApiKey) + header.Set("Content-Type", "application/json") + header.Set("Accept", "application/json") + return nil +} + +func (a *Adaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo, body []byte) (io.Reader, error) { + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("minimax: invalid request body: %w", err) + } + + modelName := resolveUpstreamModel(info, req) + + var out map[string]any + if IsV2Model(modelName) { + out = buildV2RequestBody(req, modelName) + } else { + out = buildV1RequestBody(req, modelName) + } + + data, err := json.Marshal(out) + if err != nil { + return nil, fmt.Errorf("marshal minimax request: %w", err) + } + return strings.NewReader(string(data)), nil +} + +// buildV2RequestBody v2(H3 系列)请求体:content[] 多模态。 +// 原生形态(顶层 content[])原样透传;通用形态从 prompt/images/metadata 组装。 +// 注意不透传 callback_url——上游回调携带上游 task_id,与网关公开 ID 对不上且会绕过网关轮询计费。 +func buildV2RequestBody(req map[string]any, modelName string) map[string]any { + out := map[string]any{ + "model": modelName, + "resolution": resolveResolution(req), + "duration": resolveDuration(req), + } + if content, ok := req["content"].([]any); ok && len(content) > 0 { + out["content"] = content + if ratio := resolveRatio(req); ratio != "" { + out["ratio"] = ratio + } + } else { + content := buildGenericContent(req) + if len(content) == 0 { + content = []map[string]any{{"type": "text", "text": ""}} + } + out["content"] = content + // t2v(纯文本输入)时官方要求 ratio 必填且不能为 adaptive;含图片输入时上游恒按 adaptive 处理,显式传值无害 + ratio := resolveRatio(req) + if ratio == "" { + ratio = defaultTextOnlyRatio + } + out["ratio"] = ratio + } + if wm, ok := req["aigc_watermark"].(bool); ok { + out["aigc_watermark"] = wm + } + return out +} + +// buildV1RequestBody v1(Hailuo 系列)请求体:扁平字段。 +// v1 原生入站的顶层字段(first_frame_image/last_frame_image/subject_reference/prompt_optimizer/ +// fast_pretreatment)与通用词汇(images[0]/metadata.image/metadata.last_frame 等)统一在此归并, +// 白名单式拷贝保证 callback_url 等网关侧字段不会外泄到上游。 +func buildV1RequestBody(req map[string]any, modelName string) map[string]any { + out := map[string]any{ + "model": modelName, + "resolution": resolveResolution(req), + "duration": resolveDuration(req), + } + if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" { + out["prompt"] = prompt + } + + // 首帧图:v1 原生顶层字段 > images[0]/metadata.image + firstFrame := stringField(req, "first_frame_image") + if firstFrame == "" { + firstFrame = genericFirstImage(req) + } + if firstFrame != "" { + out["first_frame_image"] = firstFrame + } + // 尾帧图:v1 原生顶层字段 > metadata.last_frame + lastFrame := stringField(req, "last_frame_image") + if lastFrame == "" { + lastFrame = metaString(req, "last_frame") + } + if lastFrame != "" { + out["last_frame_image"] = lastFrame + } + // 主体参考(S2V-01):v1 原生顶层字段 > metadata.subject_reference + if sr, ok := req["subject_reference"]; ok && sr != nil { + out["subject_reference"] = sr + } else if sr := metaAny(req, "subject_reference"); sr != nil { + out["subject_reference"] = sr + } + // 可选参数白名单透传 + for _, key := range []string{"prompt_optimizer", "fast_pretreatment", "aigc_watermark"} { + if v, ok := req[key]; ok { + out[key] = v + } + } + return out +} + +func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + reqURL, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, requestBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if err := a.BuildRequestHeader(req.Header, info); err != nil { + return nil, fmt.Errorf("setup header: %w", err) + } + client := common.NewPooledClient(120, info.ChannelMeta.Settings.UseProxy) + return client.Do(req) +} + +func (a *Adaptor) DoResponse(_ context.Context, resp *http.Response, info *common.RelayInfo) (string, []byte, *common.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read response failed"} + } + + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + // v2 用 OaiError、v1 用 base_resp,两种信封都尝试解析 + var errResp oaiErrorBody + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + } else { + var baseRespErr struct { + BaseResp *baseResp `json:"base_resp"` + } + if json.Unmarshal(body, &baseRespErr) == nil && baseRespErr.BaseResp != nil && baseRespErr.BaseResp.StatusMsg != "" { + msg = baseRespErr.BaseResp.StatusMsg + } + } + if msg == "" { + msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode) + } + return "", body, &common.TaskError{StatusCode: resp.StatusCode, Message: msg} + } + + if IsV2Model(dispatchModel(info)) { + var result submitResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + if result.TaskID == "" { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + return result.TaskID, body, nil + } + + // v1:HTTP 200 仍需检查 base_resp 业务码(status_code != 0 即业务失败) + var result submitResponseV1 + if err := json.Unmarshal(body, &result); err != nil { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse response failed"} + } + if result.BaseResp != nil && result.BaseResp.StatusCode != 0 { + msg := result.BaseResp.StatusMsg + if msg == "" { + msg = fmt.Sprintf("upstream business error %d", result.BaseResp.StatusCode) + } + return "", body, &common.TaskError{StatusCode: http.StatusBadRequest, Message: msg, ErrCode: fmt.Sprintf("%d", result.BaseResp.StatusCode)} + } + if result.TaskID == "" { + return "", body, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "upstream returned empty task id"} + } + return result.TaskID, body, nil +} + +func (a *Adaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { + var data struct { + TaskID string `json:"task_id"` + UseProxy bool `json:"use_proxy"` + Model string `json:"model"` // 轮询器注入的上游模型名,用于 v1/v2 协议分派 + } + if err := json.Unmarshal(taskData, &data); err != nil { + return nil, fmt.Errorf("minimax: invalid task data: %w", err) + } + + base := strings.TrimRight(baseURL, "/") + if IsV2Model(data.Model) { + return fetchV2Task(ctx, base, apiKey, data.TaskID, data.UseProxy) + } + return fetchV1Task(ctx, base, apiKey, data.TaskID, data.UseProxy) +} + +// fetchV2Task v2 单任务查询:GET /v2/query/video_generation/{task_id} +func fetchV2Task(ctx context.Context, base, apiKey, taskID string, useProxy bool) (*http.Response, error) { + url := fmt.Sprintf("%s/v2/query/video_generation/%s", base, taskID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := common.NewPooledClient(30, useProxy) + return client.Do(req) +} + +// fetchV1Task v1 单任务查询:GET /v1/query/video_generation?task_id=...。 +// v1 协议成功后只给 file_id,需二跳 GET /v1/files/retrieve?file_id=... 换取限时 download_url—— +// 在此链式完成并把 download_url 合并进查询响应体(ParseTaskResult 与原生查询回放共用该字段)。 +// 二跳失败不改变任务状态判定:任务仍按 Success 结算,仅缺下载直链(客户端可稍后重查)。 +// 调试日志注意:两跳共用同一 ctx 捕获器,调试记录里 URL/headers 为二跳、body 为两跳拼接。 +func fetchV1Task(ctx context.Context, base, apiKey, taskID string, useProxy bool) (*http.Response, error) { + client := common.NewPooledClient(30, useProxy) + + queryURL := fmt.Sprintf("%s/v1/query/video_generation?task_id=%s", base, url.QueryEscape(taskID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // 仅在成功且拿到 file_id 时二跳;其余状态原样返回 + var probe taskQueryResponseV1 + if resp.StatusCode != http.StatusOK || json.Unmarshal(body, &probe) != nil || probe.Status != "Success" || probe.FileID == "" { + return rebuiltResponse(resp.StatusCode, body), nil + } + + fileURL := fmt.Sprintf("%s/v1/files/retrieve?file_id=%s", base, url.QueryEscape(probe.FileID)) + fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + fileReq.Header.Set("Authorization", "Bearer "+apiKey) + + fileResp, err := client.Do(fileReq) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + defer fileResp.Body.Close() + fileBody, err := io.ReadAll(fileResp.Body) + if err != nil { + return rebuiltResponse(resp.StatusCode, body), nil + } + + var fileResult fileRetrieveResponse + if fileResp.StatusCode != http.StatusOK || json.Unmarshal(fileBody, &fileResult) != nil || + fileResult.File.DownloadURL == "" || + (fileResult.BaseResp != nil && fileResult.BaseResp.StatusCode != 0) { + // 二跳失败:保留原查询响应(download_url 缺失),任务仍可按 Success 结算 + return rebuiltResponse(resp.StatusCode, body), nil + } + + // 合并 download_url 进查询响应体 + var merged map[string]any + if json.Unmarshal(body, &merged) == nil { + merged["download_url"] = fileResult.File.DownloadURL + if newBody, err := json.Marshal(merged); err == nil { + return rebuiltResponse(resp.StatusCode, newBody), nil + } + } + return rebuiltResponse(resp.StatusCode, body), nil +} + +// rebuiltResponse 用已消费的 body 重建 http.Response(FetchTask 契约要求返回未读响应体) +func rebuiltResponse(statusCode int, body []byte) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + } +} + +// ParseTaskResult 解析上游单任务查询响应。两代协议按响应形态自动区分: +// v2 为 {"task": {...}} 包装、v1 为扁平的 {task_id, status, file_id, ...}。 +// 注意:不填 ActualCost——per_second 计费下 RecalculateByTokens 返回 0,结算保持预扣金额, +// 而 duration 必填=产物时长,预扣即终价;填入 ActualCost 反而会用上游口径覆盖平台定价。 +func (a *Adaptor) ParseTaskResult(body []byte) (*common.TaskInfo, error) { + var resp TaskQueryResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("minimax: parse task result: %w", err) + } + if resp.Task != nil { + return parseV2TaskResult(body, resp.Task) + } + return parseV1TaskResult(body) +} + +// parseV2TaskResult v2(H3)查询响应解析 +func parseV2TaskResult(body []byte, task *VideoTask) (*common.TaskInfo, error) { + info := &common.TaskInfo{Data: body} + + // 上游返回的 token 用量仅作记录(per_second 计费不按 token 结算) + if task.Usage != nil && task.Usage.TotalTokens > 0 { + info.TotalTokens = task.Usage.TotalTokens + info.CompletionTokens = task.Usage.CompletionTokens + info.PromptTokens = task.Usage.PromptTokens + } + + // 素材计量(input_seconds/output_seconds/input_image_count 等):素材计费方案的结算依据。 + // 官方 usage 携带任一按秒计量或图片张数字段即透出(全零不出,避免无素材任务占用结算链路) + if task.Usage != nil && (task.Usage.InputSeconds > 0 || task.Usage.OutputSeconds > 0 || + task.Usage.InputImageCount > 0 || task.Usage.InputAudioSeconds > 0 || task.Usage.TotalSeconds > 0) { + info.MaterialUsage = &common.TaskMaterialUsage{ + InputVideoSeconds: float64(task.Usage.InputSeconds), + InputImageCount: task.Usage.InputImageCount, + InputAudioSeconds: float64(task.Usage.InputAudioSeconds), + OutputSeconds: float64(task.Usage.OutputSeconds), + } + } + + switch task.Status { + case "queued": + info.Status = common.TaskStatusQueued + info.Progress = "10%" + case "running": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "succeeded": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + if task.Content != nil { + info.ResultURL = task.Content.URL + } + case "failed": + info.Status = common.TaskStatusFailure + info.FailReason = "video generation failed" + if task.Error != nil && task.Error.Message != "" { + info.FailReason = task.Error.Message + } + case "cancelled": + // 必须映射为终态 FAILURE:映射成非终态会让任务挂到超时兜底(30 分钟)才退款 + info.Status = common.TaskStatusFailure + info.FailReason = "task cancelled" + default: + info.Status = common.TaskStatusInProgress + info.Progress = "30%" + } + + return info, nil +} + +// parseV1TaskResult v1(Hailuo)查询响应解析(状态机 Preparing/Queueing/Processing/Success/Fail)。 +// download_url 为 FetchTask 二跳合并字段,成功时作为 ResultURL。 +func parseV1TaskResult(body []byte) (*common.TaskInfo, error) { + var resp taskQueryResponseV1 + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("minimax: parse v1 task result: %w", err) + } + + info := &common.TaskInfo{Data: body} + + switch resp.Status { + case "Preparing", "Queueing": + info.Status = common.TaskStatusQueued + info.Progress = "10%" + case "Processing": + info.Status = common.TaskStatusInProgress + info.Progress = "50%" + case "Success": + info.Status = common.TaskStatusSuccess + info.Progress = "100%" + info.ResultURL = resp.DownloadURL + case "Fail": + info.Status = common.TaskStatusFailure + info.FailReason = "video generation failed" + if resp.BaseResp != nil && resp.BaseResp.StatusMsg != "" { + info.FailReason = resp.BaseResp.StatusMsg + } + default: + info.Status = common.TaskStatusInProgress + info.Progress = "30%" + } + + return info, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return channelName +} + +// ==================== 归一化辅助函数 ==================== +// 以下函数被 EstimateBilling 与 BuildRequestBody 共用(计费一致性强约束): +// per_second 矩阵键按 spec.resolution 原值逐字符匹配,两处归一化若不一致会导致 +// 「按 768P 生成、按兜底价收费」的系统性少收。 + +// resolveDuration 提取时长秒数:顶层 duration(原生形态)> seconds > metadata.duration > 默认 6。 +func resolveDuration(req map[string]any) int { + if d, ok := parseIntAny(req["duration"]); ok && d > 0 { + return d + } + if d, ok := parseIntAny(req["seconds"]); ok && d > 0 { + return d + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Duration *int `json:"duration"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 { + return *meta.Duration + } + return defaultDurationSecs +} + +// resolveResolution 提取分辨率:顶层 resolution > metadata.resolution > metadata.size > 按模型默认档。 +// size 回退覆盖 /v1/videos 入站(该协议的分辨率就承载在 size 字段,原值透传,无换算)。 +// 默认档跟随官方口径:H3 与 Hailuo 2.x 默认 768P,T2V/I2V/S2V 旧型号默认 720P。 +func resolveResolution(req map[string]any) string { + if r, ok := req["resolution"].(string); ok && strings.TrimSpace(r) != "" { + return normalizeResolution(r) + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Resolution string `json:"resolution"` + Size string `json:"size"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + if strings.TrimSpace(meta.Resolution) != "" { + return normalizeResolution(meta.Resolution) + } + if strings.TrimSpace(meta.Size) != "" { + return normalizeResolution(meta.Size) + } + } + model, _ := req["model"].(string) + return defaultResolutionFor(model) +} + +// defaultResolutionFor 按模型取分辨率默认档(H3/Hailuo 2.x → 768P,其余 → 720P) +func defaultResolutionFor(model string) string { + m := strings.ToLower(strings.TrimSpace(model)) + if strings.HasPrefix(m, "minimax-h3") || strings.HasPrefix(m, "minimax-hailuo-2") { + return defaultResolution + } + return defaultResolutionV1 +} + +// resolveRatio 提取宽高比:顶层 ratio > metadata.ratio > metadata.aspect_ratio(kling 词汇);空 = 未指定。 +func resolveRatio(req map[string]any) string { + if r, ok := req["ratio"].(string); ok && strings.TrimSpace(r) != "" { + return r + } + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Ratio string `json:"ratio"` + AspectRatio string `json:"aspect_ratio"` + } + if taskchannel.UnmarshalMetadata(metadata, &meta) == nil { + if meta.Ratio != "" { + return meta.Ratio + } + if meta.AspectRatio != "" { + return meta.AspectRatio + } + } + return "" +} + +// normalizeResolution 分辨率值规范:仅做两代协议官方档位的大小写规范(per_second 计费矩阵键 +// 逐字符匹配,大小写差异会导致查价错档),**不做任何档位换算**——非官方档位的值 +// 原样透传,由上游裁决(非法值上游 400,错误透传给客户端并全额退款)。 +// 官方档位全集:v2 的 480P/768P/2K + v1 的 720P/768P/1080P/512P。 +func normalizeResolution(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "480p": + return "480P" + case "720p": + return "720P" + case "768p": + return "768P" + case "1080p": + return "1080P" + case "512p": + return "512P" + case "2k": + return "2K" + } + return strings.TrimSpace(v) +} + +// parseIntAny 宽松解析整数(JSON number 解码为 float64,或字符串数字) +func parseIntAny(v any) (int, bool) { + switch n := v.(type) { + case float64: + if n > 0 { + return int(n), true + } + case string: + if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil && i > 0 { + return i, true + } + } + return 0, false +} + +// buildGenericContent 从归一化任务体组装 MiniMax content[]: +// prompt → text 项;images[0]/metadata.image → 首帧图;metadata.last_frame → 尾帧图; +// metadata.content[] → 追加透传(多模态参考,与 volcengine 适配器同词汇)。 +func buildGenericContent(req map[string]any) []map[string]any { + content := make([]map[string]any, 0, 4) + + if prompt, ok := req["prompt"].(string); ok && strings.TrimSpace(prompt) != "" { + content = append(content, map[string]any{"type": "text", "text": prompt}) + } + + metadata := taskchannel.ExtractMetadata(req) + var meta struct { + Image string `json:"image"` + LastFrame string `json:"last_frame"` + Content []map[string]any `json:"content"` + } + _ = taskchannel.UnmarshalMetadata(metadata, &meta) + + firstFrame := meta.Image + if firstFrame == "" { + if images, ok := req["images"].([]any); ok && len(images) > 0 { + if url, ok := images[0].(string); ok { + firstFrame = url + } + } + } + if firstFrame != "" { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": firstFrame}, + "role": "first_frame", + }) + } + if meta.LastFrame != "" { + content = append(content, map[string]any{ + "type": "image_url", + "image_url": map[string]any{"url": meta.LastFrame}, + "role": "last_frame", + }) + } + content = append(content, meta.Content...) + + return content +} + +// contentHasText 判断原生 content[] 是否含非空 text 项 +func contentHasText(content []any) bool { + for _, item := range content { + m, ok := item.(map[string]any) + if !ok { + continue + } + if t, _ := m["type"].(string); t == "text" { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + return true + } + } + } + return false +} + +// resolveUpstreamModel 解析上游模型名(渠道模型映射优先) +func resolveUpstreamModel(info *common.RelayInfo, req map[string]any) string { + if info.ChannelMeta.IsModelMapped && info.ChannelMeta.UpstreamModelName != "" { + return info.ChannelMeta.UpstreamModelName + } + if m, ok := req["model"].(string); ok { + return m + } + return "" +} + +// ==================== v1 归并辅助 ==================== + +// hasAnyKey 判断请求 map 是否含任一指定键(值非空) +func hasAnyKey(req map[string]any, keys ...string) bool { + for _, key := range keys { + if v, ok := req[key]; ok && v != nil { + if s, isStr := v.(string); !isStr || strings.TrimSpace(s) != "" { + return true + } + } + } + return false +} + +// stringField 取顶层字符串字段(非空) +func stringField(req map[string]any, key string) string { + if s, ok := req[key].(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +// metaString 取 metadata 内字符串字段(非空) +func metaString(req map[string]any, key string) string { + if v, ok := taskchannel.ExtractMetadata(req)[key].(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +// metaAny 取 metadata 内任意类型字段(存在则返回) +func metaAny(req map[string]any, key string) any { + metadata := taskchannel.ExtractMetadata(req) + if v, ok := metadata[key]; ok { + return v + } + return nil +} + +// genericFirstImage 取通用形态的首帧图:images[0] > metadata.image +func genericFirstImage(req map[string]any) string { + if images, ok := req["images"].([]any); ok && len(images) > 0 { + if url, ok := images[0].(string); ok { + return strings.TrimSpace(url) + } + } + if v, ok := taskchannel.ExtractMetadata(req)["image"].(string); ok { + return strings.TrimSpace(v) + } + return "" +} diff --git a/relay/taskchannel/minimax/adaptor_test.go b/relay/taskchannel/minimax/adaptor_test.go new file mode 100644 index 0000000..a6ffe75 --- /dev/null +++ b/relay/taskchannel/minimax/adaptor_test.go @@ -0,0 +1,691 @@ +package minimax + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/qianfree/team-api/relay/common" + "github.com/qianfree/team-api/relay/constant" +) + +// testInfo 构造带渠道元数据的 RelayInfo +func testInfo(baseURL, upstreamModel string, mapped bool) *common.RelayInfo { + return &common.RelayInfo{ + ChannelMeta: &common.ChannelMeta{ + ChannelID: 1, + ChannelType: int(constant.ProviderMiniMax), + BaseURL: baseURL, + ApiKey: "sk-test", + UpstreamModelName: upstreamModel, + IsModelMapped: mapped, + }, + } +} + +func buildBody(t *testing.T, a *Adaptor, info *common.RelayInfo, body string) map[string]any { + t.Helper() + reader, err := a.BuildRequestBody(context.Background(), info, []byte(body)) + if err != nil { + t.Fatalf("build request body: %v", err) + } + raw, _ := io.ReadAll(reader) + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("upstream body not json: %v, raw=%s", err, string(raw)) + } + return out +} + +// TestNormalizeResolution 仅做两代协议官方档位的大小写规范(计费矩阵键逐字符匹配), +// 非官方值原样透传、由上游裁决——不做任何档位换算。 +func TestNormalizeResolution(t *testing.T) { + cases := map[string]string{ + "480P": "480P", + "480p": "480P", + " 480P ": "480P", + "720p": "720P", + "768P": "768P", + "768p": "768P", + "1080p": "1080P", // v1 官方档位:仅规范大小写 + "512p": "512P", + "2K": "2K", + "2k": "2K", + "1280x720": "1280x720", // 非官方档位:原样透传(上游 400 + 退款) + "custom-tier": "custom-tier", + "": "", + } + for in, want := range cases { + if got := normalizeResolution(in); got != want { + t.Errorf("normalizeResolution(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateRequest(t *testing.T) { + a := &Adaptor{} + + // 通用形态:prompt 必填 + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3"}`)); err == nil { + t.Error("expected error when prompt missing in generic form") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi"}`)); err != nil { + t.Errorf("generic form with prompt should pass: %v", err) + } + + // 原生形态:content 必含非空 text 项 + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err == nil { + t.Error("expected error when native content has no text item") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":" "}]}`)); err == nil { + t.Error("expected error when native content text item is blank") + } + if err := a.ValidateRequest(context.Background(), nil, []byte(`{"model":"MiniMax-H3","content":[{"type":"text","text":"海浪"},{"type":"image_url","image_url":{"url":"https://x/a.png"}}]}`)); err != nil { + t.Errorf("native form with text should pass: %v", err) + } +} + +// TestEstimateBilling_OnlySpecKeys 计费上下文只能包含 spec.* 四个键 +// (duration / resolution / input_image_count / has_input_video): +// ratios 里的 float64 值会被计费引擎连乘,混入非乘数键即资损向量。 +func TestEstimateBilling_OnlySpecKeys(t *testing.T) { + a := &Adaptor{} + ratios := a.EstimateBilling(context.Background(), nil, []byte(`{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"size":"2K"}}`)) + + if len(ratios) != 4 { + t.Fatalf("expected exactly 4 ratio keys, got %d: %v", len(ratios), ratios) + } + if d, ok := ratios["spec.duration"].(float64); !ok || d != 8 { + t.Fatalf("spec.duration = %v (%T), want float64(8)", ratios["spec.duration"], ratios["spec.duration"]) + } + if r := ratios["spec.resolution"]; r != "2K" { + t.Fatalf("spec.resolution = %v, want 2K", r) + } + // 无图请求张数为 0(float64,素材计费方案读) + if c, ok := ratios["spec.input_image_count"].(float64); !ok || c != 0 { + t.Fatalf("spec.input_image_count = %v (%T), want float64(0)", ratios["spec.input_image_count"], ratios["spec.input_image_count"]) + } + // 无视频请求存在性为 false(bool,非乘数键安全) + if has, ok := ratios["spec.has_input_video"].(bool); !ok || has { + t.Fatalf("spec.has_input_video = %v (%T), want bool(false)", ratios["spec.has_input_video"], ratios["spec.has_input_video"]) + } +} + +// TestEstimateBilling_MaterialSignals 素材归并口径(与 buildGenericContent 同源): +// 图片张数:原生形态数 content[] 的 image_url 项;通用形态数 images[] + metadata.image/last_frame/content[]。 +// 输入视频存在性:原生形态查 content[] 的 video_url 项;通用形态查 metadata.content[]。 +func TestEstimateBilling_MaterialSignals(t *testing.T) { + a := &Adaptor{} + + cases := []struct { + name string + body string + wantCount float64 + wantVideo bool + }{ + {"native content 2 images", `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"},{"type":"image_url","image_url":{"url":"https://x/a.png"}},{"type":"image_url","image_url":{"url":"https://x/b.png"}}]}`, 2, false}, + {"native content video", `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"},{"type":"video_url","video_url":{"url":"https://x/v.mp4"}}]}`, 0, true}, + {"generic images + last_frame + content", `{"model":"MiniMax-H3","prompt":"hi","images":["https://x/a.png"],"metadata":{"last_frame":"https://x/b.png","content":[{"type":"image_url","image_url":{"url":"https://x/c.png"}},{"type":"video_url","video_url":{"url":"https://x/v.mp4"}}]}}`, 3, true}, + {"metadata.image only", `{"model":"MiniMax-H3","prompt":"hi","metadata":{"image":"https://x/a.png"}}`, 1, false}, + {"generic text only", `{"model":"MiniMax-H3","prompt":"hi"}`, 0, false}, + } + for _, c := range cases { + ratios := a.EstimateBilling(context.Background(), nil, []byte(c.body)) + if got := ratios["spec.input_image_count"].(float64); got != c.wantCount { + t.Errorf("%s: input_image_count = %v, want %v", c.name, got, c.wantCount) + } + if got := ratios["spec.has_input_video"].(bool); got != c.wantVideo { + t.Errorf("%s: has_input_video = %v, want %v", c.name, got, c.wantVideo) + } + } +} + +// TestBillingBuildConsistency EstimateBilling 与 BuildRequestBody 必须共用同一归一化: +// 同一输入下,计费 spec 值必须与上游实际收到的规格一致(矩阵键逐字符匹配)。 +func TestBillingBuildConsistency(t *testing.T) { + a := &Adaptor{} + inputs := []string{ + `{"model":"MiniMax-H3","prompt":"hi","seconds":"8","metadata":{"resolution":"1080p"}}`, + `{"model":"MiniMax-H3","prompt":"hi","metadata":{"resolution":"1280x720","duration":4}}`, + `{"model":"MiniMax-H3","prompt":"hi"}`, + `{"model":"MiniMax-H3","content":[{"type":"text","text":"hi"}],"resolution":"2K","duration":15}`, + `{"model":"MiniMax-H3-Max","content":[{"type":"text","text":"hi"}],"resolution":"480P","duration":5}`, + // /v1/videos 入站:size 命名档位(未经四档映射,仅 size 原值透传)走回退归一化 + `{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"2K"}}`, + `{"model":"MiniMax-H3","prompt":"hi","seconds":"6","metadata":{"size":"768P"}}`, + } + for _, in := range inputs { + ratios := a.EstimateBilling(context.Background(), nil, []byte(in)) + out := buildBody(t, a, testInfo("", "MiniMax-H3", false), in) + + billDur := int(ratios["spec.duration"].(float64)) + if upDur := int(out["duration"].(float64)); billDur != upDur { + t.Errorf("input=%s: billing duration %d != upstream duration %d", in, billDur, upDur) + } + if ratios["spec.resolution"] != out["resolution"] { + t.Errorf("input=%s: billing resolution %v != upstream resolution %v", in, ratios["spec.resolution"], out["resolution"]) + } + } +} + +// TestResolveResolution_SizeFallback metadata.resolution 优先于 metadata.size; +// 仅 size 存在时作为分辨率信号(/v1/videos 的分辨率承载在 size 字段,原值透传)。 +func TestResolveResolution_SizeFallback(t *testing.T) { + cases := []struct { + name string + req map[string]any + want string + }{ + {"resolution wins", map[string]any{"metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "2K"}, + {"size named tier", map[string]any{"metadata": map[string]any{"size": "768P"}}, "768P"}, + {"size lowercase tier", map[string]any{"metadata": map[string]any{"size": "2k"}}, "2K"}, + {"size non-tier passthrough", map[string]any{"metadata": map[string]any{"size": "1536p"}}, "1536p"}, + {"top-level wins all", map[string]any{"resolution": "480P", "metadata": map[string]any{"resolution": "2K", "size": "768P"}}, "480P"}, + {"default for h3 model", map[string]any{"model": "MiniMax-H3"}, "768P"}, + {"default for hailuo model", map[string]any{"model": "MiniMax-Hailuo-2.3"}, "768P"}, + {"default for legacy model", map[string]any{"model": "T2V-01"}, "720P"}, + } + for _, c := range cases { + if got := resolveResolution(c.req); got != c.want { + t.Errorf("%s: resolveResolution = %q, want %q", c.name, got, c.want) + } + } +} + +func TestBuildRequestBody_NativePassthrough(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "content": [ + {"type": "text", "text": "海浪拍打礁石"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,xxx"}, "role": "first_frame"}, + {"type": "video_url", "video_url": {"url": "https://cdn.example/ref.mp4"}, "role": "reference_video"} + ], + "resolution": "2K", + "duration": 10, + "ratio": "16:9", + "aigc_watermark": true, + "callback_url": "https://evil.example/cb" + }` + out := buildBody(t, a, testInfo("", "MiniMax-H3-Upstream", true), body) + + // 模型映射 + content 原样透传 + 顶层参数 + if out["model"] != "MiniMax-H3-Upstream" { + t.Fatalf("upstream model: %v", out["model"]) + } + if out["resolution"] != "2K" || out["duration"] != float64(10) || out["ratio"] != "16:9" { + t.Fatalf("upstream params: resolution=%v duration=%v ratio=%v", out["resolution"], out["duration"], out["ratio"]) + } + if wm, _ := out["aigc_watermark"].(bool); !wm { + t.Fatalf("aigc_watermark should pass through: %v", out["aigc_watermark"]) + } + // callback_url 必须被剥离 + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must be stripped from upstream request") + } + content, ok := out["content"].([]any) + if !ok || len(content) != 3 { + t.Fatalf("expected 3 content items, got %v", out["content"]) + } + img, _ := content[1].(map[string]any) + if img["role"] != "first_frame" { + t.Fatalf("content roles must be preserved: %v", content[1]) + } + vid, _ := content[2].(map[string]any) + if vid["type"] != "video_url" { + t.Fatalf("multimodal reference item must be preserved: %v", content[2]) + } +} + +func TestBuildRequestBody_GenericForm(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "prompt": "小猫对着镜头打哈欠", + "seconds": "8", + "images": ["https://cdn.example/first.png"], + "metadata": {"resolution": "2K", "ratio": "9:16", "last_frame": "https://cdn.example/last.png"} + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["duration"] != float64(8) || out["resolution"] != "2K" || out["ratio"] != "9:16" { + t.Fatalf("mapped params: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"]) + } + content, ok := out["content"].([]any) + if !ok || len(content) != 3 { + t.Fatalf("expected text + first_frame + last_frame, got %v", out["content"]) + } + text, _ := content[0].(map[string]any) + if text["type"] != "text" || text["text"] != "小猫对着镜头打哈欠" { + t.Fatalf("text item: %v", content[0]) + } + first, _ := content[1].(map[string]any) + if first["role"] != "first_frame" { + t.Fatalf("first frame role: %v", content[1]) + } + last, _ := content[2].(map[string]any) + if last["role"] != "last_frame" { + t.Fatalf("last frame role: %v", content[2]) + } +} + +// TestBuildRequestBody_GenericDefaults 纯文生视频缺省值:duration=6、resolution=768P、ratio=16:9 +func TestBuildRequestBody_GenericDefaults(t *testing.T) { + a := &Adaptor{} + out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-H3","prompt":"hi"}`) + + if out["duration"] != float64(6) || out["resolution"] != "768P" || out["ratio"] != "16:9" { + t.Fatalf("defaults: duration=%v resolution=%v ratio=%v", out["duration"], out["resolution"], out["ratio"]) + } + content, _ := out["content"].([]any) + if len(content) != 1 { + t.Fatalf("expected text-only content, got %v", out["content"]) + } +} + +// TestBuildRequestBody_MetadataContentAppend metadata.content 多模态参考项透传追加 +func TestBuildRequestBody_MetadataContentAppend(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-H3", + "prompt": "hi", + "metadata": { + "content": [{"type": "audio_url", "audio_url": {"url": "https://cdn.example/vo.mp3"}, "role": "reference_audio"}] + } + }` + out := buildBody(t, a, testInfo("", "", false), body) + content, _ := out["content"].([]any) + if len(content) != 2 { + t.Fatalf("expected text + appended reference item, got %v", out["content"]) + } + audio, _ := content[1].(map[string]any) + if audio["type"] != "audio_url" || audio["role"] != "reference_audio" { + t.Fatalf("appended reference item: %v", content[1]) + } +} + +// httpResp 构造带 JSON body 的 http.Response 桩 +func httpResp(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func TestDoResponse(t *testing.T) { + a := &Adaptor{} + info := testInfo("", "MiniMax-H3", false) // v2 分派(DoResponse 按模型走 v1/v2 解析) + + // 提交成功 + taskID, _, taskErr := a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":"424010985738629"}`), info) + if taskErr != nil || taskID != "424010985738629" { + t.Fatalf("submit response: id=%q err=%+v", taskID, taskErr) + } + + // 上游错误:OaiError 风格 message 透传 + _, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusBadRequest, `{"error":{"type":"invalid_request_error","message":"resolution not supported"}}`), info) + if taskErr == nil || taskErr.Message != "resolution not supported" || taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("error response: %+v", taskErr) + } + + // 空 task_id + _, _, taskErr = a.DoResponse(context.Background(), httpResp(http.StatusOK, `{"task_id":""}`), info) + if taskErr == nil { + t.Fatal("expected error for empty task id") + } +} + +func TestParseTaskResult(t *testing.T) { + a := &Adaptor{} + + succeeded := `{"task":{"id":"up-1","status":"succeeded","content":{"url":"https://video.cdn.example/out.mp4"},"usage":{"total_tokens":273890,"prompt_tokens":13500,"completion_tokens":260390},"resolution":"2K","duration":5,"ratio":"16:9"}}` + info, err := a.ParseTaskResult([]byte(succeeded)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://video.cdn.example/out.mp4" { + t.Fatalf("succeeded: %+v", info) + } + if info.TotalTokens != 273890 || info.PromptTokens != 13500 || info.CompletionTokens != 260390 { + t.Fatalf("usage tokens: %+v", info) + } + if info.ActualCost != 0 { + t.Fatalf("ActualCost must stay 0 (per_second 预扣即终价), got %v", info.ActualCost) + } + + // 素材计量:官方 usage 携带按秒计量/图片张数时透出(素材计费方案的结算依据) + material := `{"task":{"id":"up-2","status":"succeeded","usage":{"input_seconds":8,"output_seconds":6,"input_image_count":8,"input_audio_seconds":12},"resolution":"768P"}}` + info, err = a.ParseTaskResult([]byte(material)) + if err != nil { + t.Fatalf("parse material: %v", err) + } + if info.MaterialUsage == nil { + t.Fatal("MaterialUsage should be exported when usage carries material fields") + } + if info.MaterialUsage.InputVideoSeconds != 8 || info.MaterialUsage.OutputSeconds != 6 || + info.MaterialUsage.InputImageCount != 8 || info.MaterialUsage.InputAudioSeconds != 12 { + t.Fatalf("material usage: %+v", info.MaterialUsage) + } + + // 素材计量全零/缺失:不透出(generic 结算链不受影响) + info, err = a.ParseTaskResult([]byte(succeeded)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.MaterialUsage != nil { + t.Fatalf("token-only usage should not export MaterialUsage, got %+v", info.MaterialUsage) + } + + statuses := map[string]struct { + want common.TaskStatusEnum + reason string + }{ + `{"task":{"status":"queued"}}`: {common.TaskStatusQueued, ""}, + `{"task":{"status":"running"}}`: {common.TaskStatusInProgress, ""}, + `{"task":{"status":"failed","error":{"code":"E1","message":"boom"}}}`: {common.TaskStatusFailure, "boom"}, + `{"task":{"status":"cancelled"}}`: {common.TaskStatusFailure, "task cancelled"}, + } + for body, want := range statuses { + info, err := a.ParseTaskResult([]byte(body)) + if err != nil { + t.Fatalf("parse %s: %v", body, err) + } + if info.Status != want.want { + t.Errorf("%s: status=%v want %v", body, info.Status, want.want) + } + if want.reason != "" && info.FailReason != want.reason { + t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason) + } + if !info.Status.IsTerminal() && (want.want == common.TaskStatusFailure) { + t.Errorf("%s: cancelled/failed must map to terminal state", body) + } + } + + // 非 task 包装形态(防御):未知结构降级为进行中,不报错 + info, err = a.ParseTaskResult([]byte(`{"task_id":"up-1"}`)) + if err != nil || info.Status != common.TaskStatusInProgress { + t.Fatalf("submit-shaped data should degrade to in-progress: %+v err=%v", info, err) + } +} + +func TestFetchTask_URL(t *testing.T) { + var gotPath, gotAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"task":{"id":"up-1","status":"running"}}`)) + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "up-1", "use_proxy": false, "model": "MiniMax-H3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + if gotPath != "/v2/query/video_generation/up-1" { + t.Fatalf("fetch path: %s", gotPath) + } + if gotAuth != "Bearer sk-test" { + t.Fatalf("authorization header: %q", gotAuth) + } +} + +func TestCancelTask(t *testing.T) { + var gotMethod, gotPath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"424010985738629","action":"cancelled","status":"cancelled"}`)) + })) + defer upstream.Close() + + result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false) + if taskErr != nil { + t.Fatalf("cancel: %+v", taskErr) + } + if gotMethod != http.MethodDelete || gotPath != "/v2/video_generation/424010985738629" { + t.Fatalf("cancel request: %s %s", gotMethod, gotPath) + } + if result.Action != "cancelled" || result.Status != "cancelled" { + t.Fatalf("cancel result: %+v", result) + } +} + +// TestCancelTask_UpstreamError 上游拒绝(如运行中不可取消):错误码与消息透传 +func TestCancelTask_UpstreamError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"type":"invalid_request_error","message":"running task cannot be cancelled"}}`)) + })) + defer upstream.Close() + + result, taskErr := CancelTask(upstream.URL, "sk-test", "424010985738629", false) + if result != nil || taskErr == nil { + t.Fatalf("expected error, got result=%+v err=%+v", result, taskErr) + } + if taskErr.StatusCode != http.StatusBadRequest || taskErr.Message != "running task cannot be cancelled" { + t.Fatalf("upstream error relay: %+v", taskErr) + } +} + +// ==================== v1(Hailuo 系列)协议 ==================== + +func TestIsV2Model(t *testing.T) { + cases := map[string]bool{ + "MiniMax-H3": true, + "MiniMax-H3-Max": true, + "minimax-h3-anything": true, + "MiniMax-Hailuo-2.3": false, + "MiniMax-Hailuo-02": false, + "T2V-01": false, + "S2V-01": false, + "": false, + } + for model, want := range cases { + if got := IsV2Model(model); got != want { + t.Errorf("IsV2Model(%q) = %v, want %v", model, got, want) + } + } +} + +func TestBuildRequestBody_V1Generic(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-Hailuo-2.3", + "prompt": "一只猫在月光下的屋顶上行走", + "seconds": "10", + "images": ["https://cdn.example/first.png"], + "metadata": {"resolution": "1080P", "last_frame": "https://cdn.example/last.png"} + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["model"] != "MiniMax-Hailuo-2.3" || out["prompt"] != "一只猫在月光下的屋顶上行走" { + t.Fatalf("v1 flat fields: %v", out) + } + if out["duration"] != float64(10) || out["resolution"] != "1080P" { + t.Fatalf("v1 duration/resolution: %v/%v", out["duration"], out["resolution"]) + } + if out["first_frame_image"] != "https://cdn.example/first.png" { + t.Fatalf("first_frame_image from images[0]: %v", out["first_frame_image"]) + } + if out["last_frame_image"] != "https://cdn.example/last.png" { + t.Fatalf("last_frame_image from metadata: %v", out["last_frame_image"]) + } + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must never reach upstream") + } +} + +// TestBuildRequestBody_V1Native v1 官方入站的扁平字段透传(含可选布尔参数) +func TestBuildRequestBody_V1Native(t *testing.T) { + a := &Adaptor{} + body := `{ + "model": "MiniMax-Hailuo-02", + "prompt": "电影感运镜", + "first_frame_image": "https://cdn.example/f.png", + "last_frame_image": "https://cdn.example/l.png", + "duration": 10, + "resolution": "768P", + "prompt_optimizer": false, + "fast_pretreatment": true, + "aigc_watermark": true, + "callback_url": "https://evil.example/cb" + }` + out := buildBody(t, a, testInfo("", "", false), body) + + if out["first_frame_image"] != "https://cdn.example/f.png" || out["last_frame_image"] != "https://cdn.example/l.png" { + t.Fatalf("frame images passthrough: %v", out) + } + if po, _ := out["prompt_optimizer"].(bool); po { + t.Fatalf("prompt_optimizer passthrough: %v", out["prompt_optimizer"]) + } + if fp, _ := out["fast_pretreatment"].(bool); !fp { + t.Fatalf("fast_pretreatment passthrough: %v", out["fast_pretreatment"]) + } + if _, has := out["callback_url"]; has { + t.Fatal("callback_url must never reach upstream") + } + if _, has := out["content"]; has { + t.Fatal("v1 request must not carry content[]") + } +} + +// TestBuildRequestBody_V1DefaultResolution 分辨率缺省按模型默认档:Hailuo 2.x → 768P,旧型号 → 720P +func TestBuildRequestBody_V1DefaultResolution(t *testing.T) { + a := &Adaptor{} + out := buildBody(t, a, testInfo("", "", false), `{"model":"MiniMax-Hailuo-2.3","prompt":"hi"}`) + if out["resolution"] != "768P" { + t.Fatalf("hailuo default resolution: %v", out["resolution"]) + } + out = buildBody(t, a, testInfo("", "", false), `{"model":"T2V-01","prompt":"hi"}`) + if out["resolution"] != "720P" { + t.Fatalf("legacy model default resolution: %v", out["resolution"]) + } +} + +func TestDoResponse_V1(t *testing.T) { + a := &Adaptor{} + info := testInfo("", "MiniMax-Hailuo-2.3", false) + + // 提交成功(含 base_resp 成功信封) + taskID, _, taskErr := a.DoResponse(context.Background(), + httpResp(http.StatusOK, `{"task_id":"v1-up-1","base_resp":{"status_code":0,"status_msg":"success"}}`), info) + if taskErr != nil || taskID != "v1-up-1" { + t.Fatalf("v1 submit: id=%q err=%+v", taskID, taskErr) + } + + // HTTP 200 但 base_resp 业务码非 0:业务失败 + _, _, taskErr = a.DoResponse(context.Background(), + httpResp(http.StatusOK, `{"base_resp":{"status_code":2013,"status_msg":"invalid parameters"}}`), info) + if taskErr == nil || taskErr.Message != "invalid parameters" || taskErr.ErrCode != "2013" { + t.Fatalf("v1 business error: %+v", taskErr) + } +} + +// TestFetchTask_V1_TwoHop v1 查询:Success 后链式二跳 files/retrieve 换 download_url 并合并进响应体 +func TestFetchTask_V1_TwoHop(t *testing.T) { + var queryPath, filePath string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/v1/query/video_generation"): + queryPath = r.URL.Path + "?" + r.URL.RawQuery + _, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","video_width":1280,"video_height":720,"base_resp":{"status_code":0}}`)) + case strings.HasPrefix(r.URL.Path, "/v1/files/retrieve"): + filePath = r.URL.Path + "?" + r.URL.RawQuery + _, _ = w.Write([]byte(`{"file":{"file_id":9001,"download_url":"https://cdn.example/out.mp4"},"base_resp":{"status_code":0}}`)) + default: + t.Errorf("unexpected upstream path: %s", r.URL.Path) + } + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + + if queryPath != "/v1/query/video_generation?task_id=v1-up-1" { + t.Fatalf("v1 query path: %s", queryPath) + } + if filePath != "/v1/files/retrieve?file_id=9001" { + t.Fatalf("file retrieve path: %s", filePath) + } + + // 合并后的响应体经 ParseTaskResult 提取 ResultURL + body, _ := io.ReadAll(resp.Body) + info, err := a.ParseTaskResult(body) + if err != nil { + t.Fatalf("parse merged: %v", err) + } + if info.Status != common.TaskStatusSuccess || info.ResultURL != "https://cdn.example/out.mp4" { + t.Fatalf("two-hop result: %+v", info) + } +} + +// TestFetchTask_V1_TwoHopFailure 二跳失败:任务仍按 Success 返回,仅缺 download_url +func TestFetchTask_V1_TwoHopFailure(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/query/video_generation") { + _, _ = w.Write([]byte(`{"task_id":"v1-up-1","status":"Success","file_id":"9001","base_resp":{"status_code":0}}`)) + return + } + // files/retrieve 返回业务错误 + _, _ = w.Write([]byte(`{"file":{"file_id":9001},"base_resp":{"status_code":1001,"status_msg":"request timeout"}}`)) + })) + defer upstream.Close() + + a := &Adaptor{} + taskData, _ := json.Marshal(map[string]any{"task_id": "v1-up-1", "use_proxy": false, "model": "MiniMax-Hailuo-2.3"}) + resp, err := a.FetchTask(context.Background(), upstream.URL, "sk-test", taskData) + if err != nil { + t.Fatalf("fetch: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + info, err := a.ParseTaskResult(body) + if err != nil { + t.Fatalf("parse: %v", err) + } + if info.Status != common.TaskStatusSuccess { + t.Fatalf("two-hop failure must not change Success status: %+v", info) + } + if info.ResultURL != "" { + t.Fatalf("download_url must be absent on two-hop failure: %q", info.ResultURL) + } +} + +func TestParseTaskResult_V1Statuses(t *testing.T) { + a := &Adaptor{} + cases := map[string]struct { + status common.TaskStatusEnum + reason string + }{ + `{"task_id":"t","status":"Preparing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""}, + `{"task_id":"t","status":"Queueing","base_resp":{"status_code":0}}`: {common.TaskStatusQueued, ""}, + `{"task_id":"t","status":"Processing","base_resp":{"status_code":0}}`: {common.TaskStatusInProgress, ""}, + `{"task_id":"t","status":"Fail","base_resp":{"status_code":1026,"status_msg":"content violates policy"}}`: {common.TaskStatusFailure, "content violates policy"}, + } + for body, want := range cases { + info, err := a.ParseTaskResult([]byte(body)) + if err != nil { + t.Fatalf("parse %s: %v", body, err) + } + if info.Status != want.status { + t.Errorf("%s: status=%v want %v", body, info.Status, want.status) + } + if want.reason != "" && info.FailReason != want.reason { + t.Errorf("%s: failReason=%q want %q", body, info.FailReason, want.reason) + } + } +} diff --git a/relay/taskchannel/minimax/cancel.go b/relay/taskchannel/minimax/cancel.go new file mode 100644 index 0000000..6f8c6a8 --- /dev/null +++ b/relay/taskchannel/minimax/cancel.go @@ -0,0 +1,66 @@ +package minimax + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/qianfree/team-api/relay/common" +) + +// 上游任务取消(DELETE /v2/video_generation/{task_id})。 +// 官方语义:仅 queued 态可取消(action=cancelled);running / cancelled 不可操作; +// succeeded / failed 是删除任务记录(action=deleted)——网关只做取消、不做删除, +// 因此调用方必须校验返回的 action 字段,仅 cancelled 视为取消成功。 + +// CancelResult 上游取消响应(DeleteVideoGenerationV2Resp) +type CancelResult struct { + TaskID string `json:"task_id"` + Action string `json:"action"` // cancelled = 取消成功;deleted = 任务已终态被删记录(非取消) + Status string `json:"status"` +} + +// CancelTask 取消上游排队中的任务。 +// 返回的 TaskError 携带上游状态码与错误信息(含上游判定「运行中不可取消」等场景), +// 调用方以本结果为准决定是否向客户端确认取消。 +func CancelTask(baseURL, apiKey, taskID string, useProxy bool) (*CancelResult, *common.TaskError) { + url := fmt.Sprintf("%s/v2/video_generation/%s", strings.TrimRight(baseURL, "/"), taskID) + req, err := http.NewRequest(http.MethodDelete, url, nil) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "create cancel request failed"} + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := common.NewPooledClient(30, useProxy) + resp, err := client.Do(req) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusBadGateway, Message: "cancel request failed: " + err.Error()} + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "read cancel response failed"} + } + + if resp.StatusCode != http.StatusOK { + msg := strings.TrimSpace(string(body)) + var errResp oaiErrorBody + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + } + if msg == "" { + msg = fmt.Sprintf("upstream returned status %d", resp.StatusCode) + } + return nil, &common.TaskError{StatusCode: resp.StatusCode, Message: msg} + } + + var result CancelResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, &common.TaskError{StatusCode: http.StatusInternalServerError, Message: "parse cancel response failed"} + } + return &result, nil +} diff --git a/relay/taskchannel/minimax/constants.go b/relay/taskchannel/minimax/constants.go new file mode 100644 index 0000000..da6bab4 --- /dev/null +++ b/relay/taskchannel/minimax/constants.go @@ -0,0 +1,21 @@ +package minimax + +// MiniMax 视频生成模型:H3 系列走 v2 协议(content[] 多模态),Hailuo 2.x 主力型号走 v1 扁平协议。 +// 旧型号(T2V/I2V/S2V-01 系列)不进默认清单,但请求传入照样转发(协议分派按模型名,不拦)。 +var ModelList = []string{ + "MiniMax-H3", + "MiniMax-H3-Max", + "MiniMax-Hailuo-2.3", + "MiniMax-Hailuo-2.3-Fast", + "MiniMax-Hailuo-02", +} + +const channelName = "MiniMaxVideo" + +// 分辨率/时长缺省值(与计费估算共用,见 adaptor.go 的 resolveResolution/resolveDuration) +const ( + defaultResolution = "768P" // H3 与 Hailuo 2.x 的默认档 + defaultResolutionV1 = "720P" // T2V/I2V/S2V 等旧型号的默认档 + defaultDurationSecs = 6 + defaultTextOnlyRatio = "16:9" +) diff --git a/relay/taskchannel/minimax/types.go b/relay/taskchannel/minimax/types.go new file mode 100644 index 0000000..28c95a9 --- /dev/null +++ b/relay/taskchannel/minimax/types.go @@ -0,0 +1,51 @@ +package minimax + +// MiniMax v2 视频协议的任务对象与响应结构。 +// 字段定义对齐官方 VideoTask(docs/modeldocs/minimax/video/04-响应对象.md), +// 由适配器解析上游查询响应、relay/handler 层组装查询端点回放响应共用。 + +// VideoTaskContent 任务产物内容(视频任务返回 url,Context-IR 任务返回 prompt) +type VideoTaskContent struct { + URL string `json:"url,omitempty"` + Prompt string `json:"prompt,omitempty"` +} + +// VideoTaskError 任务错误信息(任务失败时返回) +type VideoTaskError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// VideoTaskUsage 用量:视频任务返回按秒计量字段,H3-Context-IR 任务返回 Token 字段 +type VideoTaskUsage struct { + TotalSeconds int `json:"total_seconds,omitempty"` + InputSeconds int `json:"input_seconds,omitempty"` + OutputSeconds int `json:"output_seconds,omitempty"` + InputImageCount int `json:"input_image_count,omitempty"` + InputAudioSeconds int `json:"input_audio_seconds,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` +} + +// VideoTask 官方 VideoTask 对象(单任务查询与列表接口共用的任务载体) +type VideoTask struct { + ID string `json:"id"` + Model string `json:"model"` + Status string `json:"status"` + Error *VideoTaskError `json:"error,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Content *VideoTaskContent `json:"content,omitempty"` + Resolution string `json:"resolution,omitempty"` + Duration int `json:"duration,omitempty"` + Usage *VideoTaskUsage `json:"usage,omitempty"` + Ratio string `json:"ratio,omitempty"` + TaskType string `json:"task_type,omitempty"` + Modality string `json:"modality,omitempty"` +} + +// TaskQueryResponse v2 单任务查询响应(GetVideoGenerationV2Resp,task 外层包装) +type TaskQueryResponse struct { + Task *VideoTask `json:"task"` +} diff --git a/relay/taskchannel/sora/adaptor.go b/relay/taskchannel/sora/adaptor.go index 8a43b30..d63eb4b 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 } @@ -84,12 +102,12 @@ func (a *SoraAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo return strings.NewReader(string(data)), nil } -func (a *SoraAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *SoraAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -123,7 +141,7 @@ func (a *SoraAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *comm return result.ID, body, nil } -func (a *SoraAdaptor) FetchTask(baseURL, apiKey string, _ []byte) (*http.Response, error) { +func (a *SoraAdaptor) FetchTask(_ context.Context, baseURL, apiKey string, _ []byte) (*http.Response, error) { // Sora 的 FetchTask 需要上游任务 ID,通过 taskData 传入 return nil, fmt.Errorf("sora: use FetchTaskByID instead") } diff --git a/relay/taskchannel/suno/adaptor.go b/relay/taskchannel/suno/adaptor.go index f04803b..224aaf9 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 } @@ -74,12 +74,12 @@ func (a *SunoAdaptor) BuildRequestBody(_ context.Context, info *common.RelayInfo return strings.NewReader(string(data)), nil } -func (a *SunoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *SunoAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest("POST", url, requestBody) + req, err := http.NewRequestWithContext(ctx, "POST", url, requestBody) if err != nil { return nil, err } @@ -121,14 +121,14 @@ func (a *SunoAdaptor) DoResponse(_ context.Context, resp *http.Response, _ *comm return result.Data, body, nil } -func (a *SunoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *SunoAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var meta struct { UseProxy bool `json:"use_proxy"` } _ = json.Unmarshal(taskData, &meta) url := fmt.Sprintf("%s/suno/fetch", strings.TrimRight(baseURL, "/")) - req, err := http.NewRequest("POST", url, bytes.NewReader(taskData)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(taskData)) if err != nil { return nil, err } diff --git a/relay/taskchannel/volcengine/adaptor.go b/relay/taskchannel/volcengine/adaptor.go index d26772b..08f4c58 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 } @@ -261,12 +265,12 @@ func (a *VolcengineVideoAdaptor) BuildRequestBody(_ context.Context, info *commo return strings.NewReader(string(data)), nil } -func (a *VolcengineVideoAdaptor) DoRequest(_ context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { +func (a *VolcengineVideoAdaptor) DoRequest(ctx context.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { url, err := a.BuildRequestURL(info) if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, url, requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody) if err != nil { return nil, err } @@ -305,7 +309,7 @@ func (a *VolcengineVideoAdaptor) DoResponse(_ context.Context, resp *http.Respon return result.ID, body, nil } -func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []byte) (*http.Response, error) { +func (a *VolcengineVideoAdaptor) FetchTask(ctx context.Context, baseURL, apiKey string, taskData []byte) (*http.Response, error) { var data struct { TaskID string `json:"task_id"` UseProxy bool `json:"use_proxy"` @@ -316,7 +320,7 @@ func (a *VolcengineVideoAdaptor) FetchTask(baseURL, apiKey string, taskData []by url := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/api"), data.TaskID) - req, err := http.NewRequest(http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } diff --git a/relaykit/relayconvert/register/golden/inputs/claude__schema-variants.json b/relaykit/relayconvert/register/golden/inputs/claude__schema-variants.json new file mode 100644 index 0000000..583ccbe --- /dev/null +++ b/relaykit/relayconvert/register/golden/inputs/claude__schema-variants.json @@ -0,0 +1,40 @@ +{ + "model": "origin-model", + "max_tokens": 512, + "messages": [ + {"role": "user", "content": "Search the repo with the where filter."} + ], + "tools": [ + { + "name": "grep", + "description": "search repo", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "where": { + "type": "array", + "maxItems": 10, + "items": { + "type": "array", + "prefixItems": [ + {"type": "string"}, + {"enum": ["AND", "OR"], "type": "string"}, + {} + ] + } + }, + "paths": {"type": "array"}, + "tag": {"anyOf": [{"type": "string"}, {"type": "number"}]} + }, + "required": ["pattern"] + } + }, + "required": ["query"] + } + } + ] +} diff --git a/tests/integration/admin/tenant_tags_test.go b/tests/integration/admin/tenant_tags_test.go new file mode 100644 index 0000000..375d64c --- /dev/null +++ b/tests/integration/admin/tenant_tags_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package admin_test + +import ( + "fmt" + "testing" + + "github.com/qianfree/team-api/tests/integration/admin/testinfra" +) + +// TestTenantTagsAndRemark 租户标签/备注管理:更新 → 列表/详情可读 → 标签筛选 → 清空。 +// 运行前置:服务已部署包含 000021 迁移(tnt_tenants.tags/remark 列)。 +func TestTenantTagsAndRemark(t *testing.T) { + client := testinfra.GetAuthedClient(t) + + // 取一个真实租户:列表第一条 + listResp := client.Get("/api/admin/tenants", map[string]string{"page": "1", "page_size": "1"}) + listResp.AssertSuccess(t) + var listData struct { + List []struct { + ID int64 `json:"id"` + } `json:"list"` + } + listResp.DecodeData(t, &listData) + if len(listData.List) == 0 { + t.Skip("无租户数据,跳过标签/备注集成测试") + } + tenantID := listData.List[0].ID + + marker := fmt.Sprintf("it-tag-%d", tenantID) + + // 1. 更新标签与备注 + updateResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": []string{marker, "集成测试"}, + "remark": "集成测试备注", + }) + updateResp.AssertSuccess(t) + + // 2. 详情可读 + getResp := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + getResp.AssertSuccess(t) + var detail struct { + Tags []string `json:"tags"` + Remark string `json:"remark"` + TotalConsumed string `json:"total_consumed"` + } + getResp.DecodeData(t, &detail) + if len(detail.Tags) != 2 || detail.Tags[0] != marker { + t.Fatalf("tags = %v, want [%s 集成测试]", detail.Tags, marker) + } + if detail.Remark != "集成测试备注" { + t.Fatalf("remark = %q", detail.Remark) + } + if detail.TotalConsumed == "" { + t.Fatal("total_consumed 应返回 decimal 字符串(无消费时为 \"0\")") + } + + // 3. 标签精确筛选能命中 + filterResp := client.Get("/api/admin/tenants", map[string]string{ + "page": "1", "page_size": "20", "tag": marker, + }) + filterResp.AssertSuccess(t) + var filterData struct { + List []struct { + ID int64 `json:"id"` + } `json:"list"` + } + filterResp.DecodeData(t, &filterData) + if len(filterData.List) != 1 || filterData.List[0].ID != tenantID { + t.Fatalf("tag filter should hit tenant %d exactly, got %v", tenantID, filterData.List) + } + + // 4. 标签数量超限被拒(11 个) + tooMany := make([]string, 11) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("tag-%d", i) + } + badResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": tooMany, + }) + if badResp.Code == 0 { + t.Fatal("11 个标签应被拒绝") + } + + // 5. 清空标签/备注(空数组语义) + clearResp := client.Put(fmt.Sprintf("/api/admin/tenants/%d", tenantID), map[string]any{ + "tags": []string{}, "remark": "", + }) + clearResp.AssertSuccess(t) + getResp2 := client.Get(fmt.Sprintf("/api/admin/tenants/%d", tenantID), nil) + getResp2.AssertSuccess(t) + var detail2 struct { + Tags []string `json:"tags"` + Remark string `json:"remark"` + } + getResp2.DecodeData(t, &detail2) + if len(detail2.Tags) != 0 || detail2.Remark != "" { + t.Fatalf("清空后 tags=%v remark=%q", detail2.Tags, detail2.Remark) + } +} diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index 8887117..00527b1 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -1,9 +1,18 @@ + + + + + + diff --git a/web/admin/src/components/PricingTimeSegmentsEditor.vue b/web/admin/src/components/PricingTimeSegmentsEditor.vue new file mode 100644 index 0000000..77f8496 --- /dev/null +++ b/web/admin/src/components/PricingTimeSegmentsEditor.vue @@ -0,0 +1,412 @@ + + + + + + + diff --git a/web/admin/src/components/pricingSchemeEditors.ts b/web/admin/src/components/pricingSchemeEditors.ts new file mode 100644 index 0000000..786e281 --- /dev/null +++ b/web/admin/src/components/pricingSchemeEditors.ts @@ -0,0 +1,41 @@ +import type { Component } from 'vue' +import MinimaxMaterialEditor from './pricingSchemes/MinimaxMaterialEditor.vue' + +// 计费方案 → 定价编辑器组件映射(特殊计费方案的定价表单在此登记)。 +// 后端 pricing JSONB 顶层 scheme 键声明方案名,ModelPricingModal 据此分发内容区; +// 空名/未登记的方案回落内置通用表单。自定义方案建议 custom: 前缀命名,避免与内置方案撞名。 +// +// 编辑器契约(新增方案组件只需满足此契约并在下方登记一行): +// props: { modelId: number, schemeConfig: any } // schemeConfig = 库内已有配置初值(可为 null) +// expose: save(): Promise // 校验 + PUT 保存;失败自行 Message 提示并返回 false +// 保存成功后的提示与关弹窗由 ModelPricingModal 外壳统一处理,组件无需 emit +const schemeEditors: Record = { + // MiniMax-H3 素材计费(输出按秒 + 输入图片/视频素材) + 'custom:minimax-material': MinimaxMaterialEditor, +} + +// 特殊计费方案选项(新增方案在此登记一行即出现在定价面板的模式下拉中)。 +// modelPrefixes 为模型编码前缀白名单(大小写不敏感):仅命中的模型才显示该特殊计费选项, +// 其余模型只有通用计费,防止把专属方案误配到不适用的模型上。 +export interface SchemeEditorOption { + label: string + value: string + modelPrefixes: string[] +} + +export const schemeEditorOptions: SchemeEditorOption[] = [ + { label: '素材计费(MiniMax)', value: 'custom:minimax-material', modelPrefixes: ['MiniMax-H3'] }, +] + +// availableSchemeOptions 按模型编码过滤可用的特殊计费选项 +export function availableSchemeOptions(modelIdStr?: string | null): SchemeEditorOption[] { + if (!modelIdStr) return [] + const lower = modelIdStr.toLowerCase() + return schemeEditorOptions.filter((o) => o.modelPrefixes.some((p) => lower.startsWith(p.toLowerCase()))) +} + +// resolvePricingSchemeEditor 按方案名取编辑器组件;null = 使用通用表单 +export function resolvePricingSchemeEditor(scheme?: string | null): Component | null { + if (!scheme) return null + return schemeEditors[scheme] ?? null +} diff --git a/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue new file mode 100644 index 0000000..3af627f --- /dev/null +++ b/web/admin/src/components/pricingSchemes/MinimaxMaterialEditor.vue @@ -0,0 +1,303 @@ + + + + + diff --git a/web/admin/src/constants/vendor.ts b/web/admin/src/constants/vendor.ts new file mode 100644 index 0000000..2c8fcac --- /dev/null +++ b/web/admin/src/constants/vendor.ts @@ -0,0 +1,61 @@ +// 模型研发厂商共享常量(管理后台模型管理列表/筛选/表单共用) +// value 与后端 mdl_models.vendor 枚举一一对应(api/admin/v1/model.go 的 v:"in:" 校验、 +// internal/logic/admin/model.go 的 validModelVendors)。 +// 新增厂商时需同步:后端枚举校验、租户端 web/tenant/src/constants/vendor.ts、本文件。 +// 注意与渠道供应商类型(constants/channel.ts,chn_channels.type)是两个维度:厂商 = 模型的研发公司, +// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。 + +export const vendorOptions: { label: string; value: string }[] = [ + { label: 'OpenAI', value: 'openai' }, + { label: 'Anthropic', value: 'anthropic' }, + { label: 'Google', value: 'google' }, + { label: 'xAI', value: 'xai' }, + { label: 'Mistral', value: 'mistral' }, + { label: 'Cohere', value: 'cohere' }, + { label: 'Meta', value: 'meta' }, + { label: '阿里巴巴', value: 'alibaba' }, + { label: '字节跳动', value: 'bytedance' }, + { label: '深度求索', value: 'deepseek' }, + { label: '智谱AI', value: 'zhipu' }, + { label: '月之暗面', value: 'moonshot' }, + { label: 'MiniMax', value: 'minimax' }, + { label: '百度', value: 'baidu' }, + { label: '腾讯', value: 'tencent' }, + { label: '科大讯飞', value: 'xunfei' }, + { label: '快手', value: 'kuaishou' }, + { label: 'Midjourney', value: 'midjourney' }, + { label: 'Suno', value: 'suno' }, +] + +// value -> label 映射,表格列展示用 +export const vendorLabelMap: Record = {} +vendorOptions.forEach(o => { vendorLabelMap[o.value] = o.label }) + +// value -> Arco Tag 预设色映射(淡底深字,取接近品牌色的预设) +export const vendorTagColor: Record = { + openai: 'green', + anthropic: 'orange', + google: 'arcoblue', + xai: 'gray', + mistral: 'orangered', + cohere: 'cyan', + meta: 'blue', + alibaba: 'purple', // 图标用通义千问 mark(品牌紫),Tag 色随之取紫 + bytedance: 'arcoblue', + deepseek: 'blue', + zhipu: 'purple', + moonshot: 'gray', + minimax: 'red', + baidu: 'blue', + tencent: 'cyan', + xunfei: 'arcoblue', + kuaishou: 'orangered', + midjourney: 'gray', + suno: 'purple', +} + +// Arco Select 的 allow-search 自定义过滤:小写包含匹配 +export function filterVendorOption(inputValue: string, option: { label: string; value: string }) { + const input = inputValue.toLowerCase() + return option.label.toLowerCase().includes(input) +} diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 5b722d1..9a93b62 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -11,6 +11,7 @@ import request from '@/utils/request' import { useExport } from '@/composables/useExport' import ResponsiveTable from '@/components/ResponsiveTable.vue' import { formatBilling } from '@/composables/useCurrency' +import { vendorOptions, vendorLabelMap, vendorTagColor, filterVendorOption } from '@/constants/vendor' const loading = ref(false) const data = ref([]) @@ -23,6 +24,7 @@ const pagination = reactive({ }) const filterCategory = ref(null) +const filterVendor = ref(null) const filterStatus = ref(null) const filterSearch = ref('') const filterPricingStatus = ref(null) @@ -48,6 +50,8 @@ const categoryOptions = [ { label: '重排', value: 'rerank' }, ] +const vendorFilterOptions = [{ label: '全部厂商', value: '' }, ...vendorOptions] + const statusOptions = [ { label: '全部状态', value: '' }, { label: '启用', value: 'active' }, @@ -66,6 +70,7 @@ const form = reactive({ model_id: '', model_name: '', category: 'chat', + vendor: '', max_context_tokens: null as number | null, max_output_tokens: null as number | null, capabilities: {} as Record, @@ -157,6 +162,8 @@ const pricingModeTagColor: Record = { token: 'arcoblue', per_request: 'orangered', tiered: 'purple', + per_second: 'green', + special: 'orange', } // ===== 移动端卡片自定义布局用的格式化(仅用于卡片插槽,不改动桌面端列 render)===== @@ -172,18 +179,30 @@ function formatTokens(n: number | null | undefined): string { return String(n) } -// 定价主文本:本位币 输入/输出、单价/次、未定价(bil 层定价直显) +// 按秒模式基准单价:优先 "*" 兜底价,否则取矩阵最小正价(与定价弹窗 perSecondBasePrice 口径一致) +function perSecondBasePrice(row: any): number { + const prices = Object.values(row.per_second_prices || {}).map((p) => Number(p) || 0).filter((p) => p > 0) + if (prices.length === 0) return 0 + const wildcard = Number(row.per_second_prices?.['*']) || 0 + return wildcard > 0 ? wildcard : Math.min(...prices) +} + +// 定价主文本:本位币 输入/输出、单价/次、单价/秒、未定价(bil 层定价直显); +// special(特殊计费)按输出生成组件的每秒基准价展示 function pricingMain(row: any): string { if (!row.pricing_mode) return '未定价' if (row.pricing_mode === 'per_request') { return `${formatBilling(row.per_request_price ?? 0, 4)}/次` } + if (row.pricing_mode === 'per_second' || row.pricing_mode === 'special') { + return `${formatBilling(perSecondBasePrice(row), 4)}/秒` + } return `${formatBilling(row.input_price ?? 0, 2)}/${formatBilling(row.output_price ?? 0, 2)}` } -// 定价模式副文本:按量 / 按次 / 阶梯(未定价返回空串) +// 定价模式副文本:按量 / 按次 / 阶梯 / 按秒 / 特殊(未定价返回空串) function pricingMode(row: any): string { - const m: Record = { token: '按量', per_request: '按次', tiered: '阶梯' } + const m: Record = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒', special: '特殊计费' } return row.pricing_mode ? (m[row.pricing_mode] || row.pricing_mode) : '' } @@ -213,6 +232,15 @@ const columns: TableColumnData[] = [ return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) }, }, + { + title: '厂商', + dataIndex: 'vendor', + width: 100, + render({ record }) { + if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—') + return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor) + }, + }, { title: '上下文', dataIndex: 'max_context_tokens', width: 100 }, { title: '输出上限', dataIndex: 'max_output_tokens', width: 100 }, { @@ -223,13 +251,16 @@ const columns: TableColumnData[] = [ if (!record.pricing_mode) { return h(Tag, { color: 'orangered', size: 'small' }, () => '未定价') } - const modeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯' } + const modeLabel: Record = { token: '按量', per_request: '按次', tiered: '阶梯', per_second: '按秒', special: '特殊计费' } const tags = [ h(Tag, { color: pricingModeTagColor[record.pricing_mode] || 'gray', size: 'small' }, () => modeLabel[record.pricing_mode] || record.pricing_mode), ] if (record.pricing_mode === 'per_request') { tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, `${formatBilling(record.per_request_price ?? 0, 4)}/次`)) + } else if (record.pricing_mode === 'per_second' || record.pricing_mode === 'special') { + tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, + `${formatBilling(perSecondBasePrice(record), 4)}/秒`)) } else { tags.push(h('span', { style: 'font-size: 12px; color: var(--color-text-3); margin-left: 4px;' }, `${formatBilling(record.input_price ?? 0, 2)}/${formatBilling(record.output_price ?? 0, 2)}`)) @@ -313,6 +344,7 @@ async function fetchData() { page_size: pagination.pageSize, } if (filterCategory.value) params.category = filterCategory.value + if (filterVendor.value) params.vendor = filterVendor.value if (filterStatus.value) params.status = filterStatus.value if (filterSearch.value) params.search = filterSearch.value if (filterPricingStatus.value) params.pricing_status = filterPricingStatus.value @@ -340,6 +372,7 @@ function openCreate() { form.model_id = '' form.model_name = '' form.category = 'chat' + form.vendor = '' form.max_context_tokens = null form.max_output_tokens = null form.capabilities = {} @@ -358,6 +391,7 @@ function openEdit(row: any) { form.model_id = row.model_id form.model_name = row.model_name || '' form.category = row.category + form.vendor = row.vendor || '' form.max_context_tokens = row.max_context_tokens || null form.max_output_tokens = row.max_output_tokens || null form.capabilities = row.capabilities || {} @@ -428,6 +462,7 @@ const { exporting, exportFile } = useExport({ url: '/admin/models/export', getFilters: () => ({ category: filterCategory.value, + vendor: filterVendor.value, status: filterStatus.value, search: filterSearch.value, }), @@ -511,6 +546,15 @@ const importColumns: TableColumnData[] = [ return h(Tag, { color: categoryTagColor[record.category], size: 'small' }, () => categoryTagLabel[record.category] || record.category) }, }, + { + title: '厂商', + dataIndex: 'vendor', + width: 90, + render({ record }) { + if (!record.vendor) return h('span', { style: 'color: var(--color-text-4);' }, '—') + return h(Tag, { color: vendorTagColor[record.vendor] || 'gray', size: 'small' }, () => vendorLabelMap[record.vendor] || record.vendor) + }, + }, { title: '状态', dataIndex: 'status', @@ -676,6 +720,16 @@ function resetImport() { style="width: 140px" @change="handleFilter" /> + + + +
diff --git a/web/admin/src/views/ai/TaskLogsPage.vue b/web/admin/src/views/ai/TaskLogsPage.vue index 2ddcdfd..9516c92 100644 --- a/web/admin/src/views/ai/TaskLogsPage.vue +++ b/web/admin/src/views/ai/TaskLogsPage.vue @@ -49,6 +49,7 @@ const platformOptions = [ { label: 'Suno', value: 'suno' }, { label: '火山引擎', value: 'volcengine' }, { label: '阿里', value: 'ali' }, + { label: 'MiniMax', value: 'minimax' }, ] const statusTagColor: Record = { @@ -74,6 +75,7 @@ const platformLabel: Record = { suno: 'Suno', volcengine: '火山引擎', ali: '阿里', + minimax: 'MiniMax', } const columns: TableColumnData[] = [ diff --git a/web/admin/src/views/tenant/TenantsPage.vue b/web/admin/src/views/tenant/TenantsPage.vue index 848def8..0d33b3d 100644 --- a/web/admin/src/views/tenant/TenantsPage.vue +++ b/web/admin/src/views/tenant/TenantsPage.vue @@ -26,6 +26,8 @@ const pagination = reactive({ const filterStatus = ref(null) const filterSearch = ref('') +const filterTag = ref('') +const filterLevel = ref(null) const statusOptions = [ { label: '全部状态', value: '' }, @@ -61,6 +63,8 @@ const form = reactive({ max_members: null as number | null, max_concurrency: null as number | null, level: null as number | null, + tags: [] as string[], + remark: '', }) // Create modal @@ -124,34 +128,30 @@ const columns = computed(() => [ { title: 'ID', dataIndex: 'id', width: 70 }, { title: '租户名', dataIndex: 'name', width: 160, ellipsis: true }, { title: '代码', dataIndex: 'code', width: 120 }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render({ record }) { - return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status) - }, - }, { title: '所有者', dataIndex: 'owner_name', width: 100, ellipsis: true }, - { title: '成员', dataIndex: 'member_count', width: 70 }, { - title: '成员上限', - dataIndex: 'effective_max_members', + title: '成员数', + dataIndex: 'member_count', width: 100, render({ record }) { - const val = record.effective_max_members ?? record.max_members - const custom = record.max_members != null ? ' (自定义)' : '' - return `${val}${custom}` + const limit = record.effective_max_members ?? record.max_members + return `${record.member_count || 0}/${limit ? limit : '不限'}` }, }, { title: '并发上限', dataIndex: 'effective_max_concurrency', - width: 100, + width: 110, render({ record }) { const val = record.effective_max_concurrency ?? record.max_concurrency - const custom = record.max_concurrency != null ? ' (自定义)' : '' - return val ? `${val}${custom}` : `不限${custom}` + const text = val ? `${val}` : '不限' + if (record.max_concurrency != null) { + return h(Space, { size: 4 }, () => [ + h('span', text), + h(Tag, { color: 'arcoblue', size: 'small' }, () => '自定义'), + ]) + } + return text }, }, { @@ -181,6 +181,35 @@ const columns = computed(() => [ return formatBilling(val, 2) }, }, + { + title: `累计消费(${displayCurrency.value})`, + dataIndex: 'total_consumed', + width: 130, + render({ record }) { + const val = parseFloat(record.total_consumed || '0') + return formatBilling(val, 2) + }, + }, + { + title: '标签', + dataIndex: 'tags', + width: 140, + render({ record }) { + const tags: string[] = record.tags || [] + if (!tags.length) return '-' + return h(Space, { size: 4, wrap: 'wrap' }, () => tags.slice(0, 3).map((t) => + h(Tag, { color: 'arcoblue', size: 'small' }, () => t), + ).concat(tags.length > 3 ? [h('span', { class: 'text-xs text-gray-400' }, `+${tags.length - 3}`)] : [])) + }, + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render({ record }) { + return h(Tag, { color: statusTagColor[record.status], size: 'small' }, () => statusTagLabel[record.status] || record.status) + }, + }, { title: '创建时间', dataIndex: 'created_at', width: 170 }, { title: '操作', @@ -205,6 +234,8 @@ async function fetchData() { } if (filterStatus.value) params.status = filterStatus.value if (filterSearch.value) params.keyword = filterSearch.value + if (filterTag.value.trim()) params.tag = filterTag.value.trim() + if (filterLevel.value != null) params.level = filterLevel.value const res: any = await request.get('/admin/tenants', { params }) data.value = res.data?.data?.list || res.data?.list || [] @@ -230,6 +261,8 @@ function openEdit(row: any) { form.max_members = row.max_members ?? null form.max_concurrency = row.max_concurrency ?? null form.level = row.level ?? null + form.tags = Array.isArray(row.tags) ? [...row.tags] : [] + form.remark = row.remark || '' showModal.value = true } @@ -248,6 +281,8 @@ async function handleSubmit(done: () => void) { max_members: form.max_members, max_concurrency: form.max_concurrency, level: form.level, + tags: form.tags, + remark: form.remark, }) Message.success('更新成功') done() @@ -282,6 +317,8 @@ const { exporting, exportFile } = useExport({ getFilters: () => ({ status: filterStatus.value, keyword: filterSearch.value, + tag: filterTag.value.trim() || undefined, + level: filterLevel.value ?? undefined, }), }) @@ -312,6 +349,17 @@ const { exporting, exportFile } = useExport({ style="width: 120px" @change="handleFilter" /> + + + {{ opt.name }} + + + 搜索 @@ -330,13 +386,13 @@ const { exporting, exportFile } = useExport({ :columns="columns" :data="data" :loading="loading" - :scroll="{ x: 1300 }" + :scroll="{ x: 1530 }" :stripe="true" row-key="id" card-title-key="name" card-subtitle-key="code" card-badge-key="status" - :card-fields="['owner_name', 'member_count', 'level', 'wallet_balance']" + :card-fields="['owner_name', 'member_count', 'level', 'wallet_balance', 'total_consumed']" />