Merge pull request #27 from qianfree/feat/video

新增OpenAI视频端点
This commit is contained in:
Qian authored and GitHub committed 2026-09-13 22:22:21 +08:00
commit 439cf2e31a
152 files changed
+13666 -1461

No files matched your search

+3 -2
View File
@@ -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 认领即删。
+117 -58
View File
@@ -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 导入结果响应
+32 -23
View File
@@ -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{}
+23 -17
View File
@@ -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 {
+7
View File
@@ -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=不展示)
+54 -1
View File
@@ -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 格式端点响应
+25
View File
@@ -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 构建)时,为后端根路径注册项目介绍页,
+2
View File
@@ -78,6 +78,7 @@ type BilUsageLogsColumns struct {
BillingSnapshot string // 完整计费计算过程快照(JSONB)
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
DurationSeconds string // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
}
// bilUsageLogsColumns holds the columns for the table bil_usage_logs.
@@ -139,6 +140,7 @@ var bilUsageLogsColumns = BilUsageLogsColumns{
BillingSnapshot: "billing_snapshot",
ProjectId: "project_id",
TaskId: "task_id",
DurationSeconds: "duration_seconds",
}
// NewBilUsageLogsDao creates and returns a new DAO object for table data access.
+2
View File
@@ -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.
+2
View File
@@ -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.
+20 -32
View File
@@ -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=周日 空=每天,end<start 表示跨零点
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Id string //
ModelId string // 关联模型ID
BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
CreatedAt string //
UpdatedAt string //
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Pricing string // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
OfficialPricing string // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
}
// mdlPricingColumns holds the columns for the table mdl_pricing.
var mdlPricingColumns = MdlPricingColumns{
Id: "id",
ModelId: "model_id",
BillingMode: "billing_mode",
MinTokens: "min_tokens",
MaxTokens: "max_tokens",
InputPrice: "input_price",
OutputPrice: "output_price",
PerRequestPrice: "per_request_price",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
CacheReadPrice: "cache_read_price",
CacheCreationPrice: "cache_creation_price",
TimeSegments: "time_segments",
PriceNote: "price_note",
DiscountLabel: "discount_label",
PriceChangeNote: "price_change_note",
Id: "id",
ModelId: "model_id",
BillingMode: "billing_mode",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
PriceNote: "price_note",
DiscountLabel: "discount_label",
PriceChangeNote: "price_change_note",
Pricing: "pricing",
OfficialPricing: "official_pricing",
}
// NewMdlPricingDao creates and returns a new DAO object for table data access.
@@ -23,7 +23,7 @@ type SysAdminRolePermsDao struct {
type SysAdminRolePermsColumns struct {
Id string // 主键ID
AdminUserId string // 关联的管理员用户ID
PermissionPoint string // 权限点标识(如 tenant:create、channel:edit)
PermissionPoint string // 特批的权限点标识(独立于角色,角色禁用不影响特批)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
+4
View File
@@ -40,6 +40,8 @@ type TntTenantsColumns struct {
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
Tags string // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
Remark string // 管理员备注(运营备注,不暴露给租户控制台)
}
// tntTenantsColumns holds the columns for the table tnt_tenants.
@@ -63,6 +65,8 @@ var tntTenantsColumns = TntTenantsColumns{
DefaultChannelScope: "default_channel_scope",
Level: "level",
TeamEnabled: "team_enabled",
Tags: "tags",
Remark: "remark",
}
// NewTntTenantsDao creates and returns a new DAO object for table data access.
+4
View File
@@ -46,6 +46,8 @@ type TskModelTasksColumns struct {
CreatedAt string // 记录创建时间
UpdatedAt string // 记录更新时间
RequestId string // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
Deleted string // 软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)
DeletedAt string // 软删除时间
}
// tskModelTasksColumns holds the columns for the table tsk_model_tasks.
@@ -75,6 +77,8 @@ var tskModelTasksColumns = TskModelTasksColumns{
CreatedAt: "created_at",
UpdatedAt: "updated_at",
RequestId: "request_id",
Deleted: "deleted",
DeletedAt: "deleted_at",
}
// NewTskModelTasksDao creates and returns a new DAO object for table data access.
+224
View File
@@ -0,0 +1,224 @@
package relay
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// MiniMax 官方视频协议入站胶水(/v2 端点):
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询/删除直连 relay_handler 的 MiniMax 视频处理器。
// 协议文档:docs/modeldocs/minimax/video/
// HandleMiniMaxVideoSubmit 处理 POST /v2/video_generation(创建视频生成任务)
func HandleMiniMaxVideoSubmit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
// 官方请求体 → 归一结构(content[] 原样保留,派生 prompt)
req, taskErr := relay_handler.ParseMiniMaxVideoCreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 content/resolution/duration + 双写通用词汇)
taskBody, err := relay_handler.BuildMiniMaxVideoTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax 官方协议:{"task_id"} 响应 + task_ 前缀 ID + 请求回显落库
// (协议标识与 relay/handler.minimaxVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
Protocol: "minimax_native",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxRequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoRetrieve 处理 GET /v2/query/video_generation/{task_id}(查询视频任务)
func HandleMiniMaxVideoRetrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoCancel 处理 DELETE /v2/video_generation/{task_id}(取消排队中的视频任务)
func HandleMiniMaxVideoCancel(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoCancel(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoV1Submit 处理 POST /v1/video_generation(MiniMax v1 官方协议创建,
// 文生/图生/首尾帧/主体参考四形态共用端点,扁平字段)
func HandleMiniMaxVideoV1Submit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
req, taskErr := relay_handler.ParseMiniMaxVideoV1CreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
taskBody, err := relay_handler.BuildMiniMaxVideoV1TaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax v1 官方协议:{task_id, base_resp} 响应 + task_ 前缀 ID + 请求回显落库
Protocol: "minimax_native_v1",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxV1RequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoV1Retrieve 处理 GET /v1/query/video_generation?task_id=(v1 官方协议任务查询)
func HandleMiniMaxVideoV1Retrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoV1Retrieve(r.Context(), taskID, rc, taskDataProvider)
}
+2 -2
View File
@@ -74,8 +74,8 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
// 2. 强制非流式:剥离 stream 字段,防 DoResponse 走 SSE 分支
cleanBody := stripStreamField(body)
// 3. 估价(图片走 per_request 价)
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil)
// 3. 估价(图片走 per_request 价;nil body = 参数倍率不参与,同步图片链路 ratios 不随任务持久化)
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil, nil)
if err != nil {
writeSyncImageError(w, http.StatusInternalServerError, "estimate cost failed: "+err.Error())
return
+165
View File
@@ -0,0 +1,165 @@
package relay
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// OpenAI Videos 协议入站胶水(/v1/videos 端点):
// 创建请求在此完成 multipart/JSON 解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询/删除/内容下载直连 relay_handler 的 Videos 处理器。
// HandleVideoCreate 处理 POST /v1/videos(创建视频生成任务)
func HandleVideoCreate(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
r.Response.WriteStatus(400, g.Map{
"error": g.Map{"type": "invalid_request_error", "message": "request body is empty"},
})
return
}
// OpenAI Videos 请求(multipart/JSON)→ 归一结构(官方 SDK 一律 multipart,必须支持)
req, taskErr := relay_handler.ParseVideosCreateRequest(body, r.Header.Get("Content-Type"))
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateVideosCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 model/prompt/seconds/metadata)
taskBody, err := relay_handler.BuildVideosTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// OpenAI Videos 协议:官方 Video 对象响应 + video_ 前缀 ID + 请求回显落库
Protocol: "openai_videos",
RelayMode: int(relay_constant.RelayModeVideos),
RequestEcho: relay_handler.NewVideosRequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleVideoRetrieve 处理 GET /v1/videos/{video_id}(查询视频任务)
func HandleVideoRetrieve(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosRetrieve(r.Context(), videoID, rc, taskDataProvider)
}
// HandleVideoDelete 处理 DELETE /v1/videos/{video_id}(删除视频任务)
func HandleVideoDelete(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosDelete(r.Context(), videoID, rc, taskDataProvider)
}
// HandleVideoContent 处理 GET /v1/videos/{video_id}/content(下载成品视频)
func HandleVideoContent(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosContent(r.Context(), videoID, r.Get("variant", "video").String(), rc, taskDataProvider)
}
// writeVideosError OpenAI 官方错误格式输出(与 writeTaskError 同构,供胶水层解析阶段使用)
func writeVideosError(r *ghttp.Request, statusCode int, message, code string) {
errObj := g.Map{
"type": "invalid_request_error",
"message": message,
}
if code != "" {
errObj["code"] = code
}
r.Response.WriteStatus(statusCode, g.Map{"error": errObj})
}
+437 -114
View File
@@ -3,6 +3,7 @@ package admin
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
@@ -70,19 +71,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
if req.Category != "" {
query = query.Where("category", req.Category)
}
if req.Vendor != "" {
query = query.Where("vendor", req.Vendor)
}
if req.Status != "" {
query = query.Where("status", req.Status)
}
if req.Search != "" {
query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
// 定价状态筛选
// 定价状态筛选(pricing JSONB 唯一真相:任一模式有正价即已定价)
// 已配价谓词:token 价>0 / per_request 价>0 / per_second 矩阵非空 / tiered 任一档价>0
const pricedPredicate = `EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND (
(pricing->>'input_price')::numeric > 0
OR (pricing->>'output_price')::numeric > 0
OR (pricing->>'price')::numeric > 0
OR (pricing->'prices' IS NOT NULL AND pricing->'prices' <> '{}'::jsonb)
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(pricing->'tiers', '[]'::jsonb)) tx WHERE (tx->>'input_price')::numeric > 0 OR (tx->>'output_price')::numeric > 0)
))`
if req.PricingStatus == "priced" {
// 已定价:存在 min_tokens=0 的记录,且输入价格或输出价格不为 0
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND (mdl_pricing.input_price > 0 OR mdl_pricing.output_price > 0 OR mdl_pricing.per_request_price > 0))")
query = query.Where(pricedPredicate)
} else if req.PricingStatus == "unpriced" {
// 未定价:存在 min_tokens=0 的记录,但所有价格字段都为 0
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND mdl_pricing.input_price = 0 AND mdl_pricing.output_price = 0 AND (mdl_pricing.per_request_price IS NULL OR mdl_pricing.per_request_price = 0))")
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id) AND NOT " + pricedPredicate)
}
var total int
@@ -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
}
+119 -94
View File
@@ -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)
}
// 时段定价透传(挂锚点行)
+144 -13
View File
@@ -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) {
+16 -1
View File
@@ -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
}
+66
View File
@@ -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)
}
}
@@ -0,0 +1,121 @@
//go:build e2e
// 手动 E2E(需真实 DB/Redis;默认 go test 不编译本文件)。
// 跑法:go test -tags e2e -run TestE2EParamMultiplier ./internal/logic/billing/
// 使用独立的临时模型行 + 不存在的租户 ID,走纯基础定价路径,结束即清理,不影响现有数据。
// 验证链路:pricing JSONB 配置 param_multipliers → TaskBillingProvider.EstimateTaskCost
// 携归一化任务体求值 → ratios 注入 param_multiplier/param_matched → 费用连乘。
package billing
import (
"fmt"
"testing"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gctx"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
)
func TestE2EParamMultiplier(t *testing.T) {
// 测试 CWD 是包目录,把配置搜索路径指回仓库根的 manifest/config
adapterFile, err := gcfg.NewAdapterFile()
if err != nil {
t.Fatalf("new config adapter: %v", err)
}
if err := adapterFile.AddPath("../../../manifest/config"); err != nil {
t.Fatalf("add config path: %v", err)
}
gcfg.Instance().SetAdapter(adapterFile)
ctx := gctx.New()
const tmpModel = "tmp-e2e-param-multiplier"
const tmpTenant = int64(999999998) // 不存在的租户:走纯基础定价路径
cleanup := func() {
var id int64
v, _ := dao.MdlModels.Ctx(ctx).Where("model_id", tmpModel).Fields("id").Value()
fmt.Sscanf(v.String(), "%d", &id)
if id > 0 {
_, _ = dao.MdlPricing.Ctx(ctx).Where("model_id", id).Delete()
_, _ = dao.MdlModels.Ctx(ctx).Where("id", id).Delete()
}
ClearModelPriceCache(ctx, tmpModel)
}
cleanup()
defer cleanup()
// 临时模型 + per_second 定价(含参数倍率规则:图生视频 ×1.5,≥10 秒 ×2.0,视频输入 ×0.609)
modelID, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(do.MdlModels{
ModelId: tmpModel,
ModelName: "E2E参数倍率临时模型",
Category: "video",
Status: "active",
})
if err != nil {
t.Fatalf("insert tmp model: %v", err)
}
pricingJSON := `{
"unit": "second",
"prices": {"720p": 0.5, "1080p": 1.0, "*": 0.5},
"param_multipliers": [
{"conditions": [{"path": "metadata.image", "match": "has_value"}], "multiplier": 1.5, "note": "图生视频"},
{"conditions": [{"path": "seconds", "match": "gte", "value": 10}], "multiplier": 2.0, "note": "长视频"},
{"conditions": [{"path": "metadata.content[*].video_url", "match": "has_value"}], "multiplier": 0.609, "note": "视频输入折扣"}
]
}`
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: modelID,
BillingMode: "per_second",
Pricing: pricingJSON,
}); err != nil {
t.Fatalf("insert tmp pricing: %v", err)
}
provider := NewTaskBillingProvider()
// 1. 命中「图生视频 ×1.5」+「长视频 ×2.0」(seconds 字符串 "12" 数字强转参与比较):
// 费用 = 0.5(720p) × 12s × 1.5 × 2.0 = 18.0;ratios 注入 param_multiplier=3 / param_matched
body := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`)
ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
cost, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios, body)
if err != nil {
t.Fatalf("estimate: %v", err)
}
assertDecimal(t, cost, 18.0, "e2e param multiplier cost (0.5×12×1.5×2)")
if m, _ := ratios["param_multiplier"].(float64); m != 3.0 {
t.Fatalf("param_multiplier injected = %v (type %T), want 3.0", ratios["param_multiplier"], ratios["param_multiplier"])
}
if matched, _ := ratios["param_matched"].(string); matched != "图生视频|长视频" {
t.Fatalf("param_matched = %q, want 图生视频|长视频", matched)
}
// 2. 纯文生视频(无图、5 秒):不命中任何规则,无注入;费用 = 0.5 × 5 = 2.5
ratios2 := map[string]any{"spec.duration": 5.0}
body2 := []byte(`{"model":"` + tmpModel + `","prompt":"p","seconds":"5"}`)
cost2, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios2, body2)
if err != nil {
t.Fatalf("estimate2: %v", err)
}
assertDecimal(t, cost2, 2.5, "e2e no-match cost")
if _, injected := ratios2["param_multiplier"]; injected {
t.Fatalf("no-match should not inject, got %v", ratios2["param_multiplier"])
}
// 3. 视频输入折扣([*] 路径)+ 1080p:费用 = 1.0 × 8 × 0.609 = 4.872
ratios3 := map[string]any{"spec.duration": 8.0, "spec.resolution": "1080p"}
body3 := []byte(`{"model":"` + tmpModel + `","prompt":"p","metadata":{"resolution":"1080p","duration":8,"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`)
cost3, err := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios3, body3)
if err != nil {
t.Fatalf("estimate3: %v", err)
}
assertDecimal(t, cost3, 4.872, "e2e video-input discount cost (1.0×8×0.609)")
// 4. 定价缓存命中路径同样生效(第二次调用走 600s 缓存里的 ParamMultipliers)
ratios4 := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
cost4, _ := provider.EstimateTaskCost(ctx, tmpTenant, tmpModel, ratios4, body)
assertDecimal(t, cost4, 18.0, "e2e cached param multiplier cost")
}
@@ -62,13 +62,12 @@ func TestE2ETimeSegmentPricing(t *testing.T) {
start := now.Add(-time.Hour).Format("15:04")
end := now.Add(time.Hour).Format("15:04")
segJSON := fmt.Sprintf(`[{"name":"闲时","start_time":%q,"end_time":%q,"multiplier":0.5}]`, start, end)
// pricing JSONB 单模式存储:token 价 + 时段配置(时段并入顶层)
pricingJSON := fmt.Sprintf(`{"input_price":3,"output_price":15,"time_segments":%s}`, segJSON)
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: modelID,
BillingMode: "token",
MinTokens: 0,
InputPrice: NewFromFloat(3),
OutputPrice: NewFromFloat(15),
TimeSegments: segJSON,
ModelId: modelID,
BillingMode: "token",
Pricing: pricingJSON,
}); err != nil {
t.Fatalf("insert tmp pricing: %v", err)
}
+4 -4
View File
@@ -27,10 +27,10 @@ const (
// 预定义常量(减少重复 NewFromInt 调用)
var (
Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算
MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容)
Zero = decimal.Zero // 常用零值
One = decimal.NewFromInt(1) // 常用 1
Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算
MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容)
Zero = decimal.Zero // 常用零值
One = decimal.NewFromInt(1) // 常用 1
)
// RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)。
+292
View File
@@ -0,0 +1,292 @@
package billing
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// 参数倍率(param_multipliers):按「网关归一化后的任务体」中的请求参数匹配规则,
// 对命中规则连乘倍率。规则配置在 mdl_pricing.pricing JSONB 顶层(横切所有计费模式,与 time_segments 同级)。
//
// 设计约束(docs/模型定价存储JSON化与按秒计费设计.md 3.2):
// - 匹配源 = 转换后的通用任务体({model, prompt, seconds, metadata{...}}),规则跟语义走不跟入口协议走;
// - 一条规则内扁平条件列表 = 隐式 AND(全部满足才命中),不做嵌套布尔组(已否决);
// - 规则间连乘,互斥分支多配几条规则即 OR;
// - 不做 NOT / 正则 / 范围外运算符;
// - 数字强转:字符串可转数字则参与比较("/v1/videos 的 seconds 是字符串")。
// ParamCondition 单个匹配条件
type ParamCondition struct {
Path string `json:"path"` // 任务体 JSON 路径,支持 [*] 数组通配(任一元素命中即命中),如 metadata.content[*].video_url
Match string `json:"match"` // has_value | equals | gt | gte | lt | lte | between
Value any `json:"value,omitempty"` // equals/gt/gte/lt/lte 单值;between 为 [min,max] 双元素数组
}
// ParamRule 参数倍率规则:conditions 全部满足时按 multiplier 计入连乘
type ParamRule struct {
Conditions []ParamCondition `json:"conditions"`
Multiplier float64 `json:"multiplier"`
Note string `json:"note,omitempty"` // 命中说明,写入计费上下文供对账解释
}
// 求值注入的 ratios 键(随任务 PrivateData.billing_context 持久化,
// 预扣 / per_second 结算 / token 重算三口径经 applyRatioMultipliers 自动同源)
const (
ratioKeyParamMultiplier = "param_multiplier"
ratioKeyParamMatched = "param_matched" // 命中规则 note 列表(| 分隔);string 值不参与乘法
)
// validParamMatches 合法匹配运算符集合
var validParamMatches = map[string]bool{
"has_value": true, "equals": true,
"gt": true, "gte": true, "lt": true, "lte": true, "between": true,
}
// ValidateParamMultipliers 校验规则配置(SetModelPricing / 模型导入 fail-fast 用)
func ValidateParamMultipliers(rules []ParamRule) error {
for i, rule := range rules {
if len(rule.Conditions) == 0 {
return fmt.Errorf("参数倍率规则 %d 至少需要一个条件", i+1)
}
if rule.Multiplier <= 0 || rule.Multiplier > 100 {
return fmt.Errorf("参数倍率规则 %d 倍率需在 (0, 100] 区间", i+1)
}
for j, cond := range rule.Conditions {
if cond.Path == "" {
return fmt.Errorf("参数倍率规则 %d 条件 %d 路径为空", i+1, j+1)
}
if !validParamMatches[cond.Match] {
return fmt.Errorf("参数倍率规则 %d 条件 %d 匹配方式非法:%s", i+1, j+1, cond.Match)
}
switch cond.Match {
case "between":
arr, ok := cond.Value.([]any)
if !ok || len(arr) != 2 {
return fmt.Errorf("参数倍率规则 %d 条件 %d between 需要 [min,max] 双元素", i+1, j+1)
}
if _, ok1 := toNumber(arr[0]); !ok1 {
return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1)
}
if _, ok2 := toNumber(arr[1]); !ok2 {
return fmt.Errorf("参数倍率规则 %d 条件 %d between 边界非数字", i+1, j+1)
}
case "has_value":
// 无需值
default: // equals / gt / gte / lt / lte 需要比对值
if cond.Value == nil {
return fmt.Errorf("参数倍率规则 %d 条件 %d 需要指定比对值", i+1, j+1)
}
if cond.Match != "equals" {
if _, ok := toNumber(cond.Value); !ok {
return fmt.Errorf("参数倍率规则 %d 条件 %d %s 需要数字值", i+1, j+1, cond.Match)
}
}
}
}
}
return nil
}
// EvalParamMultipliers 对任务体求值参数倍率:命中规则连乘,返回 (总倍率, 命中说明列表)。
// 无规则/无命中返回 (1.0, nil);解析失败的任务体安全返回未命中(不阻断计费)。
func EvalParamMultipliers(rules []ParamRule, taskBody []byte) (float64, []string) {
if len(rules) == 0 || len(taskBody) == 0 {
return 1.0, nil
}
var root any
if err := json.Unmarshal(taskBody, &root); err != nil {
return 1.0, nil
}
total := 1.0
var matched []string
for _, rule := range rules {
if rule.Multiplier <= 0 || !ruleConditionsMatch(rule.Conditions, root) {
continue
}
total *= rule.Multiplier
if rule.Note != "" {
matched = append(matched, rule.Note)
} else {
matched = append(matched, strings.Join(conditionPaths(rule.Conditions), " & "))
}
}
if len(matched) == 0 {
return 1.0, nil
}
return total, matched
}
// ruleConditionsMatch 隐式 AND:全部条件满足才命中
func ruleConditionsMatch(conds []ParamCondition, root any) bool {
for _, cond := range conds {
if !conditionMatch(cond, root) {
return false
}
}
return true
}
// conditionMatch 单条件匹配:路径提取([*] 扇出)后按运算符判断,任一候选值满足即命中
func conditionMatch(cond ParamCondition, root any) bool {
values, found := lookupPath(root, cond.Path)
if !found {
return false
}
switch cond.Match {
case "has_value":
// 路径存在且任一候选值非 null(false/0/"" 均视为"有值"——与"未传"语义不同)
for _, v := range values {
if v != nil {
return true
}
}
return false
case "equals":
for _, v := range values {
if valuesEqual(v, cond.Value) {
return true
}
}
return false
case "between":
arr, ok := cond.Value.([]any)
if !ok || len(arr) != 2 {
return false
}
lo, ok1 := toNumber(arr[0])
hi, ok2 := toNumber(arr[1])
if !ok1 || !ok2 {
return false
}
for _, v := range values {
if n, ok := toNumber(v); ok && n >= lo && n <= hi {
return true
}
}
return false
default: // gt / gte / lt / lte
target, ok := toNumber(cond.Value)
if !ok {
return false
}
for _, v := range values {
n, ok := toNumber(v)
if !ok {
continue
}
switch cond.Match {
case "gt":
if n > target {
return true
}
case "gte":
if n >= target {
return true
}
case "lt":
if n < target {
return true
}
case "lte":
if n <= target {
return true
}
}
}
return false
}
}
// lookupPath 按点分路径提取候选值([*] 数组通配扇出)。
// found=false 表示路径不存在(键缺失或类型不符);[*] 命中空数组时 found=false。
func lookupPath(root any, path string) (values []any, found bool) {
segments := strings.Split(path, ".")
current := []any{root}
for _, seg := range segments {
if seg == "" {
return nil, false
}
wildcard := strings.HasSuffix(seg, "[*]")
key := seg
if wildcard {
key = strings.TrimSuffix(seg, "[*]")
}
var next []any
for _, cur := range current {
m, ok := cur.(map[string]any)
if !ok {
continue
}
v, exists := m[key]
if !exists {
continue
}
if wildcard {
arr, ok := v.([]any)
if !ok {
continue
}
next = append(next, arr...)
} else {
next = append(next, v)
}
}
if len(next) == 0 {
return nil, false
}
current = next
}
return current, true
}
// valuesEqual 等值比较:数字强转后比较("8" == 8),string/bool 严格相等
func valuesEqual(v, target any) bool {
if target == nil {
return false
}
// 数字优先:两侧都能转数字则按数值比
if vn, ok1 := toNumber(v); ok1 {
if tn, ok2 := toNumber(target); ok2 {
return vn == tn
}
}
vs, ok1 := v.(string)
ts, ok2 := target.(string)
if ok1 && ok2 {
return vs == ts
}
vb, ok1 := v.(bool)
tb, ok2 := target.(bool)
if ok1 && ok2 {
return vb == tb
}
return false
}
// toNumber 数字强转:JSON number(float64)/ 可解析数字字符串 → float64;bool/对象/数组不可转
func toNumber(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(n), 64)
if err == nil {
return f, true
}
return 0, false
default:
return 0, false
}
}
// conditionPaths 条件路径列表(规则无 note 时作命中说明兜底)
func conditionPaths(conds []ParamCondition) []string {
paths := make([]string, 0, len(conds))
for _, c := range conds {
paths = append(paths, c.Path)
}
return paths
}
@@ -0,0 +1,304 @@
package billing
import (
"encoding/json"
"testing"
)
// ── lookupPath 路径提取 ──
func TestLookupPath(t *testing.T) {
var root any
json.Unmarshal([]byte(`{
"model": "m",
"seconds": "8",
"metadata": {
"image": "https://x",
"duration": 8,
"resolution": null,
"content": [
{"type": "text", "text": "hi"},
{"type": "image_url", "image_url": {"url": "a"}, "role": "first_frame"},
{"type": "video_url", "video_url": {"url": "v"}}
]
}
}`), &root)
cases := []struct {
path string
found bool
count int
}{
{"model", true, 1},
{"seconds", true, 1},
{"metadata.image", true, 1},
{"metadata.duration", true, 1},
{"metadata.resolution", true, 1}, // 存在但值为 null
{"metadata.missing", false, 0},
{"metadata", true, 1},
{"metadata.content", true, 1},
{"metadata.content[*].type", true, 3},
{"metadata.content[*].video_url.url", true, 1},
{"metadata.content[*].role", true, 1},
{"metadata.content[*].missing", false, 0},
{"", false, 0},
{"metadata..image", false, 0},
}
for _, c := range cases {
values, found := lookupPath(root, c.path)
if found != c.found {
t.Errorf("lookupPath(%q) found=%v want %v", c.path, found, c.found)
continue
}
if found && len(values) != c.count {
t.Errorf("lookupPath(%q) values=%d want %d", c.path, len(values), c.count)
}
}
}
// ── 匹配语义 ──
func evalOne(t *testing.T, body string, conds ...ParamCondition) bool {
t.Helper()
return ruleConditionsMatch(conds, jsonMustParse(t, body))
}
func jsonMustParse(t *testing.T, s string) any {
t.Helper()
var root any
if err := json.Unmarshal([]byte(s), &root); err != nil {
t.Fatalf("parse: %v", err)
}
return root
}
func TestMatchHasValue(t *testing.T) {
// false/0/空串均为"有值"(与未传语义不同);null 不算
if !evalOne(t, `{"watermark": false}`, ParamCondition{Path: "watermark", Match: "has_value"}) {
t.Error("false should count as has_value")
}
if !evalOne(t, `{"x": ""}`, ParamCondition{Path: "x", Match: "has_value"}) {
t.Error("empty string should count as has_value")
}
if !evalOne(t, `{"x": 0}`, ParamCondition{Path: "x", Match: "has_value"}) {
t.Error("zero should count as has_value")
}
if evalOne(t, `{"x": null}`, ParamCondition{Path: "x", Match: "has_value"}) {
t.Error("null should NOT count as has_value")
}
if evalOne(t, `{}`, ParamCondition{Path: "x", Match: "has_value"}) {
t.Error("missing key should not match")
}
// 数组:任一元素非 null
if !evalOne(t, `{"content":[{"video_url":null},{"video_url":{"url":"v"}}]}`,
ParamCondition{Path: "content[*].video_url", Match: "has_value"}) {
t.Error("any non-null element should match")
}
}
func TestMatchEquals(t *testing.T) {
// 数字强转:"8" == 8
if !evalOne(t, `{"seconds": "8"}`, ParamCondition{Path: "seconds", Match: "equals", Value: float64(8)}) {
t.Error(`"8" should equal 8`)
}
if !evalOne(t, `{"mode": "pro"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) {
t.Error("string equals")
}
if !evalOne(t, `{"flag": true}`, ParamCondition{Path: "flag", Match: "equals", Value: true}) {
t.Error("bool equals")
}
if evalOne(t, `{"mode": "std"}`, ParamCondition{Path: "mode", Match: "equals", Value: "pro"}) {
t.Error("different string should not equal")
}
}
func TestMatchComparisons(t *testing.T) {
cond := func(match string, val float64) ParamCondition {
return ParamCondition{Path: "metadata.duration", Match: match, Value: val}
}
body := `{"metadata":{"duration":8}}`
for _, m := range []string{"gt", "gte", "lt", "lte"} {
var want bool
switch m {
case "gt":
want = false
case "gte":
want = true
case "lt":
want = false
case "lte":
want = true
}
if got := evalOne(t, body, cond(m, 8)); got != want {
t.Errorf("%s 8 vs 8 = %v want %v", m, got, want)
}
}
// 字符串数字强转参与比较
if !evalOne(t, `{"seconds":"12"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) {
t.Error(`"12" gte 10 should match`)
}
// 非数字字符串不参与比较
if evalOne(t, `{"seconds":"abc"}`, ParamCondition{Path: "seconds", Match: "gte", Value: float64(10)}) {
t.Error("non-numeric string should not match comparison")
}
}
func TestMatchBetween(t *testing.T) {
cond := ParamCondition{Path: "metadata.duration", Match: "between", Value: []any{float64(8), float64(12)}}
if !evalOne(t, `{"metadata":{"duration":8}}`, cond) {
t.Error("between should be inclusive of min")
}
if !evalOne(t, `{"metadata":{"duration":12}}`, cond) {
t.Error("between should be inclusive of max")
}
if evalOne(t, `{"metadata":{"duration":7}}`, cond) {
t.Error("below min should not match")
}
// 数组任一元素落入区间
if !evalOne(t, `{"arr":[{"d":3},{"d":9}]}`, ParamCondition{Path: "arr[*].d", Match: "between", Value: []any{float64(8), float64(12)}}) {
t.Error("any element in range should match")
}
}
// ── 规则求值 ──
func TestEvalParamMultipliers(t *testing.T) {
rules := []ParamRule{
{
// 图生视频:metadata.image 有值 → 1.5
Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}},
Multiplier: 1.5,
Note: "图生视频",
},
{
// 隐式 AND:1080p 且 ≥10 秒 → 2.0
Conditions: []ParamCondition{
{Path: "metadata.resolution", Match: "equals", Value: "1080p"},
{Path: "metadata.duration", Match: "gte", Value: float64(10)},
},
Multiplier: 2.0,
Note: "1080p 长视频",
},
{
// 视频输入折扣
Conditions: []ParamCondition{{Path: "metadata.content[*].video_url", Match: "has_value"}},
Multiplier: 0.609,
Note: "视频输入折扣",
},
}
// 命中规则 1(×1.5),规则 2 分辨率不满足
m, matched := EvalParamMultipliers(rules, []byte(`{"model":"m","metadata":{"image":"https://x","resolution":"720p","duration":5}}`))
if m != 1.5 || len(matched) != 1 || matched[0] != "图生视频" {
t.Fatalf("case1: m=%v matched=%v", m, matched)
}
// 命中规则 1 + 规则 2(连乘 3.0)
m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":12}}`))
if m != 3.0 || len(matched) != 2 {
t.Fatalf("case2: m=%v matched=%v", m, matched)
}
// 全不命中 → 1.0 / nil
m, matched = EvalParamMultipliers(rules, []byte(`{"model":"m"}`))
if m != 1.0 || matched != nil {
t.Fatalf("case3: m=%v matched=%v", m, matched)
}
// AND 语义:一个条件不满足整条不命中
m, _ = EvalParamMultipliers(rules, []byte(`{"metadata":{"image":"i","resolution":"1080p","duration":5}}`))
if m != 1.5 {
t.Fatalf("case4: partial-AND should only match rule1, m=%v", m)
}
// 视频输入折扣([*] 路径)
m, matched = EvalParamMultipliers(rules, []byte(`{"metadata":{"content":[{"type":"video_url","video_url":{"url":"v"}}]}}`))
if m != 0.609 || len(matched) != 1 {
t.Fatalf("case5: m=%v matched=%v", m, matched)
}
// 无规则 / 空 body / 非法 JSON → 1.0
if m, _ := EvalParamMultipliers(nil, []byte(`{}`)); m != 1.0 {
t.Error("no rules should be 1.0")
}
if m, _ := EvalParamMultipliers(rules, nil); m != 1.0 {
t.Error("nil body should be 1.0")
}
if m, _ := EvalParamMultipliers(rules, []byte(`not-json`)); m != 1.0 {
t.Error("invalid json should be 1.0")
}
}
func TestEvalParamMultipliers_MultiplierProductOrder(t *testing.T) {
// 无 note 时命中说明回退为路径拼接
rules := []ParamRule{
{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 2.0},
{Conditions: []ParamCondition{{Path: "b", Match: "has_value"}}, Multiplier: 3.0},
}
m, matched := EvalParamMultipliers(rules, []byte(`{"a":1,"b":1}`))
if m != 6.0 || len(matched) != 2 || matched[0] != "a" {
t.Fatalf("m=%v matched=%v", m, matched)
}
}
// ── 校验 ──
func TestValidateParamMultipliers(t *testing.T) {
ok := []ParamRule{{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: float64(1)}}, Multiplier: 1.5}}
if err := ValidateParamMultipliers(ok); err != nil {
t.Fatalf("valid rules rejected: %v", err)
}
bad := []struct {
name string
rule ParamRule
}{
{"空条件", ParamRule{Conditions: nil, Multiplier: 1.5}},
{"倍率为零", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 0}},
{"倍率超上限", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "has_value"}}, Multiplier: 101}},
{"路径为空", ParamRule{Conditions: []ParamCondition{{Path: "", Match: "has_value"}}, Multiplier: 1.5}},
{"匹配方式非法", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "regex"}}, Multiplier: 1.5}},
{"gte 缺值", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte"}}, Multiplier: 1.5}},
{"gte 非数字", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "gte", Value: "abc"}}, Multiplier: 1.5}},
{"between 非数组", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: float64(1)}}, Multiplier: 1.5}},
{"between 单元素", ParamRule{Conditions: []ParamCondition{{Path: "a", Match: "between", Value: []any{float64(1)}}}, Multiplier: 1.5}},
}
for _, c := range bad {
if err := ValidateParamMultipliers([]ParamRule{c.rule}); err == nil {
t.Errorf("%s: should be rejected", c.name)
}
}
}
// ── EstimateTaskCost 注入集成 ──
func TestEstimateTaskCost_ParamMultiplierInjection(t *testing.T) {
// per_second 模型 + 参数倍率规则:命中注入 ratios 且费用连乘
pricing := &PricingResult{
BillingMode: "per_second",
TenantMultiplier: 1.0,
PerSecondPrices: map[string]float64{"720p": 0.5, "*": 0.5},
ParamMultipliers: []ParamRule{
{Conditions: []ParamCondition{{Path: "metadata.image", Match: "has_value"}}, Multiplier: 1.5, Note: "图生视频"},
{Conditions: []ParamCondition{{Path: "seconds", Match: "gte", Value: float64(10)}}, Multiplier: 2.0, Note: "长视频"},
},
}
// 直接调纯函数路径(estimateTaskCost 只吃 ratios)——先验证求值注入逻辑的等价小函数
ratios := map[string]any{"spec.duration": 12.0, "spec.resolution": "720p"}
body := []byte(`{"model":"m","prompt":"p","seconds":"12","metadata":{"image":"https://x","duration":12,"resolution":"720p"}}`)
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); m != 3.0 || len(matched) != 2 {
t.Fatalf("eval: m=%v matched=%v", m, matched)
}
// 模拟 EstimateTaskCost 内部注入(保持与实现一致的键名)
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, body); len(matched) > 0 && m > 0 {
ratios[ratioKeyParamMultiplier] = m
ratios[ratioKeyParamMatched] = matched[0]
}
// 注入后费用 = 0.5 × 12 × 1.5 × 2.0 = 18
assertDecimal(t, estimateTaskCost(pricing, ratios, 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")
}
+258 -56
View File
@@ -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。
+190
View File
@@ -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)
}
}
@@ -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)
}
}
+11
View File
@@ -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) {
+1 -1
View File
@@ -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
}
+209
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
+142
View File
@@ -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{})
}
+4 -3
View File
@@ -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)
+18 -3
View File
@@ -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, "---")
// 按次计费特殊处理
+84
View File
@@ -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{
+21 -4
View File
@@ -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)
+125 -76
View File
@@ -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)
+117 -12
View File
@@ -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)
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
lcommon "github.com/qianfree/team-api/internal/logic/common"
)
// TimeSegment 时段定价规则(mdl_pricing.time_segments JSONB 数组元素)。
// TimeSegment 时段定价规则(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。
// 语义:按数组顺序先命中先生效,可用来表达「促销时段压在常驻时段之上」;
// 未命中任何时段 → 乘数 1.0(默认价)。最终费用 = 各项小计 × 租户乘数 × 时段乘数。
type TimeSegment struct {
+62 -22
View File
@@ -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 重建而回滚未物化增量。
+118 -1
View File
@@ -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)
}
}
+93
View File
@@ -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))
}
}
+14 -3
View File
@@ -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,
+109
View File
@@ -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",
+14 -14
View File
@@ -25,25 +25,25 @@ import (
// RelayConverterMetrics 是单个转换器的累计指标快照(读取时计算派生字段)。
type RelayConverterMetrics struct {
ConverterID string `json:"converter_id"` // 如 "openai_to_claude"
From string `json:"from"` // 源协议格式
To string `json:"to"` // 目标协议格式
Success int64 `json:"success"` // 累计成功次数
Failed int64 `json:"failed"` // 累计失败次数
TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值
LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断)
ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed)
AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒)
ConverterID string `json:"converter_id"` // 如 "openai_to_claude"
From string `json:"from"` // 源协议格式
To string `json:"to"` // 目标协议格式
Success int64 `json:"success"` // 累计成功次数
Failed int64 `json:"failed"` // 累计失败次数
TotalMs int64 `json:"total_ms"` // 累计耗时(毫秒),用于算均值
LastError string `json:"last_error,omitempty"` // 最近一次错误信息(截断)
ErrorRate float64 `json:"error_rate"` // 派生:失败率 = failed/(success+failed)
AvgDurationMs float64 `json:"avg_duration_ms"` // 派生:平均耗时(毫秒)
}
// converterCounter 是单个转换器的并发安全计数器。
// from/to 在创建时写入后只读(无需加锁);热路径字段用 atomic;lastErr 失败稀疏,用自带互斥锁。
type converterCounter struct {
from string
to string
success atomic.Int64
failed atomic.Int64
totalMs atomic.Int64
from string
to string
success atomic.Int64
failed atomic.Int64
totalMs atomic.Int64
lastErrMu sync.Mutex
lastErr string
}
+1
View File
@@ -404,6 +404,7 @@ func buildUsageLogDO(record *common.UsageRecord) do.BilUsageLogs {
AudioOutputTokens: record.AudioOutputTokens,
ImageOutputTokens: record.ImageOutputTokens,
ReasoningTokens: record.ReasoningTokens,
DurationSeconds: record.DurationSeconds,
RequestedModel: sanitizeUTF8(record.RequestedModel),
UpstreamModel: sanitizeUTF8(record.UpstreamModel),
+86 -5
View File
@@ -329,9 +329,9 @@ type privateData struct {
UpstreamTaskID string `json:"upstream_task_id"`
TaskType string `json:"task_type"`
BillingContext struct {
Ratios map[string]float64 `json:"ratios"`
ModelName string `json:"model_name"`
PreDeduct float64 `json:"pre_deduct"`
Ratios map[string]any `json:"ratios"`
ModelName string `json:"model_name"`
PreDeduct float64 `json:"pre_deduct"`
} `json:"billing_context"`
}
@@ -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
}
+50
View File
@@ -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("租户过滤不匹配应拒绝")
}
}
+30 -2
View File
@@ -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 {
+36 -7
View File
@@ -122,7 +122,7 @@ type SyncImageJob struct {
Model string
RequestBody []byte
PreDeductAmount decimal.Decimal
Ratios map[string]float64
Ratios map[string]any
SubmitTime time.Time
}
@@ -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)
}
+72 -47
View File
@@ -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
+123 -118
View File
@@ -23,7 +23,7 @@ type pricingTierRow struct {
// memberModelScopeNoAccess 表示成员无权访问任何模型的哨兵值
const memberModelScopeNoAccess = -1
// tenantModelPriceRow 显式分配模型的价格查询结果
// tenantModelPriceRow 显式分配模型的价格查询结果(平台基础价统一从 pricing JSONB 解析)
type tenantModelPriceRow struct {
ModelDBID int64 `json:"model_db_id"`
ID int64 `json:"id"`
@@ -31,12 +31,7 @@ type tenantModelPriceRow struct {
PerRequestPrice *float64 `json:"per_request_price"`
DiscountRatio *float64 `json:"discount_ratio"`
MaxConcurrency *int `json:"max_concurrency"`
BaseInputPrice float64 `json:"base_input_price"`
BaseOutputPrice float64 `json:"base_output_price"`
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
BaseBillingMode string `json:"base_billing_mode"`
BasePerRequestPrice *float64 `json:"base_per_request_price"`
BaseDiscountLabel *string `json:"base_discount_label"`
BasePriceChangeNote *string `json:"base_price_change_note"`
CustomInputPrice *float64 `json:"custom_input_price"`
@@ -44,19 +39,16 @@ type tenantModelPriceRow struct {
CustomCacheReadPrice *float64 `json:"custom_cache_read_price"`
CustomCacheCreationPrice *float64 `json:"custom_cache_creation_price"`
CustomPricingTiers string `json:"custom_pricing_tiers"`
BasePricing string `json:"base_pricing"` // pricing JSONB(唯一真相)
}
// groupPriceRow 分组模型的 base 价格查询结果
type groupPriceRow struct {
ModelID int64 `json:"model_id"`
BaseBillingMode string `json:"base_billing_mode"`
BaseInputPrice float64 `json:"base_input_price"`
BaseOutputPrice float64 `json:"base_output_price"`
BaseCacheReadPrice float64 `json:"base_cache_read_price"`
BaseCacheCreationPrice float64 `json:"base_cache_creation_price"`
BasePerRequestPrice *float64 `json:"base_per_request_price"`
BaseDiscountLabel *string `json:"base_discount_label"`
BasePriceChangeNote *string `json:"base_price_change_note"`
ModelID int64 `json:"model_id"`
BaseBillingMode string `json:"base_billing_mode"`
BaseDiscountLabel *string `json:"base_discount_label"`
BasePriceChangeNote *string `json:"base_price_change_note"`
BasePricing string `json:"base_pricing"`
}
// baseTierRow 阶梯定价查询结果
@@ -74,7 +66,7 @@ type memberScopeRow struct {
ModelName string `json:"model_name"`
}
// priceInfo 显式模型的完整价格信息
// priceInfo 显式模型的完整价格信息(基础价字段从平台 pricing JSONB 解析而来)
type priceInfo struct {
ID int64
BillingMode *string
@@ -94,6 +86,15 @@ type priceInfo struct {
CustomCacheReadPrice *float64
CustomCacheCreationPrice *float64
CustomPricingTiers string
BaseBlob *billing.PricingBlob // 平台 pricing JSONB 解析结果(tiered 阶梯/时段/per_second 矩阵)
}
// BasePerSecondPrices per_second 矩阵(无 blob 时为 nil)
func (p *priceInfo) BasePerSecondPrices() map[string]float64 {
if p.BaseBlob == nil {
return nil
}
return p.BaseBlob.Prices
}
// groupPriceInfo 分组模型的价格信息
@@ -106,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)
+3
View File
@@ -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_ 记账层,币种 = 系统本位币
+1
View File
@@ -69,4 +69,5 @@ type BilUsageLogs struct {
BillingSnapshot any // 完整计费计算过程快照(JSONB)
ProjectId any // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId any // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
DurationSeconds any // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
}
+1
View File
@@ -23,4 +23,5 @@ type BilWallets struct {
UpdatedAt *gtime.Time // 更新时间
CumulativeRecharge any // 累计充值总额(USD)
LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false)
TotalConsumed any // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
}
+1
View File
@@ -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,空串=未分类
}
+11 -18
View File
@@ -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<start 表示跨零点
PriceNote any // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
g.Meta `orm:"table:mdl_pricing, do:true"`
Id any //
ModelId any // 关联模型ID
BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
PriceNote any // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel any // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote any // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Pricing any // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
OfficialPricing any // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
}
+1 -1
View File
@@ -14,7 +14,7 @@ type SysAdminRolePerms struct {
g.Meta `orm:"table:sys_admin_role_perms, do:true"`
Id any // 主键ID
AdminUserId any // 关联的管理员用户ID
PermissionPoint any // 权限点标识(如 tenant:create、channel:edit)
PermissionPoint any // 特批的权限点标识(独立于角色,角色禁用不影响特批)
CreatedAt *gtime.Time // 创建时间
UpdatedAt *gtime.Time // 更新时间
}
+2
View File
@@ -31,4 +31,6 @@ type TntTenants struct {
DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level any // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
Tags any // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
Remark any // 管理员备注(运营备注,不暴露给租户控制台)
}
+2
View File
@@ -37,4 +37,6 @@ type TskModelTasks struct {
CreatedAt *gtime.Time // 记录创建时间
UpdatedAt *gtime.Time // 记录更新时间
RequestId any // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
Deleted any // 软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)
DeletedAt *gtime.Time // 软删除时间
}
+1
View File
@@ -68,4 +68,5 @@ type BilUsageLogs struct {
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
DurationSeconds int `json:"duration_seconds" orm:"duration_seconds" description:"按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL"` // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
}
+11 -10
View File
@@ -11,14 +11,15 @@ import (
// BilWallets is the golang structure for table bil_wallets.
type BilWallets struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
Balance decimal.Decimal `json:"balance" orm:"balance" description:"总余额"` // 总余额
FrozenBalance decimal.Decimal `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
WarningThreshold *decimal.Decimal `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
CumulativeRecharge decimal.Decimal `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
Balance decimal.Decimal `json:"balance" orm:"balance" description:"总余额"` // 总余额
FrozenBalance decimal.Decimal `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
WarningThreshold *decimal.Decimal `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
CumulativeRecharge decimal.Decimal `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
TotalConsumed decimal.Decimal `json:"total_consumed" orm:"total_consumed" description:"累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)"` // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
}
+16 -15
View File
@@ -10,19 +10,20 @@ import (
// MdlModels is the golang structure for table mdl_models.
type MdlModels struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
ModelId string `json:"model_id" orm:"model_id" description:"模型唯一标识(如 gpt-4o、claude-3-5-sonnet)"` // 模型唯一标识(如 gpt-4o、claude-3-5-sonnet)
ModelName string `json:"model_name" orm:"model_name" description:"模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)"` // 模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)
Category string `json:"category" orm:"category" description:"模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)"` // 模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)
Status string `json:"status" orm:"status" description:"状态:active(可用)/ deprecated(已废弃)/ offline(已下线)"` // 状态:active(可用)/ deprecated(已废弃)/ offline(已下线)
MaxContextTokens int `json:"max_context_tokens" orm:"max_context_tokens" description:"最大上下文 token 数"` // 最大上下文 token 数
MaxOutputTokens int `json:"max_output_tokens" orm:"max_output_tokens" description:"最大输出 token 数"` // 最大输出 token 数
Description string `json:"description" orm:"description" description:"模型描述"` // 模型描述
Tags []string `json:"tags" orm:"tags" description:"标签(如 reasoning、vision、function_calling)"` // 标签(如 reasoning、vision、function_calling)
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
DeprecatedAt *gtime.Time `json:"deprecated_at" orm:"deprecated_at" description:"标记弃用的时间(NULL表示未弃用)"` // 标记弃用的时间(NULL表示未弃用)
SunsetDate *gtime.Time `json:"sunset_date" orm:"sunset_date" description:"计划下线日期(到达后返回410 Gone,NULL表示未设置)"` // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel string `json:"replacement_model" orm:"replacement_model" description:"推荐替代模型名(NULL表示无替代)"` // 推荐替代模型名(NULL表示无替代)
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
ModelId string `json:"model_id" orm:"model_id" description:"模型唯一标识(如 gpt-4o、claude-3-5-sonnet)"` // 模型唯一标识(如 gpt-4o、claude-3-5-sonnet)
ModelName string `json:"model_name" orm:"model_name" description:"模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)"` // 模型显示名称(如 GPT-4o、Claude 3.5 Sonnet)
Category string `json:"category" orm:"category" description:"模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)"` // 模型分类:chat(对话)/ embedding(嵌入)/ image(图像)/ audio(音频)/ rerank(重排序)
Status string `json:"status" orm:"status" description:"状态:active(可用)/ deprecated(已废弃)/ offline(已下线)"` // 状态:active(可用)/ deprecated(已废弃)/ offline(已下线)
MaxContextTokens int `json:"max_context_tokens" orm:"max_context_tokens" description:"最大上下文 token 数"` // 最大上下文 token 数
MaxOutputTokens int `json:"max_output_tokens" orm:"max_output_tokens" description:"最大输出 token 数"` // 最大输出 token 数
Description string `json:"description" orm:"description" description:"模型描述"` // 模型描述
Tags []string `json:"tags" orm:"tags" description:"标签(如 reasoning、vision、function_calling)"` // 标签(如 reasoning、vision、function_calling)
Capabilities string `json:"capabilities" orm:"capabilities" description:"模型能力特性(如 vision、function_calling、reasoning 等)"` // 模型能力特性(如 vision、function_calling、reasoning 等)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
DeprecatedAt *gtime.Time `json:"deprecated_at" orm:"deprecated_at" description:"标记弃用的时间(NULL表示未弃用)"` // 标记弃用的时间(NULL表示未弃用)
SunsetDate *gtime.Time `json:"sunset_date" orm:"sunset_date" description:"计划下线日期(到达后返回410 Gone,NULL表示未设置)"` // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel string `json:"replacement_model" orm:"replacement_model" description:"推荐替代模型名(NULL表示无替代)"` // 推荐替代模型名(NULL表示无替代)
Vendor string `json:"vendor" orm:"vendor" description:"研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类"` // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类
}
+10 -17
View File
@@ -6,25 +6,18 @@ package entity
import (
"github.com/gogf/gf/v2/os/gtime"
"github.com/shopspring/decimal"
)
// MdlPricing is the golang structure for table mdl_pricing.
type MdlPricing struct {
Id int64 `json:"id" orm:"id" description:""` //
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
CacheReadPrice decimal.Decimal `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice decimal.Decimal `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
TimeSegments string `json:"time_segments" orm:"time_segments" description:"时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{\"name\":\"闲时\",\"days\":[1,2,3,4,5],\"start_time\":\"00:00\",\"end_time\":\"08:00\",\"valid_from\":\"\",\"valid_to\":\"\",\"multiplier\":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点"` // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
PriceNote string `json:"price_note" orm:"price_note" description:"价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无"` // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Id int64 `json:"id" orm:"id" description:""` //
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
PriceNote string `json:"price_note" orm:"price_note" description:"价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无"` // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string `json:"discount_label" orm:"discount_label" description:"折扣标签(对外展示,如\"7折起\"、\"限时5折\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string `json:"price_change_note" orm:"price_change_note" description:"价格调整说明(对外展示,提示用户价格有变动,如\"9月1日起输入价下调\"),仅 min_tokens=0 锚点行使用,NULL/空=不展示"` // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Pricing string `json:"pricing" orm:"pricing" description:"计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:\"second\",prices:{分辨率规格:每秒单价,\"*\":兜底价}};顶层可含 time_segments(时段定价)"` // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
OfficialPricing string `json:"official_pricing" orm:"official_pricing" description:"官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)"` // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
}
@@ -10,9 +10,9 @@ import (
// SysAdminRolePerms is the golang structure for table sys_admin_role_perms.
type SysAdminRolePerms struct {
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
AdminUserId int64 `json:"admin_user_id" orm:"admin_user_id" description:"关联的管理员用户ID"` // 关联的管理员用户ID
PermissionPoint string `json:"permission_point" orm:"permission_point" description:"权限点标识(如 tenant:create、channel:edit)"` // 权限点标识(如 tenant:create、channel:edit)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
AdminUserId int64 `json:"admin_user_id" orm:"admin_user_id" description:"关联的管理员用户ID"` // 关联的管理员用户ID
PermissionPoint string `json:"permission_point" orm:"permission_point" description:"特批的权限点标识(独立于角色,角色禁用不影响特批)"` // 特批的权限点标识(独立于角色,角色禁用不影响特批)
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
}
+2
View File
@@ -29,4 +29,6 @@ type TntTenants struct {
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
Tags string `json:"tags" orm:"tags" description:"租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)"` // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
Remark string `json:"remark" orm:"remark" description:"管理员备注(运营备注,不暴露给租户控制台)"` // 管理员备注(运营备注,不暴露给租户控制台)
}
+2
View File
@@ -36,4 +36,6 @@ type TskModelTasks struct {
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"记录创建时间"` // 记录创建时间
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"记录更新时间"` // 记录更新时间
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id"` // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
Deleted bool `json:"deleted" orm:"deleted" description:"软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)"` // 软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)
DeletedAt *gtime.Time `json:"deleted_at" orm:"deleted_at" description:"软删除时间"` // 软删除时间
}
+4 -1
View File
@@ -24,6 +24,9 @@ type (
// UpdateUserStatus enables or disables an admin user.
UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (*v1.AdminUserUpdateStatusRes, error)
// UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。
//
// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解
// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。
UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error)
// ResetUserPassword resets an admin user's password.
ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error)
@@ -329,7 +332,7 @@ type (
UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error)
// DeleteModel 删除模型(同时删除定价记录、租户分配记录和分组关联)
DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.ModelDeleteRes, error)
// GetModelPricing 获取模型定价
// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容)
GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error)
// SetModelPricing 设置模型定价(全量替换)
SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error)
+10
View File
@@ -155,6 +155,10 @@ type (
// UpdateMemberRole updates a member's role.
UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpdateRoleReq) (*v1.TenantMemberUpdateRoleRes, error)
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
//
// 角色校验此前缺失:只挡了「不能重置自己」和「不能重置 owner」,任意 member 都能改掉
// 同组织 admin 的密码并登录该账号,等于组织内横向提权。与相邻的 UnlockMember /
// RemoveMember / UpdateMemberRole 保持同一道闸门。
ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error)
// UnlockMember 解除成员登录锁定。仅 owner/admin 可操作;租户隔离双键校验。
UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockReq) (*v1.TenantMemberUnlockRes, error)
@@ -168,6 +172,9 @@ type (
// 导出列与成员列表页表格保持一致(用户/角色/状态/额度限制/可用模型/本月消费/加入时间/最后更新)。
ExportMembers(ctx context.Context, req *v1.TenantMemberExportReq) (*v1.TenantMemberExportRes, error)
// MemberImport parses CSV content, validates, creates an import record.
//
// 角色校验此前缺失:批量导入会直接创建租户成员账号(并按套餐占用席位),
// 与 MemberCreate / MemberInvite 是同一类动作,必须同为 owner/admin。
MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error)
// ImportRecords returns a paginated list of import records.
ImportRecords(ctx context.Context, req *v1.TenantImportRecordsReq) (*v1.TenantImportRecordsRes, error)
@@ -277,6 +284,9 @@ type (
// ProjectArchive archives a project and revokes all its keys.
ProjectArchive(ctx context.Context, req *v1.TenantProjectArchiveReq) (*v1.TenantProjectArchiveRes, error)
// ProjectUnarchive restores an archived project. Keys are NOT auto-restored.
//
// 角色校验此前缺失:Create/Update/Archive 都要求 owner/admin,唯独恢复归档没拦,
// 任意 member 都能把管理员刚归档的项目恢复回来(归档是治理动作,撤销它同样是)。
ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error)
// ProjectGet 根据 ID 获取单个项目详情(含统计摘要)
ProjectGet(ctx context.Context, req *v1.TenantProjectGetReq) (*v1.TenantProjectGetRes, error)
+1
View File
@@ -23,6 +23,7 @@ const (
// 验证器 App 生成的 SHA-256 码在服务端永远校验失败。要安全切换到 SHA-256 必须先:
// 1. 在用户表持久化每条 secret 对应的算法;
// 2. ValidateCode 改用 totp.ValidateCustom 并按存储的算法校验。
//
// 这属于涉及 schema 与 2FA 安全路径的较大改动,超出 P3 细节优化范围,故暂保留 SHA-1。
func GenerateSecret(accountName string) (secret string, uri string, err error) {
key, err := totp.Generate(totp.GenerateOpts{
+272
View File
@@ -0,0 +1,272 @@
-- +goose Up
-- 定价存储 JSON 化 + 特殊计费 + 视频任务软删除 + 租户标签备注/钱包累计消费 + 模型厂商(由原 000021~000027 合并)。
-- 设计文档:docs/模型定价存储JSON化与按秒计费设计.md、docs/租户标签备注与累计消费-执行规划.md
--
-- mdl_pricing 一步收敛为「每模型一行 + pricing JSONB 唯一真相」:
-- * 旧价格列/多行档位回填进 JSON 后删除,model_id 加唯一约束;
-- * 新增 official_pricing 官方参考定价(管理端折扣计算的基准参照,非计费依据);
-- * billing_mode 值域新增 special(pricing JSONB 顶层 scheme 键声明计费方案,如 custom:minimax-material);
-- * 配套 bil_usage_logs.duration_seconds 落按秒计费任务时长,不塞 token 字段凑合。
-- 另含四笔独立变更:tsk_model_tasks 软删除、tnt_tenants 标签/备注、bil_wallets 累计消费、mdl_models 厂商字段。
-- ⚠️ 部署顺序强约束:必须先部署识别 special 的新代码,再执行本迁移——
-- 旧代码读到 special 会按未配价 fail-closed 拒绝该模型的同步路径请求,且旧代码依赖的标量价格列在本迁移中被删除。
-- ── 一、tsk_model_tasks:OpenAI Videos DELETE 端点软删除 ──
-- 官方语义为「永久删除已完成/失败视频及其资产」,网关侧保留计费与审计记录,
-- 采用软删除(deleted=true 后客户端查询/下载均按 404 处理)。
-- +goose StatementBegin
ALTER TABLE tsk_model_tasks
ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN deleted_at TIMESTAMPTZ NULL;
-- +goose StatementEnd
COMMENT ON COLUMN tsk_model_tasks.deleted IS '软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)';
COMMENT ON COLUMN tsk_model_tasks.deleted_at IS '软删除时间';
-- ── 二、mdl_pricing:收敛为每模型一行,pricing JSONB 唯一真相 ──
-- 保留列:model_id(加 UNIQUE)、billing_mode(可索引筛选)、pricing、official_pricing、
-- price_note / discount_label / price_change_note。
-- 1. 锚点行去重(历史脏数据:个别模型存在多条 min_tokens=0 行)。
-- 保留每组 id 最大的一条——与旧读取语义一致(GetModelPriceAt 全行扫描循环覆盖,后行胜出);
-- 去重先于回填,避免被删锚点行作为 min_tokens=0 档位混进聚合出的 tiers 数组、污染首档价格。
-- +goose StatementBegin
DELETE FROM mdl_pricing p
USING mdl_pricing p2
WHERE p.model_id = p2.model_id
AND p.min_tokens = 0 AND p2.min_tokens = 0
AND p.id < p2.id;
-- +goose StatementEnd
-- 2. 新增 pricing(唯一计费真相)与 official_pricing(官方参考定价)两列
-- +goose StatementBegin
ALTER TABLE mdl_pricing
ADD COLUMN IF NOT EXISTS pricing JSONB,
ADD COLUMN IF NOT EXISTS official_pricing JSONB;
-- +goose StatementEnd
COMMENT ON COLUMN mdl_pricing.pricing IS '计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)与 scheme(特殊计费方案)';
COMMENT ON COLUMN mdl_pricing.official_pricing IS '官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)';
-- 3. 存量回填:仅锚点行(min_tokens=0)写 pricing 列
-- token / per_request:锚点行标量列构造 JSON
-- tiered:全部档位行聚合成 tiers 数组(锚点行承载 cache 价)
-- time_segments:并入 pricing 顶层
-- +goose StatementBegin
WITH tiers AS (
SELECT model_id,
jsonb_agg(jsonb_build_object(
'min_tokens', min_tokens, 'max_tokens', max_tokens,
'input_price', input_price, 'output_price', output_price
) ORDER BY min_tokens) AS tier_arr
FROM mdl_pricing GROUP BY model_id
)
UPDATE mdl_pricing p SET pricing =
CASE p.billing_mode
WHEN 'per_request' THEN jsonb_build_object('price', COALESCE(p.per_request_price, 0))
WHEN 'tiered' THEN jsonb_build_object(
'tiers', t.tier_arr,
'cache_read_price', p.cache_read_price,
'cache_creation_price', p.cache_creation_price)
ELSE jsonb_build_object(
'input_price', p.input_price, 'output_price', p.output_price,
'cache_read_price', p.cache_read_price,
'cache_creation_price', p.cache_creation_price)
END
|| CASE WHEN p.time_segments IS NOT NULL
THEN jsonb_build_object('time_segments', p.time_segments) ELSE '{}'::jsonb END
FROM tiers t
WHERE t.model_id = p.model_id AND p.min_tokens = 0 AND p.pricing IS NULL;
-- +goose StatementEnd
-- 4. 删除阶梯档位行(档位已聚合进锚点行 pricing->'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
+2 -2
View File
@@ -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)
+4 -4
View File
@@ -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},
+18
View File
@@ -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 {
+44
View File
@@ -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}
+3
View File
@@ -170,6 +170,9 @@ type UsageRecord struct {
// 其他 token
ImageOutputTokens int // 图像输出 token(DALL-E)
// 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 0
DurationSeconds int
// 请求元数据
RequestedModel string // 用户请求的模型名
UpstreamModel string // 上游实际模型名
+26 -6
View File
@@ -15,12 +15,16 @@ type TaskAdaptor interface {
// ValidateRequest 校验请求参数
ValidateRequest(ctx context.Context, info *RelayInfo, body []byte) *TaskError
// EstimateBilling 估算任务费用(提交前)
// 返回计费比率 map,如 {"duration_ratio": 1.5, "resolution_ratio": 2.0}
EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]float64
// EstimateBilling 估算任务费用(提交前)。
// 返回计费上下文 map,两类键:
// - float64 乘数值:如 {"video_input": 0.6, "quality": 3.5},引擎按序连乘;
// - string 规格事实值:如 {"spec.duration": 8, "spec.resolution": "720p"},
// per_second 计费模式据此查定价矩阵(spec.duration 为 float64 秒数,spec.resolution 为规格原值)。
// 旧键 duration/resolution 保留 token 伪装估算路径使用,语义随各 adaptor 存量口径,勿混用。
EstimateBilling(ctx context.Context, info *RelayInfo, body []byte) map[string]any
// AdjustBillingOnSubmit 提交后根据上游确认参数调整计费
AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]float64
AdjustBillingOnSubmit(info *RelayInfo, taskData []byte) map[string]any
// BuildRequestURL 构建上游请求 URL
BuildRequestURL(info *RelayInfo) (string, error)
@@ -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
+16 -6
View File
@@ -8,10 +8,14 @@ import (
)
// TaskBillingProvider 异步任务计费接口
// 异步任务计费基于时长/分辨率等比率,非 token 数
// 异步任务计费基于时长/分辨率等比率,非 token 数。
// ratios 键约定见 TaskAdaptor.EstimateBilling(float64 乘数 + spec.* 规格事实值)
type TaskBillingProvider interface {
// EstimateTaskCost 估算任务费用
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error)
// EstimateTaskCost 估算任务费用。
// taskBody 为归一化后的任务体(转换后通用格式):定价配置了参数倍率(param_multipliers)时,
// 实现方在函数内求值并把命中倍率就地注入 ratios(param_multiplier / param_matched 键,
// 随任务持久化保证预扣/结算/重算三口径同源);nil body = 不求值。
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error)
// PreDeductTask 预扣任务费用
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error)
@@ -30,7 +34,7 @@ type TaskBillingProvider interface {
// SettleTaskSuccess 任务成功结算(含计费快照)。billAt 为任务受理时刻(时段定价按该时刻评估,
// 异步任务结算滞后数分钟~小时,按结算时刻会丢失受理时的时段价)
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string, billAt time.Time) (*SettlementResult, error)
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*SettlementResult, error)
// SettleTaskFailed 任务失败退还预扣
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error
@@ -43,6 +47,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)
}
+8
View File
@@ -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 渠道基本信息(轮询时查询)
+8
View File
@@ -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:
+3
View File
@@ -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
}
+329
View File
@@ -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)
}
+356
View File
@@ -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,
}
}
+147
View File
@@ -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)
}
}
@@ -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[]")
}
}
+427
View File
@@ -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())
}
}
+2
View File
@@ -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 ""
}
+90 -11
View File
@@ -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 {
+305
View File
@@ -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)
}
}
+269
View File
@@ -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,
}
}
+225
View File
@@ -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)
}
}
+334
View File
@@ -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())
}
}
+336
View File
@@ -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())
}
}
+10 -8
View File
@@ -140,25 +140,27 @@ func (a *AliAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, bod
return nil
}
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
ratios := map[string]float64{"base": 1.0}
func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
ratios := map[string]any{"base": 1.0}
var req map[string]any
if json.Unmarshal(body, &req) != nil {
return ratios
}
if a.isVideo {
// duration 影响计费
// duration 影响计费:spec.duration 为真实秒数(per_second 用),duration 为存量信号
metadata := taskchannel.ExtractMetadata(req)
var meta struct {
Duration *int `json:"duration"`
}
if taskchannel.UnmarshalMetadata(metadata, &meta) == nil && meta.Duration != nil && *meta.Duration > 0 {
ratios["duration"] = float64(*meta.Duration)
ratios["spec.duration"] = float64(*meta.Duration)
}
if seconds, ok := req["seconds"].(string); ok {
if d, err := parseInt(seconds); err == nil && d > 0 {
ratios["duration"] = float64(d)
ratios["spec.duration"] = float64(d)
}
}
} else {
@@ -171,7 +173,7 @@ func (a *AliAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, bod
return ratios
}
func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
func (a *AliAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
return nil
}
@@ -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
}
+15 -7
View File
@@ -42,12 +42,20 @@ func (a *GeminiAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo,
return nil
}
func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
ratios := map[string]float64{"base": 1.0}
func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
ratios := map[string]any{"base": 1.0}
var req openAIVideoRequest
if json.Unmarshal(body, &req) == nil {
resolution := parseResolution(req.Metadata.Resolution)
duration := req.Metadata.Duration
// per_second 事实键:真实秒数 + 规格原值(矩阵查价键)
if duration > 0 {
ratios["spec.duration"] = float64(duration)
}
if resolution != "" {
ratios["spec.resolution"] = resolution
}
// 存量 token 伪装路径乘数(语义为乘数非秒数,保持原行为)
if resolution == "1080p" {
ratios["resolution"] = 1.5
} else if resolution == "4k" {
@@ -60,7 +68,7 @@ func (a *GeminiAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo,
return ratios
}
func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
func (a *GeminiAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
return nil
}
@@ -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
}
+11 -9
View File
@@ -116,8 +116,8 @@ func (a *KlingAdaptor) ValidateRequest(_ context.Context, _ *common.RelayInfo, b
return nil
}
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]float64 {
ratios := map[string]float64{"base": 1.0}
func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, body []byte) map[string]any {
ratios := map[string]any{"base": 1.0}
var req map[string]any
if json.Unmarshal(body, &req) != nil {
return ratios
@@ -145,7 +145,7 @@ func (a *KlingAdaptor) EstimateBilling(_ context.Context, _ *common.RelayInfo, b
return ratios
}
func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]float64 {
func (a *KlingAdaptor) AdjustBillingOnSubmit(_ *common.RelayInfo, _ []byte) map[string]any {
return nil
}
@@ -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)
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more