feat(model):模型定价分时功能
This commit is contained in:
1 parent
330d02f19d
commit
add95ca5ab
41 files changed
+2481
-817
No files matched your search
@@ -263,7 +263,9 @@ cmd(路由注册)
|
||||
|
||||
价格查询优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。
|
||||
|
||||
最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。
|
||||
最终价格公式:`最终价格 = 基础价格 × 模型乘数 × 租户乘数 × 时段乘数`。模型乘数根据模型稀有度/成本动态调整;租户乘数根据租户等级/用量等级设置(VIP 折扣等)。双层乘数替代单纯的"租户独立定价",提供更灵活的定价能力。
|
||||
|
||||
**时段定价**(`mdl_pricing.time_segments` JSONB,挂在 min_tokens=0 锚点行):命名时段有序数组(名称/适用日/每日时间窗/有效期/乘数),**从上到下先命中先生效,未命中=默认价 1.0**;乘数作用于全部价格(各阶梯档/缓存价/按次价),不支持每时段独立阶梯结构。**定价时刻 = 请求(任务)受理时刻**——结算/异步任务路径必须传受理时刻(`GetModelPriceAt`),预扣时刻≈受理时刻可用 `GetModelPrice`;时段按平台时区(`sys_options.pricing_time_timezone`,默认 Asia/Shanghai)解释。时段配置随 `PricingResult` 进 600s 定价缓存,缓存命中后按 billAt 重评估乘数(`reapplyTimeMultiplier`),TimeMultiplier 零值兜底为 1.0。
|
||||
|
||||
### 钱包状态:Redis 权威 + DB 物化(强约束)
|
||||
|
||||
|
||||
+47
-30
@@ -106,6 +106,19 @@ type PricingItem struct {
|
||||
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
|
||||
}
|
||||
|
||||
// TimeSegmentItem 时段定价项(mdl_pricing.time_segments JSONB 数组元素)。
|
||||
// 语义:按数组顺序先命中先生效(促销时段放前面覆盖常驻时段),未命中=默认价(乘数 1.0);
|
||||
// 最终费用 = 各项小计 × 租户乘数 × 时段乘数。
|
||||
type TimeSegmentItem struct {
|
||||
Name string `json:"name" v:"required" dc:"时段名(写入计费快照)"`
|
||||
Days []int `json:"days" dc:"适用星期 1=周一..7=周日;空=每天"`
|
||||
StartTime string `json:"start_time" dc:"每日开始时刻 HH:MM;与 end_time 均空=全天"`
|
||||
EndTime string `json:"end_time" dc:"每日结束时刻 HH:MM;end<start 表示跨零点(如 22:00~06:00)"`
|
||||
ValidFrom string `json:"valid_from" dc:"生效起始日期 YYYY-MM-DD(含端点);空=长期,促销/定时调价用"`
|
||||
ValidTo string `json:"valid_to" dc:"生效结束日期 YYYY-MM-DD(含端点)"`
|
||||
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:10" dc:"价格乘数,0.5=半价"`
|
||||
}
|
||||
|
||||
// PricingGetReq 获取模型定价
|
||||
type PricingGetReq struct {
|
||||
g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"`
|
||||
@@ -113,14 +126,16 @@ type PricingGetReq struct {
|
||||
}
|
||||
|
||||
type PricingGetRes struct {
|
||||
List []PricingItem `json:"list"`
|
||||
List []PricingItem `json:"list"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(锚点行 time_segments)"`
|
||||
}
|
||||
|
||||
// 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:"定价列表"`
|
||||
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:"时段定价(可选,全量替换;空数组清除时段配置)"`
|
||||
}
|
||||
|
||||
type PricingSetRes struct{}
|
||||
@@ -232,19 +247,20 @@ type ModelImportPreviewRes struct {
|
||||
|
||||
// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)
|
||||
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"`
|
||||
Conflict string `json:"conflict"` // "" 或 "exists"
|
||||
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"
|
||||
}
|
||||
|
||||
// ModelImportReq 确认导入模型请求
|
||||
@@ -255,19 +271,20 @@ type ModelImportReq struct {
|
||||
|
||||
// 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:"定价列表"`
|
||||
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
|
||||
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
|
||||
ModelName string `json:"model_name" dc:"模型显示名称"`
|
||||
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
|
||||
Status string `json:"status" dc:"状态"`
|
||||
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
|
||||
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
|
||||
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
|
||||
Description string `json:"description" dc:"模型描述"`
|
||||
Tags []string `json:"tags" dc:"标签列表"`
|
||||
SunsetDate string `json:"sunset_date" dc:"下线日期"`
|
||||
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
|
||||
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
|
||||
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
|
||||
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
|
||||
}
|
||||
|
||||
// ModelImportRes 导入结果响应
|
||||
|
||||
@@ -23,6 +23,21 @@ type PricingTierItem struct {
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
}
|
||||
|
||||
// TimePriceItem 时段价目项(后端按「租户有效价 × 时段乘数」换算好的展示价,前端直接渲染)。
|
||||
// tiered 模型 InputPrice/OutputPrice 为首档换算价(起价);per_request 模型填 PerRequestPrice。
|
||||
type TimePriceItem struct {
|
||||
Name string `json:"name"` // 时段名
|
||||
Days []int `json:"days,omitempty"` // 适用星期 1=周一..7=周日;空=每天
|
||||
StartTime string `json:"start_time,omitempty"` // 空=全天
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
ValidFrom string `json:"valid_from,omitempty"` // 促销/定时调价窗口(空=长期)
|
||||
ValidTo string `json:"valid_to,omitempty"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// TenantAvailableModelItem 租户可用模型信息
|
||||
type TenantAvailableModelItem struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -43,6 +58,8 @@ type TenantAvailableModelItem struct {
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice *float64 `json:"cache_creation_price"`
|
||||
PricingTiers []PricingTierItem `json:"pricing_tiers"`
|
||||
// TimePrices 时段价目(平台配置了时段定价的模型才有;价格为换算后的展示价)
|
||||
TimePrices []TimePriceItem `json:"time_prices"`
|
||||
// AsyncImage 图片模型异步端点是否可用(提交 /v1/images/generations/async 后轮询取图)。
|
||||
// 仅图片分类模型有意义;真异步厂商,或同步厂商且「同步图片异步化」开关开启时为 true。
|
||||
AsyncImage bool `json:"async_image"`
|
||||
|
||||
@@ -29,7 +29,7 @@ type BilUsageDailyColumns struct {
|
||||
ChannelId string // 渠道ID(0=无渠道)
|
||||
Status string // 请求状态:success/error/timeout/cancelled
|
||||
RequestCount string // 请求数(COUNT(*))
|
||||
InputTokens string // 输入Token合计
|
||||
InputTokens string // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
|
||||
OutputTokens string // 输出Token合计
|
||||
TotalCost string // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
|
||||
AccountCost string // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
|
||||
@@ -37,7 +37,7 @@ type BilUsageDailyColumns struct {
|
||||
SumFirstTokenMs string // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
|
||||
CreatedAt string //
|
||||
UpdatedAt string //
|
||||
CacheCreationTokens string // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
|
||||
CacheCreationTokens string // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
|
||||
CacheReadTokens string // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
|
||||
CacheHitRequestCount string // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type BilUsageLogsColumns struct {
|
||||
ModelName string // 调用的模型名
|
||||
RequestId string // 请求唯一ID
|
||||
RelayMode string // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens string // 输入 token 数
|
||||
InputTokens string // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
|
||||
OutputTokens string // 输出 token 数
|
||||
TotalCost string // 本次调用费用
|
||||
Currency string // 货币(USD)
|
||||
@@ -39,7 +39,7 @@ type BilUsageLogsColumns struct {
|
||||
ClientIp string // 客户端 IP
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
CacheCreationTokens string // 缓存创建 token 数 (Claude)
|
||||
CacheCreationTokens string // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
|
||||
CacheReadTokens string // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost string // 输入 token 费用
|
||||
OutputCost string // 输出 token 费用
|
||||
|
||||
@@ -33,6 +33,7 @@ type MdlPricingColumns struct {
|
||||
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 表示跨零点
|
||||
}
|
||||
|
||||
// mdlPricingColumns holds the columns for the table mdl_pricing.
|
||||
@@ -49,6 +50,7 @@ var mdlPricingColumns = MdlPricingColumns{
|
||||
UpdatedAt: "updated_at",
|
||||
CacheReadPrice: "cache_read_price",
|
||||
CacheCreationPrice: "cache_creation_price",
|
||||
TimeSegments: "time_segments",
|
||||
}
|
||||
|
||||
// NewMdlPricingDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -451,6 +451,7 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
CacheCreationPrice float64 `json:"cache_creation_price"`
|
||||
TimeSegments string `json:"time_segments"`
|
||||
}
|
||||
|
||||
err := dao.MdlPricing.Ctx(ctx).
|
||||
@@ -462,6 +463,7 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
|
||||
}
|
||||
|
||||
result := make([]v1.PricingItem, 0, len(rows))
|
||||
var timeSegments []billing.TimeSegment
|
||||
for _, r := range rows {
|
||||
result = append(result, v1.PricingItem{
|
||||
BillingMode: r.BillingMode,
|
||||
@@ -473,12 +475,109 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
CacheCreationPrice: r.CacheCreationPrice,
|
||||
})
|
||||
// 时段定价只认锚点行(min_tokens=0),阶梯行上的残留忽略
|
||||
if r.MinTokens == 0 && 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return &v1.PricingGetRes{List: result}, nil
|
||||
return &v1.PricingGetRes{List: result, TimeSegments: timeSegmentsToAPI(timeSegments)}, nil
|
||||
}
|
||||
|
||||
// timeSegmentsToAPI billing.TimeSegment → API 时段项
|
||||
func timeSegmentsToAPI(segments []billing.TimeSegment) []v1.TimeSegmentItem {
|
||||
if len(segments) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]v1.TimeSegmentItem, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
result = append(result, v1.TimeSegmentItem{
|
||||
Name: seg.Name,
|
||||
Days: seg.Days,
|
||||
StartTime: seg.StartTime,
|
||||
EndTime: seg.EndTime,
|
||||
ValidFrom: seg.ValidFrom,
|
||||
ValidTo: seg.ValidTo,
|
||||
Multiplier: seg.Multiplier,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// timeSegmentsFromAPI API 时段项 → billing.TimeSegment(含校验)
|
||||
func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
result := make([]billing.TimeSegment, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, billing.TimeSegment{
|
||||
Name: item.Name,
|
||||
Days: item.Days,
|
||||
StartTime: item.StartTime,
|
||||
EndTime: item.EndTime,
|
||||
ValidFrom: item.ValidFrom,
|
||||
ValidTo: item.ValidTo,
|
||||
Multiplier: item.Multiplier,
|
||||
})
|
||||
}
|
||||
if err := billing.ValidateTimeSegments(result); err != nil {
|
||||
return nil, 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 传播。
|
||||
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)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
segmentsJSON = string(data)
|
||||
}
|
||||
_, err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", modelDBID).
|
||||
Where("min_tokens", 0).
|
||||
Data(g.Map{"time_segments": segmentsJSON}).
|
||||
Update()
|
||||
return err
|
||||
}
|
||||
|
||||
// SetModelPricing 设置模型定价(全量替换)
|
||||
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
|
||||
// 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误
|
||||
segments, err := timeSegmentsFromAPI(req.TimeSegments)
|
||||
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
|
||||
}
|
||||
}
|
||||
if !hasAnchor {
|
||||
return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行")
|
||||
}
|
||||
}
|
||||
|
||||
// 先查模型编码,供事务提交后按模型清除所有租户的价格缓存
|
||||
var model struct {
|
||||
ModelId string `json:"model_id"`
|
||||
@@ -491,7 +590,7 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
|
||||
|
||||
// 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败,
|
||||
// 该模型会残留「无定价」状态,导致计费失败或回退到默认价。
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -519,6 +618,11 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
|
||||
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -61,18 +61,19 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。
|
||||
@@ -90,6 +91,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
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"`
|
||||
}
|
||||
var allPricingRows []pricingRow
|
||||
if len(modelIDs) > 0 {
|
||||
@@ -97,8 +99,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 按 model_id 分组,供后续组装
|
||||
// 按 model_id 分组,供后续组装;时段定价只认锚点行(min_tokens=0)
|
||||
pricingByModel := make(map[int64][]exportPricing, len(allPricingRows))
|
||||
segmentsByModel := make(map[int64][]v1.TimeSegmentItem, len(allPricingRows))
|
||||
for _, p := range allPricingRows {
|
||||
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
|
||||
BillingMode: p.BillingMode,
|
||||
@@ -110,6 +113,12 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]exportModel, 0, len(models))
|
||||
@@ -125,6 +134,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
Capabilities: parseCapabilities(m.Capabilities),
|
||||
ReplacementModel: m.ReplacementModel,
|
||||
Pricing: pricingByModel[m.ID],
|
||||
TimeSegments: segmentsByModel[m.ID],
|
||||
}
|
||||
if em.Pricing == nil {
|
||||
em.Pricing = []exportPricing{}
|
||||
@@ -298,6 +308,11 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除)
|
||||
if err := writeTimeSegmentsForModel(ctx, existing.ID, item.TimeSegments); err != nil {
|
||||
return gerror.Wrapf(err, "模型 %s 时段定价", item.ModelId)
|
||||
}
|
||||
|
||||
relay.NewDataProvider().InvalidateModelCache(item.ModelId)
|
||||
billing.ClearModelPriceCache(ctx, item.ModelId)
|
||||
res.Imported++
|
||||
@@ -351,6 +366,11 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价透传(挂锚点行)
|
||||
if err := writeTimeSegmentsForModel(ctx, id, item.TimeSegments); err != nil {
|
||||
return gerror.Wrapf(err, "模型 %s 时段定价", item.ModelId)
|
||||
}
|
||||
|
||||
res.Imported++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build e2e
|
||||
|
||||
// 手动 E2E(需真实 DB/Redis;默认 go test 不编译本文件)。
|
||||
// 跑法:go test -tags e2e -run TestE2ETimeSegmentPricing ./internal/logic/billing/
|
||||
// 使用独立的临时模型行 + 不存在的租户 ID,走纯基础定价路径,结束即清理,不影响现有数据。
|
||||
|
||||
package billing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "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 TestE2ETimeSegmentPricing(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-time-segment"
|
||||
const tmpTenant = int64(999999999) // 不存在的租户:走纯基础定价路径
|
||||
|
||||
// 清理(含上次失败运行的残留)
|
||||
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()
|
||||
|
||||
// 临时模型 + 定价(时段窗口覆盖 now±1h,乘数 0.5)
|
||||
modelID, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(do.MdlModels{
|
||||
ModelId: tmpModel,
|
||||
ModelName: "E2E时段定价临时模型",
|
||||
Category: "chat",
|
||||
Status: "active",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("insert tmp model: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
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)
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: modelID,
|
||||
BillingMode: "token",
|
||||
MinTokens: 0,
|
||||
InputPrice: NewFromFloat(3),
|
||||
OutputPrice: NewFromFloat(15),
|
||||
TimeSegments: segJSON,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert tmp pricing: %v", err)
|
||||
}
|
||||
|
||||
// 1. 首次解析(DB 路径):命中时段
|
||||
p1, err := GetModelPrice(ctx, tmpTenant, tmpModel)
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelPrice: %v", err)
|
||||
}
|
||||
assertFloat(t, p1.TimeMultiplier, 0.5, "e2e TimeMultiplier (db path)")
|
||||
if p1.TimeRuleName != "闲时" {
|
||||
t.Fatalf("TimeRuleName = %q, want 闲时", p1.TimeRuleName)
|
||||
}
|
||||
assertFloat(t, p1.InputPrice, 3.0, "e2e InputPrice")
|
||||
|
||||
// 2. 费用计算:1M 输入 + 1M 输出 = 18 × 0.5 = 9
|
||||
bd, err := CalculateCost(ctx, tmpTenant, tmpModel, 1_000_000, 1_000_000)
|
||||
if err != nil {
|
||||
t.Fatalf("CalculateCost: %v", err)
|
||||
}
|
||||
assertFloat(t, bd.TotalCost, 9.0, "e2e TotalCost (18 × 0.5)")
|
||||
|
||||
// 3. 缓存命中路径 + 窗口外 billAt:重评估为 1.0(受理时刻语义)
|
||||
p2, err := GetModelPriceAt(ctx, tmpTenant, tmpModel, now.Add(-3*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelPriceAt(out of window): %v", err)
|
||||
}
|
||||
assertFloat(t, p2.TimeMultiplier, 1.0, "e2e TimeMultiplier (cache hit, out-of-window billAt)")
|
||||
if p2.TimeRuleName != "" {
|
||||
t.Fatalf("out-of-window TimeRuleName = %q, want empty", p2.TimeRuleName)
|
||||
}
|
||||
|
||||
// 4. 缓存命中路径 + 窗口内 billAt:重评估回 0.5
|
||||
p3, err := GetModelPriceAt(ctx, tmpTenant, tmpModel, now.Add(10*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("GetModelPriceAt(in window): %v", err)
|
||||
}
|
||||
assertFloat(t, p3.TimeMultiplier, 0.5, "e2e TimeMultiplier (cache hit, in-window billAt)")
|
||||
|
||||
t.Logf("E2E 通过:DB 解析/费用计算/缓存重评估均符合预期(窗口 %s~%s)", start, end)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
@@ -56,7 +57,15 @@ type PricingResult struct {
|
||||
MaxOutputTokens int
|
||||
|
||||
// 租户自定义阶梯定价(JSONB 解析后的原始数据,供 CalculateCost 使用)
|
||||
CustomTiers []pricingTierRow
|
||||
// 租户未自定义时回落为平台阶梯(mdl_pricing 的 min_tokens 档位行)
|
||||
CustomTiers []pricingTierRow `json:"CustomTiers"`
|
||||
|
||||
// 时段定价配置(mdl_pricing 锚点行 time_segments,随定价缓存存储)
|
||||
TimeSegments []TimeSegment `json:"time_segments,omitempty"`
|
||||
// 时段乘数(读取时按定价时刻评估;缓存条目中的值是写入时刻的结果,仅作参考)
|
||||
TimeMultiplier float64 `json:"time_multiplier"`
|
||||
// 命中的时段名(写入计费快照,供账单解释;未命中为空)
|
||||
TimeRuleName string `json:"time_rule_name"`
|
||||
}
|
||||
|
||||
// ClearTenantPriceCache 清除租户的所有模型价格缓存
|
||||
@@ -85,13 +94,27 @@ func ClearModelPriceCache(ctx context.Context, modelName string) {
|
||||
modelPriceCache.DeleteByPattern(ctx, fmt.Sprintf("*:%s", modelName))
|
||||
}
|
||||
|
||||
// GetModelPrice 获取模型价格
|
||||
// 优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认
|
||||
// GetModelPrice 获取模型价格(时段乘数按当前时刻评估)。
|
||||
// 预扣等「调用时刻≈受理时刻」的路径使用本函数。
|
||||
func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*PricingResult, error) {
|
||||
return GetModelPriceAt(ctx, tenantID, modelName, time.Time{})
|
||||
}
|
||||
|
||||
// GetModelPriceAt 获取模型价格,时段乘数按 billAt(定价时刻)评估。
|
||||
// 优先级:租户独立价 > 套餐价 > 模型基础价 > 硬编码默认。
|
||||
// 定价时刻语义:结算/异步任务路径必须传「请求(任务)受理时刻」,保证 07:59 发出的请求
|
||||
// 即使 08:05 结算也按 07:59 的时段价计费,预扣与结算口径一致;billAt 零值按当前时刻。
|
||||
func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, billAt time.Time) (*PricingResult, error) {
|
||||
if billAt.IsZero() {
|
||||
billAt = time.Now()
|
||||
}
|
||||
cacheKey := fmt.Sprintf("%d:%s", tenantID, modelName)
|
||||
var cached PricingResult
|
||||
if modelPriceCache.GetJSON(ctx, cacheKey, &cached) {
|
||||
return &cached, nil
|
||||
// 缓存条目中的时段乘数是写入时刻的评估结果,必须按 billAt 重评估
|
||||
// (GetJSON 的 target 为调用方独有副本,L1 回填存的也是反序列化副本,
|
||||
// 就地改写不会波及缓存内对象、无数据竞争)
|
||||
return reapplyTimeMultiplier(ctx, &cached, billAt), nil
|
||||
}
|
||||
|
||||
// 1. 查模型基础信息(max_output_tokens 一并取出,随定价缓存供预扣估算复用)
|
||||
@@ -115,25 +138,45 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
|
||||
return nil, gerror.Newf("model not found: %s", modelName)
|
||||
}
|
||||
|
||||
// 2. 从 mdl_pricing 获取基础定价
|
||||
// 2. 从 mdl_pricing 获取定价(全行读取:锚点行 min_tokens=0 是默认价与时段配置载体,
|
||||
// 其余行是平台阶梯档位。此前只读锚点行导致平台阶梯不参与计费,退化为第一档平价)
|
||||
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"`
|
||||
}
|
||||
|
||||
var pricing *pricingRow
|
||||
var pricingRows []pricingRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", model.ID).
|
||||
Where("min_tokens", 0).
|
||||
Scan(&pricing)
|
||||
OrderAsc("min_tokens").
|
||||
Scan(&pricingRows)
|
||||
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
|
||||
@@ -229,6 +272,21 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 平台阶梯兜底:租户未自定义阶梯时使用平台阶梯(mdl_pricing 档位行)。
|
||||
// 计费模式非 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)
|
||||
@@ -271,12 +329,36 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
|
||||
CacheCreation1hPrice: cacheCreationPrice * 1.6, // 1h TTL = 1.6× 基础价(因为 2.0÷1.25=1.6,对应官方 2× 输入价)
|
||||
MaxOutputTokens: model.MaxOutputTokens,
|
||||
CustomTiers: customTiers,
|
||||
TimeSegments: timeSegments,
|
||||
}
|
||||
|
||||
// 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考)
|
||||
if len(timeSegments) > 0 {
|
||||
loc := pricingTimeLocation(ctx)
|
||||
result.TimeMultiplier, result.TimeRuleName = resolveTimeMultiplier(timeSegments, billAt, loc)
|
||||
} else {
|
||||
result.TimeMultiplier = 1.0
|
||||
}
|
||||
|
||||
modelPriceCache.Set(ctx, cacheKey, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// reapplyTimeMultiplier 缓存命中路径:按 billAt 重评估时段乘数后就地改写并返回。
|
||||
// 入参必须是本调用新建/反序列化的独有副本,不得传入共享指针:
|
||||
// GetJSON 保证 target 归调用方所有(L1 回填存反序列化副本),
|
||||
// DB 加载路径的 result 在 Set 之后不再改写。
|
||||
func reapplyTimeMultiplier(ctx context.Context, p *PricingResult, billAt time.Time) *PricingResult {
|
||||
if len(p.TimeSegments) == 0 {
|
||||
p.TimeMultiplier = 1.0
|
||||
p.TimeRuleName = ""
|
||||
return p
|
||||
}
|
||||
loc := pricingTimeLocation(ctx)
|
||||
p.TimeMultiplier, p.TimeRuleName = resolveTimeMultiplier(p.TimeSegments, billAt, loc)
|
||||
return p
|
||||
}
|
||||
|
||||
// CalculateCost 计算实际费用(含阶梯定价)
|
||||
// inputTokens / outputTokens 为实际使用的 token 数
|
||||
func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputTokens, outputTokens int) (*CostBreakdown, error) {
|
||||
@@ -293,11 +375,13 @@ func CalculateCost(ctx context.Context, tenantID int64, modelName string, inputT
|
||||
func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *rcommon.Usage) *CostBreakdown {
|
||||
baseInputTokens, outputTokens, cacheReadTokens, cacheCreation5mTokens, cacheCreation1hTokens := resolveTokenCounts(pricing, inputTokens, outputTokens, usage)
|
||||
|
||||
// 按次计费:直接返回单价
|
||||
// 按次计费:单价 × 租户乘数 × 时段乘数。
|
||||
// 此前未乘租户乘数(按次模型对租户折扣免疫,疑似遗漏),本次与 token/tiered 口径对齐
|
||||
if pricing.BillingMode == "per_request" {
|
||||
mulD := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
return &CostBreakdown{
|
||||
BaseCost: pricing.PerRequestPrice,
|
||||
TotalCost: pricing.PerRequestPrice,
|
||||
TotalCost: InexactFloat64(RoundMoney(NewFromFloat(pricing.PerRequestPrice).Mul(mulD))),
|
||||
InputTokens: baseInputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
BillingMode: pricing.BillingMode,
|
||||
@@ -316,10 +400,10 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
|
||||
// 输出费用(已改为 decimal)
|
||||
outputCostD := computeOutputCost(pricing, outputTokens)
|
||||
|
||||
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 + 各项求和)改用 decimal 精确运算,
|
||||
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 × 时段乘数 + 各项求和)改用 decimal 精确运算,
|
||||
// 最终四舍五入到 10 位(NUMERIC(20,10))再返回 float64,消除 float64 累计误差。
|
||||
million := decimal.NewFromInt(1_000_000)
|
||||
mul := NewFromFloat(pricing.TenantMultiplier)
|
||||
mul := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
|
||||
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(NewFromFloat(pricing.CacheReadPrice))
|
||||
|
||||
@@ -457,9 +541,10 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 按次计费:直接用单价
|
||||
// 按次计费:单价 × 租户乘数 × 时段乘数(与 computeCost 结算口径一致)
|
||||
if pricing.BillingMode == "per_request" {
|
||||
return pricing.PerRequestPrice, nil
|
||||
mult := NewFromFloat(pricing.TenantMultiplier).Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
return InexactFloat64(NewFromFloat(pricing.PerRequestPrice).Mul(mult)), nil
|
||||
}
|
||||
|
||||
// Token 计费:估算输出上限。
|
||||
@@ -496,7 +581,7 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
|
||||
|
||||
// validatePricingConfigured 校验模型定价有效性(fail-closed)。
|
||||
// 未配置定价的模型不得放行:零价会让预扣/结算全部为 0,等同免费使用。
|
||||
// 允许只配 OutputPrice(输入免费)或只配租户自定义阶梯(CustomTiers)的合法场景。
|
||||
// 允许只配 OutputPrice(输入免费)或只配含正价的自定义/平台阶梯(CustomTiers)的合法场景。
|
||||
func validatePricingConfigured(pricing *PricingResult, modelName string) error {
|
||||
switch pricing.BillingMode {
|
||||
case "per_request":
|
||||
@@ -504,13 +589,24 @@ func validatePricingConfigured(pricing *PricingResult, modelName string) error {
|
||||
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s (per_request price not set)", modelName)
|
||||
}
|
||||
default: // token / tiered
|
||||
if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && len(pricing.CustomTiers) == 0 {
|
||||
if pricing.InputPrice <= 0 && pricing.OutputPrice <= 0 && !tiersHavePrice(pricing.CustomTiers) {
|
||||
return gerror.Wrapf(rcommon.ErrModelPricingNotConfigured, "model=%s", modelName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。
|
||||
// 平台阶梯兜底后 CustomTiers 可能恒非空,全 0 价阶梯不得视为「已配置定价」放行
|
||||
func tiersHavePrice(tiers []pricingTierRow) bool {
|
||||
for _, tier := range tiers {
|
||||
if tier.InputPrice > 0 || tier.OutputPrice > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pricingTierRow 定价阶梯行(绝对价格)
|
||||
type pricingTierRow struct {
|
||||
MinTokens int64 `json:"min_tokens"`
|
||||
@@ -519,41 +615,18 @@ type pricingTierRow struct {
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
}
|
||||
|
||||
// calculateTieredCostFromPricing 从 mdl_pricing 读取阶梯绝对价格计算费用
|
||||
func calculateTieredCostFromPricing(ctx context.Context, modelName string, tokens int, isInput bool) (float64, float64) {
|
||||
if tokens <= 0 {
|
||||
return 0, 1.0
|
||||
// effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。
|
||||
// 兜底两类场景:升级部署后 Redis L2 缓存中的旧定价条目(无该字段)、手工构造的零值 PricingResult。
|
||||
// 计费系统 fail-safe 原则:时段字段缺失不得把费用清零。
|
||||
func effectiveTimeMultiplier(p *PricingResult) float64 {
|
||||
if p.TimeMultiplier <= 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// 查模型ID
|
||||
type modelIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
var mid modelIDRow
|
||||
err := dao.MdlModels.Ctx(ctx).
|
||||
Where("model_id", modelName).
|
||||
Fields("id").
|
||||
Scan(&mid)
|
||||
if err != nil || mid.ID == 0 {
|
||||
return 0, 1.0
|
||||
}
|
||||
|
||||
// 查阶梯定价(绝对价格)
|
||||
var tiers []pricingTierRow
|
||||
err = dao.MdlPricing.Ctx(ctx).
|
||||
Where("model_id", mid.ID).
|
||||
Where("billing_mode", "tiered").
|
||||
OrderAsc("min_tokens").
|
||||
Fields("min_tokens, max_tokens, input_price, output_price").
|
||||
Scan(&tiers)
|
||||
if err != nil || len(tiers) == 0 {
|
||||
return 0, 1.0
|
||||
}
|
||||
|
||||
return calculateTieredCostFromTiers(tiers, tokens, isInput), 1.0
|
||||
return p.TimeMultiplier
|
||||
}
|
||||
|
||||
// calculateTieredCostFromTiers 从给定的阶梯数组计算费用(租户自定义阶梯或基础阶梯共用)
|
||||
// calculateTieredCostFromTiers 从给定的阶梯数组计算费用(租户自定义阶梯或平台阶梯共用;
|
||||
// 平台阶梯随 GetModelPrice 加载进 PricingResult.CustomTiers,走 600s 定价缓存)
|
||||
func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bool) float64 {
|
||||
if tokens <= 0 || len(tiers) == 0 {
|
||||
return 0
|
||||
|
||||
@@ -315,6 +315,17 @@ func TestValidatePricingConfigured(t *testing.T) {
|
||||
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 1.0}}},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
// 平台阶梯兜底使 CustomTiers 恒非空,全 0 价阶梯不得视为「已配置」放行(免费使用)
|
||||
name: "tiered 双零且阶梯全 0 价拒绝",
|
||||
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, InputPrice: 0, OutputPrice: 0}}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "tiered 双零但阶梯任一档有正价放行",
|
||||
pricing: &PricingResult{BillingMode: "tiered", CustomTiers: []pricingTierRow{{MinTokens: 0, InputPrice: 0, OutputPrice: 0}, {MinTokens: 100, OutputPrice: 10.0}}},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -516,8 +527,66 @@ func TestComputeCost_PerRequestMode_WithMultiplier(t *testing.T) {
|
||||
|
||||
cb := computeCost(pricing, 100, 50, nil)
|
||||
|
||||
// Per-request ignores multiplier in BaseCost but applies it to TotalCost
|
||||
assertFloat(t, cb.TotalCost, 0.10, "per_request TotalCost")
|
||||
// BaseCost 保持折扣前单价,TotalCost = 单价 × 租户乘数 × 时段乘数
|
||||
// (此前按次计费不乘租户乘数,租户折扣对按次模型不生效,已对齐 token/tiered 口径)
|
||||
assertFloat(t, cb.TotalCost, 0.09, "per_request TotalCost")
|
||||
assertFloat(t, cb.BaseCost, 0.10, "per_request BaseCost")
|
||||
}
|
||||
|
||||
func TestComputeCost_PerRequestMode_WithTimeMultiplier(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "per_request",
|
||||
PerRequestPrice: 0.10,
|
||||
TenantMultiplier: 0.9,
|
||||
TimeMultiplier: 0.5,
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
cb := computeCost(pricing, 100, 50, nil)
|
||||
assertFloat(t, cb.TotalCost, 0.045, "per_request TotalCost (tenant×time)")
|
||||
|
||||
// 时段乘数零值兜底:视为 1.0,不得把费用清零
|
||||
pricing.TimeMultiplier = 0
|
||||
cb = computeCost(pricing, 100, 50, nil)
|
||||
assertFloat(t, cb.TotalCost, 0.09, "per_request TotalCost (zero time multiplier fallback)")
|
||||
}
|
||||
|
||||
func TestComputeCost_WithTimeMultiplier(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "token",
|
||||
InputPrice: 3.0,
|
||||
OutputPrice: 15.0,
|
||||
TenantMultiplier: 1.0,
|
||||
TimeMultiplier: 0.5,
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
// 1M input + 1M output:默认价 18,闲时半价 9
|
||||
cb := computeCost(pricing, 1_000_000, 1_000_000, nil)
|
||||
assertFloat(t, cb.InputCost, 1.5, "InputCost (time 0.5)")
|
||||
assertFloat(t, cb.OutputCost, 7.5, "OutputCost (time 0.5)")
|
||||
assertFloat(t, cb.TotalCost, 9.0, "TotalCost (time 0.5)")
|
||||
|
||||
// 与租户乘数叠加:0.8 × 0.5 = 0.4
|
||||
pricing.TenantMultiplier = 0.8
|
||||
cb = computeCost(pricing, 1_000_000, 1_000_000, nil)
|
||||
assertFloat(t, cb.TotalCost, 7.2, "TotalCost (tenant 0.8 × time 0.5)")
|
||||
|
||||
// tiered 模式:阶梯小计 × 时段乘数
|
||||
tiered := &PricingResult{
|
||||
BillingMode: "tiered",
|
||||
TenantMultiplier: 1.0,
|
||||
TimeMultiplier: 0.5,
|
||||
Currency: "USD",
|
||||
CustomTiers: []pricingTierRow{
|
||||
{MinTokens: 0, MaxTokens: ptrInt64(100_000), InputPrice: 5.0, OutputPrice: 15.0},
|
||||
{MinTokens: 100_000, MaxTokens: nil, InputPrice: 3.0, OutputPrice: 10.0},
|
||||
},
|
||||
}
|
||||
// 输入 200K:第一档 100K×$5/1M=0.5 + 第二档 100K×$3/1M=0.3 = 0.8,半价 0.4
|
||||
cb = computeCost(tiered, 200_000, 0, nil)
|
||||
assertFloat(t, cb.InputCost, 0.4, "tiered InputCost (time 0.5)")
|
||||
assertFloat(t, cb.TotalCost, 0.4, "tiered TotalCost (time 0.5)")
|
||||
}
|
||||
|
||||
func TestComputeCost_TieredMode_WithCustomTiers(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
@@ -30,13 +31,13 @@ func (b *BillingProviderImpl) PreDeduct(ctx context.Context, tenantID int64, mod
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) {
|
||||
func (b *BillingProviderImpl) Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*common.SettlementResult, error) {
|
||||
inputTokens, outputTokens := 0, 0
|
||||
if usage != nil {
|
||||
inputTokens = usage.PromptTokens
|
||||
outputTokens = usage.CompletionTokens
|
||||
}
|
||||
result, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID)
|
||||
result, err := Settle(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, inputTokens, outputTokens, preDeductAmount, projectID, billAt)
|
||||
return toCommonSettlementResult(result), err
|
||||
}
|
||||
|
||||
@@ -60,9 +61,9 @@ func (b *BillingProviderImpl) SettleFailed(ctx context.Context, tenantID int64,
|
||||
return SettleFailed(ctx, tenantID, requestID, preDeductAmount)
|
||||
}
|
||||
|
||||
func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64) (*common.SettlementResult, error) {
|
||||
func (b *BillingProviderImpl) SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *common.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*common.SettlementResult, error) {
|
||||
// 直接透传完整 usage:流中断结算需计入 cache token(拆成 input/output 两数会丢 cache 明细)
|
||||
result, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, usage, preDeductAmount, projectID)
|
||||
result, err := SettleStreamInterrupted(ctx, tenantID, userID, apiKeyID, channelID, modelName, requestID, relayMode, usage, preDeductAmount, projectID, billAt)
|
||||
return toCommonSettlementResult(result), err
|
||||
}
|
||||
|
||||
|
||||
@@ -145,20 +145,22 @@ func deleteBillingRecord(ctx context.Context, billingID int64) error {
|
||||
|
||||
// Settle 结算请求费用
|
||||
// 预扣→调用→结算→退差额/补扣
|
||||
// billAt 为请求受理时刻(时段定价按该时刻评估;零值按当前时刻兜底)
|
||||
func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
modelName, requestID, relayMode string,
|
||||
inputTokens, outputTokens int,
|
||||
preDeductAmount float64, projectID int64) (*SettlementResult, error) {
|
||||
preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error) {
|
||||
|
||||
// 1. 计算实际费用
|
||||
breakdown, err := CalculateCost(ctx, tenantID, modelName, inputTokens, outputTokens)
|
||||
if err != nil {
|
||||
// 1. 定价与实际费用:按受理时刻一次性取价(时段定价预扣/结算口径一致)
|
||||
pricingResult, pricingErr := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
var breakdown *CostBreakdown
|
||||
if pricingErr != nil {
|
||||
// A4 修复:计价失败【不得】按零费用结算——那会把定价异常/模型未配价/短暂 DB 故障
|
||||
// 都变成免费请求。改为 fail-closed 兜底:按已冻结的预扣额计费(预扣是请求受理时的估价,
|
||||
// 当前可得的最佳估值),与 task 结算路径(async_polling / sync_image_worker 默认
|
||||
// actualCost = PreDeductAmount)保持一致。保留 token 数便于账单核对。
|
||||
g.Log().Errorf(ctx, "settle: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
|
||||
requestID, modelName, preDeductAmount, err)
|
||||
requestID, modelName, preDeductAmount, pricingErr)
|
||||
breakdown = &CostBreakdown{
|
||||
TotalCost: preDeductAmount,
|
||||
BaseCost: preDeductAmount,
|
||||
@@ -166,6 +168,8 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
OutputTokens: outputTokens,
|
||||
Currency: "USD",
|
||||
}
|
||||
} else {
|
||||
breakdown = computeCost(pricingResult, inputTokens, outputTokens, nil)
|
||||
}
|
||||
actualCost := breakdown.TotalCost
|
||||
|
||||
@@ -178,8 +182,7 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
return nil, gerror.Wrapf(err, "settle: get wallet")
|
||||
}
|
||||
|
||||
// 4. 获取定价信息(事务外只读)
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
// 4. 定价信息已在第 1 步随费用计算一并获取(事务外只读)
|
||||
|
||||
// 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水)
|
||||
billingID, err := executeSettlement(ctx, settlementTxParams{
|
||||
@@ -255,17 +258,29 @@ func Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SettleWithUsage 完整 Usage 结算(含 cache token 计费 + 计费快照)
|
||||
// SettleWithUsage 完整 Usage 结算(含 cache token 计费 + 计费快照)。
|
||||
// 时段定价按 relayInfo.StartTime(请求受理时刻)评估,跨时段边界的请求不因结算延迟变价。
|
||||
func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
modelName, requestID, relayMode string,
|
||||
usage *rcommon.Usage, preDeductAmount float64, relayInfo *rcommon.RelayInfo) (*SettlementResult, error) {
|
||||
|
||||
// 1. 使用完整 Usage 计算实际费用(含 cache token)
|
||||
breakdown, err := CalculateCostWithUsage(ctx, tenantID, modelName, usage)
|
||||
if err != nil {
|
||||
// 1. 定价与实际费用:按受理时刻一次性取价后用完整 Usage 计算(含 cache token)。
|
||||
// 此前 CalculateCostWithUsage 与第 4 步各取一次定价,两次结果可能不一致(缓存刷新窗口),已合并。
|
||||
var billAt time.Time
|
||||
if relayInfo != nil {
|
||||
billAt = relayInfo.StartTime
|
||||
}
|
||||
pricingResult, pricingErr := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
var breakdown *CostBreakdown
|
||||
if pricingErr != nil || usage == nil {
|
||||
// A4 修复:计价失败 fail-closed 兜底按预扣额计费,而非零费用(免费请求)。见 Settle 同段说明。
|
||||
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
|
||||
requestID, modelName, preDeductAmount, err)
|
||||
if pricingErr != nil {
|
||||
g.Log().Errorf(ctx, "settle_with_usage: calculate cost failed for %s (model=%s), fallback to pre-deduct estimate %.6f: %v",
|
||||
requestID, modelName, preDeductAmount, pricingErr)
|
||||
} else {
|
||||
g.Log().Errorf(ctx, "settle_with_usage: usage is nil for %s (model=%s), fallback to pre-deduct estimate %.6f",
|
||||
requestID, modelName, preDeductAmount)
|
||||
}
|
||||
fb := &CostBreakdown{
|
||||
TotalCost: preDeductAmount,
|
||||
BaseCost: preDeductAmount,
|
||||
@@ -276,6 +291,8 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
fb.OutputTokens = usage.CompletionTokens
|
||||
}
|
||||
breakdown = fb
|
||||
} else {
|
||||
breakdown = computeCost(pricingResult, usage.PromptTokens, usage.CompletionTokens, usage)
|
||||
}
|
||||
actualCost := breakdown.TotalCost
|
||||
|
||||
@@ -288,8 +305,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
return nil, gerror.Wrapf(err, "settle_with_usage: get wallet")
|
||||
}
|
||||
|
||||
// 4. 获取定价信息(事务外只读)
|
||||
pricingResult, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
// 4. 定价信息已在第 1 步随费用计算一并获取(事务外只读)
|
||||
|
||||
// 5. 执行结算(幂等闸门 → Redis 认领扣款 → 流水)
|
||||
billingID, err := executeSettlement(ctx, settlementTxParams{
|
||||
@@ -401,9 +417,10 @@ func SettleFailed(ctx context.Context, tenantID int64, requestID string, preDedu
|
||||
// SettleStreamInterrupted 流式中断结算:按已确认 usage 走完整 Usage 结算(含 cache token 计费 + 快照)。
|
||||
// 此前按 (input, output) 两数走 Settle,会丢弃 usage 中的 cache_read / cache_creation token——
|
||||
// Claude 场景 prompt_tokens 不含 cache token,大缓存请求中断时缓存部分完全漏计费。
|
||||
// billAt 为请求受理时刻(写入合成 RelayInfo 供时段定价评估;零值按当前时刻兜底)。
|
||||
func SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
modelName, requestID, relayMode string,
|
||||
usage *rcommon.Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error) {
|
||||
usage *rcommon.Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error) {
|
||||
|
||||
// usage 为 nil 必须兜换空 Usage:SettleWithUsage 对 nil usage 会 fail-closed 按预扣全额计费,
|
||||
// 而中断且无任何 usage 的正确语义是 0 token 结算(成本 0,全额退差)
|
||||
@@ -414,6 +431,7 @@ func SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, ch
|
||||
ProjectID: projectID,
|
||||
OriginModelName: modelName,
|
||||
IsStream: true,
|
||||
StartTime: billAt,
|
||||
}
|
||||
return SettleWithUsage(ctx, tenantID, userID, apiKeyID, channelID,
|
||||
modelName, requestID, relayMode, usage, preDeductAmount, relayInfo)
|
||||
|
||||
@@ -33,6 +33,8 @@ type BillingSnapshotMultipliers struct {
|
||||
TenantMultiplier float64 `json:"tenant_multiplier"`
|
||||
DiscountRatio float64 `json:"discount_ratio"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
TimeMultiplier float64 `json:"time_multiplier"` // 时段乘数(未启用时段定价时为 1)
|
||||
TimeRule string `json:"time_rule"` // 命中的时段名(供账单解释;未命中为空)
|
||||
}
|
||||
|
||||
// BillingSnapshotCachePrices 缓存价格信息
|
||||
@@ -91,6 +93,8 @@ func GenerateBillingSnapshot(
|
||||
TenantMultiplier: pricing.TenantMultiplier,
|
||||
DiscountRatio: pricing.DiscountRatio,
|
||||
RateMultiplier: pricing.TenantMultiplier,
|
||||
TimeMultiplier: effectiveTimeMultiplier(pricing),
|
||||
TimeRule: pricing.TimeRuleName,
|
||||
},
|
||||
TokenCosts: buildTokenCosts(pricing, breakdown),
|
||||
}
|
||||
@@ -228,13 +232,27 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
|
||||
}
|
||||
|
||||
// 小计 × 倍率
|
||||
// 小计 × 倍率(乘法链 = 租户倍率 × 时段乘数,两者均为 1 时省略该行)
|
||||
subtotal := snapshot.Settlement.ActualCost
|
||||
if snapshot.Multipliers.TenantMultiplier != 1.0 && snapshot.Multipliers.TenantMultiplier != 0 {
|
||||
preMultiplier := subtotal / snapshot.Multipliers.TenantMultiplier
|
||||
lines = append(lines, fmt.Sprintf("小计: $%s × 租户倍率(%.2f) = $%s",
|
||||
formatPrice(preMultiplier), snapshot.Multipliers.TenantMultiplier, formatPrice(subtotal)))
|
||||
effTenant := snapshot.Multipliers.TenantMultiplier
|
||||
effTime := snapshot.Multipliers.TimeMultiplier
|
||||
if effTime <= 0 {
|
||||
effTime = 1.0
|
||||
}
|
||||
if effTenant > 0 && (effTenant != 1.0 || effTime != 1.0) {
|
||||
preMultiplier := subtotal / (effTenant * effTime)
|
||||
multDesc := fmt.Sprintf("%.2f", effTenant)
|
||||
if effTime != 1.0 {
|
||||
multDesc += fmt.Sprintf(" × 时段(%.2f)", effTime)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("小计: $%s × 租户倍率(%s) = $%s",
|
||||
formatPrice(preMultiplier), multDesc, formatPrice(subtotal)))
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价行:放在 token/per_request 分支之后统一展示,保证账单可解释(按次计费同样适用时段乘数)
|
||||
if snapshot.Multipliers.TimeRule != "" && snapshot.Multipliers.TimeMultiplier > 0 && snapshot.Multipliers.TimeMultiplier != 1.0 {
|
||||
lines = append(lines, fmt.Sprintf("时段: %s ×%.2f", snapshot.Multipliers.TimeRule, snapshot.Multipliers.TimeMultiplier))
|
||||
}
|
||||
|
||||
// 结算信息
|
||||
|
||||
@@ -443,3 +443,54 @@ func TestFormatPrice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateBillingSnapshot_TimeMultipliers(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
InputPrice: 1.0,
|
||||
OutputPrice: 2.0,
|
||||
BillingMode: "token",
|
||||
TenantMultiplier: 0.8,
|
||||
TimeMultiplier: 0.5,
|
||||
TimeRuleName: "闲时",
|
||||
}
|
||||
breakdown := &CostBreakdown{InputTokens: 100, OutputTokens: 50}
|
||||
|
||||
snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, nil, nil)
|
||||
assertFloat(t, snapshot.Multipliers.TimeMultiplier, 0.5, "TimeMultiplier")
|
||||
if snapshot.Multipliers.TimeRule != "闲时" {
|
||||
t.Errorf("TimeRule = %q, want 闲时", snapshot.Multipliers.TimeRule)
|
||||
}
|
||||
|
||||
// 时段乘数零值兜底:快照记录 1 而非 0
|
||||
pricing.TimeMultiplier = 0
|
||||
snapshot = GenerateBillingSnapshot(pricing, breakdown, nil, nil, nil)
|
||||
assertFloat(t, snapshot.Multipliers.TimeMultiplier, 1.0, "TimeMultiplier (zero fallback)")
|
||||
}
|
||||
|
||||
func TestGenerateBillingSummary_TimeRule(t *testing.T) {
|
||||
snapshot := &BillingSnapshot{
|
||||
Pricing: BillingSnapshotPricing{
|
||||
BillingMode: "token",
|
||||
EffectiveInputPrice: 1.0,
|
||||
BillingSource: "base",
|
||||
},
|
||||
Multipliers: BillingSnapshotMultipliers{
|
||||
TenantMultiplier: 0.8,
|
||||
TimeMultiplier: 0.5,
|
||||
TimeRule: "闲时",
|
||||
},
|
||||
Settlement: BillingSnapshotSettlement{
|
||||
PreDeductAmount: 0.1,
|
||||
ActualCost: 0.04,
|
||||
},
|
||||
RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "gpt-4o"},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
if !strings.Contains(text, "闲时") {
|
||||
t.Errorf("summary should contain time rule name, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "时段(0.50)") {
|
||||
t.Errorf("summary should contain time multiplier in subtotal line, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,8 @@ func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)
|
||||
// totalTokens/completionTokens: 上游返回的 token 用量
|
||||
// ratios: 提交时保存的计费比率(如 video_input 折扣)
|
||||
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) (*common.SettlementResult, error) {
|
||||
// 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) {
|
||||
diff := SubtractMoney(actualCost, preDeductAmount)
|
||||
|
||||
// 1. 获取钱包
|
||||
@@ -162,8 +163,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
return nil, fmt.Errorf("settle task: get wallet: %w", err)
|
||||
}
|
||||
|
||||
// 2. 获取定价(事务外只读)
|
||||
pricing, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
|
||||
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
|
||||
var billingMode string
|
||||
@@ -337,7 +338,7 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
|
||||
// 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行
|
||||
// (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。
|
||||
if totalTokens <= 0 {
|
||||
bd.BaseCost = actualCost
|
||||
bd.BaseCost = preMultiplierCost(actualCost, pricing)
|
||||
bd.TotalCost = actualCost
|
||||
return bd
|
||||
}
|
||||
@@ -346,17 +347,22 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
|
||||
bd.OutputTokens = totalTokens
|
||||
bd.OutputCost = actualCost
|
||||
bd.TotalCost = actualCost
|
||||
|
||||
tenantMul := pricing.TenantMultiplier
|
||||
if tenantMul > 0 {
|
||||
bd.BaseCost = actualCost / tenantMul
|
||||
} else {
|
||||
bd.BaseCost = actualCost
|
||||
}
|
||||
bd.BaseCost = preMultiplierCost(actualCost, pricing)
|
||||
|
||||
return bd
|
||||
}
|
||||
|
||||
// preMultiplierCost 把已含「租户乘数 × 时段乘数」的实际费用还原为折扣前成本(BaseCost 语义)。
|
||||
// actualCost 来自 RecalculateByTokens(已乘全部乘数)或预扣额(EstimatePreDeductAmount 同样含乘数),
|
||||
// 还原时两个乘数都要除,与 computeCost 的 BaseCost = 折扣前小计语义对齐
|
||||
func preMultiplierCost(actualCost float64, pricing *PricingResult) float64 {
|
||||
mul := pricing.TenantMultiplier * effectiveTimeMultiplier(pricing)
|
||||
if mul <= 0 {
|
||||
return actualCost
|
||||
}
|
||||
return actualCost / mul
|
||||
}
|
||||
|
||||
// SettleTaskFailed 任务失败退还预扣
|
||||
func (b *TaskBillingProviderImpl) SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error {
|
||||
preDeductFloat := InexactFloat64(preDeductAmount)
|
||||
@@ -395,14 +401,15 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
|
||||
}
|
||||
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用。
|
||||
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 附加比率
|
||||
// 如果模型没有配置 token 单价(纯按次计费),返回 0 表示不做 token 重算。
|
||||
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error) {
|
||||
// 公式: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) {
|
||||
if totalTokens <= 0 {
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
pricing, err := GetModelPrice(ctx, tenantID, modelName)
|
||||
pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt)
|
||||
if err != nil {
|
||||
return Zero, nil
|
||||
}
|
||||
@@ -416,7 +423,8 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan
|
||||
million := decimal.NewFromInt(1_000_000)
|
||||
costD := decimal.NewFromInt(int64(totalTokens)).Div(million).
|
||||
Mul(NewFromFloat(pricing.OutputPrice)).
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier))
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier)).
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
|
||||
// 应用附加比率(视频输入折扣等)
|
||||
// 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘
|
||||
|
||||
@@ -118,6 +118,44 @@ func TestBuildTaskCostBreakdown_TokenMode_ZeroTokens(t *testing.T) {
|
||||
assertFloat(t, bd.TotalCost, 3.375, "TotalCost")
|
||||
}
|
||||
|
||||
// TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier BaseCost 还原必须同时除以
|
||||
// 租户乘数与时段乘数(actualCost 已含全部乘数),与 computeCost 的折扣前小计语义对齐。
|
||||
func TestBuildTaskCostBreakdown_TokenMode_TimeMultiplier(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "token",
|
||||
OutputPrice: 3.0,
|
||||
TenantMultiplier: 0.8,
|
||||
TimeMultiplier: 0.5,
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
// 0.4 / (0.8 × 0.5) = 1.0
|
||||
bd := buildTaskCostBreakdown(pricing, 0.4, 10000, 5000)
|
||||
assertFloat(t, bd.BaseCost, 1.0, "BaseCost (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.4, "TotalCost")
|
||||
|
||||
// 无 token 用量分支同样按双乘数还原:0.08 / (0.8 × 0.5) = 0.2
|
||||
bd = buildTaskCostBreakdown(pricing, 0.08, 0, 0)
|
||||
assertFloat(t, bd.BaseCost, 0.2, "BaseCost zero-token (tenant × time)")
|
||||
assertFloat(t, bd.TotalCost, 0.08, "TotalCost zero-token")
|
||||
}
|
||||
|
||||
// TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback 时段乘数零值(升级部署后
|
||||
// Redis L2 旧定价条目缺字段)兜底为 1.0,BaseCost 还原不得失真。
|
||||
func TestBuildTaskCostBreakdown_ZeroTimeMultiplierFallback(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
BillingMode: "token",
|
||||
OutputPrice: 3.0,
|
||||
TenantMultiplier: 0.8,
|
||||
TimeMultiplier: 0, // 旧缓存条目缺字段
|
||||
Currency: "USD",
|
||||
}
|
||||
|
||||
// 0.024 / (0.8 × 1.0) = 0.03
|
||||
bd := buildTaskCostBreakdown(pricing, 0.024, 1000, 500)
|
||||
assertFloat(t, bd.BaseCost, 0.03, "BaseCost (time fallback 1.0)")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_PerRequest 按次计费直接取按次单价。
|
||||
func TestEstimateTaskCost_PerRequest(t *testing.T) {
|
||||
pricing := &PricingResult{
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// TimeSegment 时段定价规则(mdl_pricing.time_segments JSONB 数组元素)。
|
||||
// 语义:按数组顺序先命中先生效,可用来表达「促销时段压在常驻时段之上」;
|
||||
// 未命中任何时段 → 乘数 1.0(默认价)。最终费用 = 各项小计 × 租户乘数 × 时段乘数。
|
||||
type TimeSegment struct {
|
||||
Name string `json:"name"` // 时段名(写入计费快照,供账单解释)
|
||||
Days []int `json:"days,omitempty"` // 适用星期 1=周一..7=周日;空=每天
|
||||
StartTime string `json:"start_time,omitempty"` // 每日开始时刻 "HH:MM";与 EndTime 均空=全天
|
||||
EndTime string `json:"end_time,omitempty"` // 每日结束时刻;End<Start 表示跨零点(如 22:00~06:00)
|
||||
ValidFrom string `json:"valid_from,omitempty"` // 生效起始日期 "2006-01-02"(含端点);促销/定时调价用
|
||||
ValidTo string `json:"valid_to,omitempty"` // 生效结束日期(含端点);空=长期有效
|
||||
Multiplier float64 `json:"multiplier"` // 价格乘数,0.5=半价;取值 (0,10]
|
||||
}
|
||||
|
||||
const (
|
||||
// maxTimeSegments 单模型时段数量上限,防止配置爆炸与评估开销失控
|
||||
maxTimeSegments = 20
|
||||
// defaultPricingTimezone 时段定价默认时区(平台面向中国区运营)
|
||||
defaultPricingTimezone = "Asia/Shanghai"
|
||||
)
|
||||
|
||||
// tzLocationCache 时区名 → *time.Location 缓存,避免每请求 LoadLocation
|
||||
var tzLocationCache sync.Map
|
||||
|
||||
// resolveTimeMultiplier 按定价时刻评估时段乘数(纯函数,不触 DB/缓存)。
|
||||
// 返回命中的乘数与时段名;未命中返回 (1.0, "")。
|
||||
// 格式非法的时段直接跳过——数据在写入时已过 ValidateTimeSegments,此处仅防御性兜底。
|
||||
func resolveTimeMultiplier(segments []TimeSegment, billAt time.Time, loc *time.Location) (float64, string) {
|
||||
for _, seg := range segments {
|
||||
if segmentMatches(seg, billAt, loc) {
|
||||
return seg.Multiplier, seg.Name
|
||||
}
|
||||
}
|
||||
return 1.0, ""
|
||||
}
|
||||
|
||||
// segmentMatches 判断 billAt(换算到 loc 时区后)是否命中单个时段。
|
||||
// 三个维度正交组合:日期边界 × 星期 × 每日时间窗。
|
||||
func segmentMatches(seg TimeSegment, t time.Time, loc *time.Location) bool {
|
||||
local := t.In(loc)
|
||||
|
||||
// 日期边界(含端点):YYYY-MM-DD 按可比较整数
|
||||
if seg.ValidFrom != "" {
|
||||
from, ok := parseSegDate(seg.ValidFrom)
|
||||
if !ok || segDateNum(local) < from {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if seg.ValidTo != "" {
|
||||
to, ok := parseSegDate(seg.ValidTo)
|
||||
if !ok || segDateNum(local) > to {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 星期过滤:Go 的 Weekday 周日=0,映射为 ISO 习惯的 1=周一..7=周日
|
||||
if len(seg.Days) > 0 {
|
||||
wd := int(local.Weekday())
|
||||
if wd == 0 {
|
||||
wd = 7
|
||||
}
|
||||
if !slices.Contains(seg.Days, wd) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 每日时间窗:两项均空=全天;只填一项视为非法,跳过
|
||||
if seg.StartTime == "" && seg.EndTime == "" {
|
||||
return true
|
||||
}
|
||||
start, okS := parseSegClock(seg.StartTime)
|
||||
end, okE := parseSegClock(seg.EndTime)
|
||||
if !okS || !okE {
|
||||
return false
|
||||
}
|
||||
cur := local.Hour()*60 + local.Minute()
|
||||
if start <= end {
|
||||
// 常规窗口 [start, end),结束时刻那一分钟起不再命中(08:00~ 表示到 07:59:59)
|
||||
return cur >= start && cur < end
|
||||
}
|
||||
// 跨零点窗口(如 22:00~06:00):当日 >= start 或 次日凌晨 < end
|
||||
return cur >= start || cur < end
|
||||
}
|
||||
|
||||
// parseSegClock 解析 "HH:MM" 为当日分钟数(严格两位数字,"9:00"/"24:00" 等视为非法)
|
||||
func parseSegClock(s string) (int, bool) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
h, errH := strconv.Atoi(parts[0])
|
||||
m, errM := strconv.Atoi(parts[1])
|
||||
if errH != nil || errM != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return h*60 + m, true
|
||||
}
|
||||
|
||||
// parseSegDate 解析 "2006-01-02" 为可比较整数 YYYYMMDD
|
||||
func parseSegDate(s string) (int, bool) {
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return segDateNum(t), true
|
||||
}
|
||||
|
||||
// segDateNum time.Time → YYYYMMDD 整数,用于日期边界比较
|
||||
func segDateNum(t time.Time) int {
|
||||
return t.Year()*10000 + int(t.Month())*100 + t.Day()
|
||||
}
|
||||
|
||||
// ValidateTimeSegments 校验时段定价配置(管理后台保存时调用,fail-fast 返回用户可读中文错误)
|
||||
func ValidateTimeSegments(segments []TimeSegment) error {
|
||||
if len(segments) > maxTimeSegments {
|
||||
return gerror.Newf("时段数量不能超过 %d 条", maxTimeSegments)
|
||||
}
|
||||
for i, seg := range segments {
|
||||
label := seg.Name
|
||||
if label == "" {
|
||||
label = strconv.Itoa(i + 1)
|
||||
}
|
||||
if strings.TrimSpace(seg.Name) == "" {
|
||||
return gerror.Newf("第 %s 条时段名称不能为空", label)
|
||||
}
|
||||
if seg.Multiplier <= 0 || seg.Multiplier > 10 {
|
||||
return gerror.Newf("时段「%s」乘数必须在 (0, 10] 之间", seg.Name)
|
||||
}
|
||||
// 时间窗:两项要么都空(全天)要么都填
|
||||
if (seg.StartTime == "") != (seg.EndTime == "") {
|
||||
return gerror.Newf("时段「%s」开始与结束时间必须同时填写或同时留空", seg.Name)
|
||||
}
|
||||
if seg.StartTime != "" {
|
||||
if _, ok := parseSegClock(seg.StartTime); !ok {
|
||||
return gerror.Newf("时段「%s」开始时间格式必须为 HH:MM", seg.Name)
|
||||
}
|
||||
if _, ok := parseSegClock(seg.EndTime); !ok {
|
||||
return gerror.Newf("时段「%s」结束时间格式必须为 HH:MM", seg.Name)
|
||||
}
|
||||
}
|
||||
// 星期:1~7 且不重复
|
||||
seen := make(map[int]bool, len(seg.Days))
|
||||
for _, d := range seg.Days {
|
||||
if d < 1 || d > 7 {
|
||||
return gerror.Newf("时段「%s」适用日必须在 1(周一)~ 7(周日)之间", seg.Name)
|
||||
}
|
||||
if seen[d] {
|
||||
return gerror.Newf("时段「%s」适用日存在重复", seg.Name)
|
||||
}
|
||||
seen[d] = true
|
||||
}
|
||||
// 日期边界格式与先后
|
||||
if seg.ValidFrom != "" {
|
||||
if _, ok := parseSegDate(seg.ValidFrom); !ok {
|
||||
return gerror.Newf("时段「%s」生效起始日期格式必须为 YYYY-MM-DD", seg.Name)
|
||||
}
|
||||
}
|
||||
if seg.ValidTo != "" {
|
||||
if _, ok := parseSegDate(seg.ValidTo); !ok {
|
||||
return gerror.Newf("时段「%s」生效结束日期格式必须为 YYYY-MM-DD", seg.Name)
|
||||
}
|
||||
}
|
||||
if seg.ValidFrom != "" && seg.ValidTo != "" && seg.ValidFrom > seg.ValidTo {
|
||||
return gerror.Newf("时段「%s」生效起始日期不能晚于结束日期", seg.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pricingTimeLocation 返回时段定价使用的时区(sys_options: pricing_time_timezone,默认 Asia/Shanghai)。
|
||||
// Location 按 tz 字符串进程级缓存;解析失败兜底 time.Local(容器/主机 TZ 为 Asia/Shanghai)。
|
||||
func pricingTimeLocation(ctx context.Context) *time.Location {
|
||||
tz := lcommon.Config().GetOption(ctx, "pricing_time_timezone")
|
||||
if tz == "" {
|
||||
tz = defaultPricingTimezone
|
||||
}
|
||||
if loc, ok := tzLocationCache.Load(tz); ok {
|
||||
return loc.(*time.Location)
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "billing: 非法计费时区配置 %q,回退本地时区", tz)
|
||||
loc = time.Local
|
||||
}
|
||||
tzLocationCache.Store(tz, loc)
|
||||
return loc
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cst 测试用固定时区 UTC+8(与生产默认 Asia/Shanghai 偏移一致),避免依赖宿主机时区
|
||||
var cst = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// monday/wednesday/saturday/sunday 2026-08-17 为周一(下方有防御性断言),18 周二、19 周三、22 周六、23 周日
|
||||
var (
|
||||
monday = time.Date(2026, 8, 17, 0, 0, 0, 0, cst)
|
||||
wednesday = time.Date(2026, 8, 19, 0, 0, 0, 0, cst)
|
||||
saturday = time.Date(2026, 8, 22, 0, 0, 0, 0, cst)
|
||||
)
|
||||
|
||||
func TestTimePricing_FixtureWeekdaySanity(t *testing.T) {
|
||||
// 日期夹具防御性校验:若有人改动夹具日期导致星期错位,在此直接失败
|
||||
if monday.Weekday() != time.Monday || wednesday.Weekday() != time.Wednesday ||
|
||||
saturday.Weekday() != time.Saturday {
|
||||
t.Fatalf("测试日期夹具星期不正确: %v %v %v", monday.Weekday(), wednesday.Weekday(), saturday.Weekday())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_FirstMatchWins(t *testing.T) {
|
||||
segments := []TimeSegment{
|
||||
{Name: "促销", Multiplier: 0.8, StartTime: "00:00", EndTime: "24:00"}, // "24:00" 非法,跳过
|
||||
{Name: "促销2", Multiplier: 0.8},
|
||||
{Name: "闲时", Multiplier: 0.5},
|
||||
}
|
||||
// 命中第二条(第一条格式非法被跳过),证明顺序先命中 + 坏格式防御
|
||||
mult, name := resolveTimeMultiplier(segments, wednesday.Add(10*time.Hour), cst)
|
||||
assertFloat(t, mult, 0.8, "mult")
|
||||
if name != "促销2" {
|
||||
t.Fatalf("expected 促销2, got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_NormalWindow(t *testing.T) {
|
||||
segments := []TimeSegment{{Name: "忙时", Days: []int{1, 2, 3, 4, 5}, StartTime: "09:00", EndTime: "18:00", Multiplier: 1.2}}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
at time.Time
|
||||
expected float64
|
||||
}{
|
||||
{"工作日窗口内 10:00", monday.Add(10 * time.Hour), 1.2},
|
||||
{"工作日开始时刻 09:00(含)", monday.Add(9 * time.Hour), 1.2},
|
||||
{"工作日结束时刻 18:00(不含)", monday.Add(18 * time.Hour), 1.0},
|
||||
{"工作日窗口前 08:59", monday.Add(8*time.Hour + 59*time.Minute), 1.0},
|
||||
{"周六不命中(days 过滤)", saturday.Add(10 * time.Hour), 1.0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
|
||||
assertFloat(t, mult, c.expected, c.desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_CrossMidnight(t *testing.T) {
|
||||
segments := []TimeSegment{{Name: "夜段", StartTime: "22:00", EndTime: "06:00", Multiplier: 0.5}}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
at time.Time
|
||||
expected float64
|
||||
}{
|
||||
{"22:00 起始(含)", wednesday.Add(22 * time.Hour), 0.5},
|
||||
{"23:30 窗口内", wednesday.Add(23*time.Hour + 30*time.Minute), 0.5},
|
||||
{"次日 03:00 仍命中", wednesday.Add(24*time.Hour + 3*time.Hour), 0.5},
|
||||
{"06:00 结束(不含)", wednesday.Add(6 * time.Hour), 1.0},
|
||||
{"12:00 窗口外", wednesday.Add(12 * time.Hour), 1.0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
|
||||
assertFloat(t, mult, c.expected, c.desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_AllDayAndEmpty(t *testing.T) {
|
||||
// 全天段(时间两项均空)
|
||||
segments := []TimeSegment{{Name: "周末", Days: []int{6, 7}, Multiplier: 0.8}}
|
||||
mult, name := resolveTimeMultiplier(segments, saturday.Add(15*time.Hour), cst)
|
||||
assertFloat(t, mult, 0.8, "周末全天")
|
||||
if name != "周末" {
|
||||
t.Fatalf("expected 周末, got %s", name)
|
||||
}
|
||||
|
||||
// 无时段 → 默认 1.0
|
||||
mult, name = resolveTimeMultiplier(nil, wednesday, cst)
|
||||
assertFloat(t, mult, 1.0, "无时段")
|
||||
if name != "" {
|
||||
t.Fatalf("expected empty name, got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_DateBounds(t *testing.T) {
|
||||
segments := []TimeSegment{{Name: "促销", ValidFrom: "2026-08-17", ValidTo: "2026-08-19", Multiplier: 0.8}}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
at time.Time
|
||||
expected float64
|
||||
}{
|
||||
{"生效首日 00:05(含端点)", monday.Add(5 * time.Minute), 0.8},
|
||||
{"生效末日 23:59(含端点)", wednesday.Add(23*time.Hour + 59*time.Minute), 0.8},
|
||||
{"结束后次日", wednesday.Add(24 * time.Hour), 1.0},
|
||||
{"开始前一日", monday.Add(-time.Hour), 1.0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
mult, _ := resolveTimeMultiplier(segments, c.at, cst)
|
||||
assertFloat(t, mult, c.expected, c.desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_CombinedDimensions(t *testing.T) {
|
||||
// 日期边界 × 工作日 × 时间窗三维度正交组合
|
||||
segments := []TimeSegment{
|
||||
{Name: "九月促销忙时", Days: []int{1, 2, 3, 4, 5}, StartTime: "09:00", EndTime: "18:00",
|
||||
ValidFrom: "2026-09-01", ValidTo: "2026-09-30", Multiplier: 0.8},
|
||||
}
|
||||
septMonday := time.Date(2026, 9, 7, 10, 0, 0, 0, cst) // 2026-09-07 周一
|
||||
if septMonday.Weekday() != time.Monday {
|
||||
t.Fatalf("夹具 2026-09-07 应为周一, got %v", septMonday.Weekday())
|
||||
}
|
||||
mult, _ := resolveTimeMultiplier(segments, septMonday, cst)
|
||||
assertFloat(t, mult, 0.8, "促销期内工作日忙时")
|
||||
|
||||
// 周六即使时间命中也不生效
|
||||
septSaturday := time.Date(2026, 9, 12, 10, 0, 0, 0, cst)
|
||||
mult, _ = resolveTimeMultiplier(segments, septSaturday, cst)
|
||||
assertFloat(t, mult, 1.0, "促销期内周六")
|
||||
|
||||
// 工作日但窗口外
|
||||
mult, _ = resolveTimeMultiplier(segments, septMonday.Add(20*time.Hour), cst)
|
||||
assertFloat(t, mult, 1.0, "促销期内工作日夜里")
|
||||
|
||||
// 促销期外的工作日忙时
|
||||
octMonday := time.Date(2026, 10, 5, 10, 0, 0, 0, cst)
|
||||
mult, _ = resolveTimeMultiplier(segments, octMonday, cst)
|
||||
assertFloat(t, mult, 1.0, "促销期外")
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_BadFormatSkipped(t *testing.T) {
|
||||
segments := []TimeSegment{
|
||||
{Name: "坏时间", StartTime: "9:00", EndTime: "18:00", Multiplier: 0.1}, // "9:00" 缺前导零视为非法
|
||||
{Name: "坏小时", StartTime: "25:00", EndTime: "26:00", Multiplier: 0.2},
|
||||
{Name: "只填开始", StartTime: "09:00", Multiplier: 0.3},
|
||||
{Name: "兜底段", Multiplier: 0.9},
|
||||
}
|
||||
mult, name := resolveTimeMultiplier(segments, monday.Add(10*time.Hour), cst)
|
||||
assertFloat(t, mult, 0.9, "坏格式全部跳过后命中兜底段")
|
||||
if name != "兜底段" {
|
||||
t.Fatalf("expected 兜底段, got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTimeMultiplier_TimezoneConversion(t *testing.T) {
|
||||
// 时段按评估时区解释:UTC 16:00 = CST 00:00(次日),应命中 CST 00:00~08:00 的闲时段
|
||||
segments := []TimeSegment{{Name: "闲时", StartTime: "00:00", EndTime: "08:00", Multiplier: 0.5}}
|
||||
utc := time.FixedZone("UTC", 0)
|
||||
at := time.Date(2026, 8, 19, 16, 30, 0, 0, utc) // UTC 16:30 = CST 00:30
|
||||
mult, _ := resolveTimeMultiplier(segments, at, cst)
|
||||
assertFloat(t, mult, 0.5, "跨时区换算后命中")
|
||||
}
|
||||
|
||||
func TestValidateTimeSegments(t *testing.T) {
|
||||
ok := []TimeSegment{
|
||||
{Name: "闲时", StartTime: "00:00", EndTime: "08:00", Multiplier: 0.5},
|
||||
{Name: "周末", Days: []int{6, 7}, Multiplier: 10},
|
||||
{Name: "促销", ValidFrom: "2026-09-01", ValidTo: "2026-09-30", Multiplier: 0.8},
|
||||
}
|
||||
if err := ValidateTimeSegments(ok); err != nil {
|
||||
t.Fatalf("合法配置不应报错: %v", err)
|
||||
}
|
||||
if err := ValidateTimeSegments(nil); err != nil {
|
||||
t.Fatalf("空配置不应报错: %v", err)
|
||||
}
|
||||
|
||||
badCases := []struct {
|
||||
desc string
|
||||
seg TimeSegment
|
||||
errIs bool // 期望报错
|
||||
}{
|
||||
{"乘数为 0", TimeSegment{Name: "a", Multiplier: 0}, true},
|
||||
{"乘数超上限", TimeSegment{Name: "a", Multiplier: 10.1}, true},
|
||||
{"名称为空", TimeSegment{Name: "", Multiplier: 1}, true},
|
||||
{"只填开始时间", TimeSegment{Name: "a", StartTime: "09:00", Multiplier: 1}, true},
|
||||
{"时间格式非法", TimeSegment{Name: "a", StartTime: "9:00", EndTime: "18:00", Multiplier: 1}, true},
|
||||
{"小时越界", TimeSegment{Name: "a", StartTime: "24:00", EndTime: "25:00", Multiplier: 1}, true},
|
||||
{"星期越界", TimeSegment{Name: "a", Days: []int{0}, Multiplier: 1}, true},
|
||||
{"星期重复", TimeSegment{Name: "a", Days: []int{1, 1}, Multiplier: 1}, true},
|
||||
{"日期格式非法", TimeSegment{Name: "a", ValidFrom: "2026/09/01", Multiplier: 1}, true},
|
||||
{"起始晚于结束", TimeSegment{Name: "a", ValidFrom: "2026-09-30", ValidTo: "2026-09-01", Multiplier: 1}, true},
|
||||
}
|
||||
for _, c := range badCases {
|
||||
err := ValidateTimeSegments([]TimeSegment{c.seg})
|
||||
if c.errIs && err == nil {
|
||||
t.Fatalf("%s: 期望报错但通过", c.desc)
|
||||
}
|
||||
}
|
||||
|
||||
// 数量上限
|
||||
var many []TimeSegment
|
||||
for range maxTimeSegments + 1 {
|
||||
many = append(many, TimeSegment{Name: "s", Multiplier: 1})
|
||||
}
|
||||
if err := ValidateTimeSegments(many); err == nil {
|
||||
t.Fatalf("超过 %d 条应报错", maxTimeSegments)
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcache"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
@@ -135,8 +137,15 @@ func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
|
||||
g.Log().Warningf(ctx, "[Cache] JSON unmarshal failed key=%s: %v", fullKey, unmarshalErr)
|
||||
return false
|
||||
}
|
||||
// Backfill L1
|
||||
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
|
||||
// Backfill L1:存反序列化副本而非 target 本身。target 指针归调用方所有,
|
||||
// 若调用方在 GetJSON 返回后就地改写(如定价缓存按时段重评乘数),
|
||||
// 存储别名会与并发 L1 读者的 json.Marshal 构成数据竞争。
|
||||
// 副本失败时退回存 target(此时调用方须自律不改写,与历史行为一致)
|
||||
if cp, cpErr := unmarshalCopy(jsonStr, target); cpErr == nil {
|
||||
gcache.Set(ctx, fullKey, cp, jitterTTL(c.ttl))
|
||||
} else {
|
||||
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -144,6 +153,21 @@ func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// unmarshalCopy 按 target 的具体类型从 JSON 生成一个独立副本(L1 回填用,避免
|
||||
// L1 持有调用方 target 指针的别名)。target 必须是非 nil 指针(调用前已成功
|
||||
// unmarshal 到 target,天然满足)。
|
||||
func unmarshalCopy(jsonStr string, target any) (any, error) {
|
||||
t := reflect.TypeOf(target)
|
||||
if t == nil || t.Kind() != reflect.Pointer {
|
||||
return nil, gerror.New("cache: unmarshalCopy target must be a non-nil pointer")
|
||||
}
|
||||
cp := reflect.New(t.Elem()).Interface()
|
||||
if err := json.Unmarshal([]byte(jsonStr), cp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cp, nil
|
||||
}
|
||||
|
||||
// GetOrSet retrieves a value or calls fn to set it if missing.
|
||||
func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Context) (any, error)) (any, error) {
|
||||
if val, ok := c.Get(ctx, key); ok {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -19,3 +20,57 @@ func TestJitterTTLRange(t *testing.T) {
|
||||
t.Fatalf("jitterTTL(0) = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnmarshalCopyIndependent 副本必须与 target 完全独立:改写副本不得波及 target,
|
||||
// 反之亦然(GetJSON L2 回填后调用方改写 target 不得产生缓存别名竞争)。
|
||||
func TestUnmarshalCopyIndependent(t *testing.T) {
|
||||
type seg struct {
|
||||
Name string `json:"name"`
|
||||
Mult float64 `json:"mult"`
|
||||
}
|
||||
type payload struct {
|
||||
TimeMultiplier float64 `json:"time_multiplier"`
|
||||
TimeRule string `json:"time_rule"`
|
||||
Segs []seg `json:"segs"`
|
||||
}
|
||||
|
||||
target := payload{}
|
||||
jsonStr := `{"time_multiplier":1.5,"time_rule":"忙时","segs":[{"name":"a","mult":2}]}`
|
||||
if err := json.Unmarshal([]byte(jsonStr), &target); err != nil {
|
||||
t.Fatalf("unmarshal target: %v", err)
|
||||
}
|
||||
|
||||
cpAny, err := unmarshalCopy(jsonStr, &target)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshalCopy: %v", err)
|
||||
}
|
||||
cp, ok := cpAny.(*payload)
|
||||
if !ok {
|
||||
t.Fatalf("unmarshalCopy 返回类型 %T, want *payload", cpAny)
|
||||
}
|
||||
|
||||
// 初始值一致
|
||||
if cp.TimeRule != target.TimeRule || cp.TimeMultiplier != target.TimeMultiplier || len(cp.Segs) != len(target.Segs) {
|
||||
t.Fatalf("副本初始值与 target 不一致: %+v vs %+v", cp, &target)
|
||||
}
|
||||
|
||||
// 改写副本不波及 target
|
||||
cp.TimeMultiplier = 0.5
|
||||
cp.TimeRule = "闲时"
|
||||
if target.TimeMultiplier != 1.5 || target.TimeRule != "忙时" {
|
||||
t.Fatalf("改写副本波及了 target: %+v", &target)
|
||||
}
|
||||
|
||||
// 改写 target 不波及副本
|
||||
target.TimeMultiplier = 9.9
|
||||
if cp.TimeMultiplier != 0.5 {
|
||||
t.Fatalf("改写 target 波及了副本: %+v", cp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnmarshalCopyRejectsNonPointer 非 pointer target 必须报错而非 panic。
|
||||
func TestUnmarshalCopyRejectsNonPointer(t *testing.T) {
|
||||
if _, err := unmarshalCopy(`{}`, map[string]any{}); err == nil {
|
||||
t.Fatal("non-pointer target 应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,8 @@ var Registry = []SettingDef{
|
||||
Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true},
|
||||
{Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"},
|
||||
{Key: "pricing_time_timezone", Type: SettingTypeString, Default: "Asia/Shanghai", Category: "general",
|
||||
Label: "计费时区", Description: "模型时段定价(峰谷/促销)按此时区解释,IANA 时区名,如 Asia/Shanghai"},
|
||||
|
||||
// ── OAuth (第三方登录) ──
|
||||
{Key: "oauth_auto_register", Type: SettingTypeBool, Default: "false", Category: "oauth",
|
||||
|
||||
@@ -219,7 +219,7 @@ func handleUnsettledTasks(ctx context.Context) {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
settleResult, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
|
||||
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount,
|
||||
0, 0, pd.BillingContext.Ratios, t.PublicTaskID)
|
||||
0, 0, pd.BillingContext.Ratios, t.PublicTaskID, t.CreatedAt)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "poll: retry settle task %s: %v", t.PublicTaskID, err)
|
||||
} else {
|
||||
@@ -263,7 +263,7 @@ func handleUnsettledSyncImage(ctx context.Context, t *common.AsyncTask) {
|
||||
actualCost = t.PreDeductAmount
|
||||
}
|
||||
settleResult, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
|
||||
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios, t.PublicTaskID)
|
||||
t.ModelName, t.RequestID, actualCost, t.PreDeductAmount, 0, 0, pd.BillingContext.Ratios, t.PublicTaskID, t.CreatedAt)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "poll: retry settle sync_image task %s: %v", t.PublicTaskID, err)
|
||||
return
|
||||
@@ -422,13 +422,13 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
actualCost = billing.NewFromFloat(taskInfo.ActualCost)
|
||||
} 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); err == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios, task.CreatedAt); err == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
actualCost = tokenCost
|
||||
}
|
||||
}
|
||||
|
||||
task.ActualCost = actualCost
|
||||
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios, task.PublicTaskID)
|
||||
settleResult, err = taskBilling.SettleTaskSuccess(ctx, task.TenantID, task.UserID, task.ApiKeyID, task.ChannelID, task.ModelName, task.RequestID, actualCost, task.PreDeductAmount, taskInfo.TotalTokens, taskInfo.CompletionTokens, pd.BillingContext.Ratios, task.PublicTaskID, task.CreatedAt)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "poll: settle task %s: %v", task.PublicTaskID, err)
|
||||
} else {
|
||||
|
||||
@@ -593,7 +593,7 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
|
||||
actualCost := job.PreDeductAmount
|
||||
// 上游返回了 token 用量 → 按真实 token 重算;仅 token 计费模型返回 >0,按次模型返回 0 保持预扣。
|
||||
if totalTokens > 0 {
|
||||
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios, job.SubmitTime); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
actualCost = tokenCost
|
||||
}
|
||||
}
|
||||
@@ -612,9 +612,10 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细)
|
||||
// 2. 结算钱包(传真实 token 用量:驱动 bil_records.output_tokens 与计费快照的 token 明细;
|
||||
// billAt=提交时刻,时段定价按受理时刻计价)
|
||||
settleResult, serr := syncImageBilling.SettleTaskSuccess(ctx, job.TenantID, job.UserID, job.ApiKeyID, sel.ChannelID,
|
||||
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID)
|
||||
job.Model, job.RequestID, actualCost, job.PreDeductAmount, totalTokens, completionTokens, job.Ratios, job.PublicTaskID, job.SubmitTime)
|
||||
if serr != nil {
|
||||
// 保留 billing_settled=false,由未结算兜底网重放结算
|
||||
g.Log().Warningf(ctx, "sync_image: task %s settle success failed (unsettled net will retry): %v", job.PublicTaskID, serr)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
)
|
||||
@@ -232,6 +233,36 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
}
|
||||
}
|
||||
|
||||
// 时段定价批量查询(锚点行 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组装最终列表
|
||||
list := make([]v1.TenantAvailableModelItem, 0, len(models))
|
||||
for _, m := range models {
|
||||
@@ -276,6 +307,9 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
item.PricingTiers = buildTiers(pi.CustomPricingTiers, pi.BaseInputPrice, pi.BaseOutputPrice, baseTiersMap[m.ModelDBID])
|
||||
}
|
||||
|
||||
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], effectiveBillingMode,
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
|
||||
|
||||
list = append(list, item)
|
||||
} else {
|
||||
// group 来源的模型:使用 base 定价,与显式模型展示一致(含按次单价 / 缓存价 / 阶梯明细)
|
||||
@@ -317,6 +351,8 @@ func (s *sTenant) ListAvailableModels(ctx context.Context, req *v1.TenantAvailab
|
||||
if billingMode == "tiered" && ok {
|
||||
item.PricingTiers = buildTiers("", baseInputPrice, baseOutputPrice, baseTiersMap[m.ModelDBID])
|
||||
}
|
||||
item.TimePrices = buildTimePrices(timeSegmentsMap[m.ModelDBID], billingMode,
|
||||
inputPrice, outputPrice, perRequestPrice, item.PricingTiers)
|
||||
list = append(list, item)
|
||||
}
|
||||
}
|
||||
@@ -488,3 +524,63 @@ func buildTiers(customTiersJSON string, baseInputPrice, baseOutputPrice float64,
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
// buildTimePrices 构建时段展示价目:每个时段 = 模型当前有效价 × 时段乘数(后端换算,前端直接渲染)。
|
||||
// token 模式换算输入/输出价;per_request 换算按次价;tiered 用首档价换算(起价,前端标注「起」)。
|
||||
func buildTimePrices(segments []billing.TimeSegment, billingMode string,
|
||||
inputPrice, outputPrice, perRequestPrice *float64, tiers []v1.PricingTierItem) []v1.TimePriceItem {
|
||||
if len(segments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tiered 模式无阶梯数据时无法换算,不展示时段价目(防御)
|
||||
var tierInput, tierOutput *float64
|
||||
if billingMode == "tiered" {
|
||||
if len(tiers) == 0 {
|
||||
return nil
|
||||
}
|
||||
tierInput = &tiers[0].InputPrice
|
||||
tierOutput = &tiers[0].OutputPrice
|
||||
}
|
||||
|
||||
result := make([]v1.TimePriceItem, 0, len(segments))
|
||||
for _, seg := range segments {
|
||||
tp := v1.TimePriceItem{
|
||||
Name: seg.Name,
|
||||
Days: seg.Days,
|
||||
StartTime: seg.StartTime,
|
||||
EndTime: seg.EndTime,
|
||||
ValidFrom: seg.ValidFrom,
|
||||
ValidTo: seg.ValidTo,
|
||||
Multiplier: seg.Multiplier,
|
||||
}
|
||||
switch billingMode {
|
||||
case "per_request":
|
||||
if perRequestPrice != nil {
|
||||
tp.PerRequestPrice = mulDisplayPrice(*perRequestPrice, seg.Multiplier)
|
||||
}
|
||||
case "tiered":
|
||||
if tierInput != nil {
|
||||
tp.InputPrice = mulDisplayPrice(*tierInput, seg.Multiplier)
|
||||
}
|
||||
if tierOutput != nil {
|
||||
tp.OutputPrice = mulDisplayPrice(*tierOutput, seg.Multiplier)
|
||||
}
|
||||
default: // token
|
||||
if inputPrice != nil {
|
||||
tp.InputPrice = mulDisplayPrice(*inputPrice, seg.Multiplier)
|
||||
}
|
||||
if outputPrice != nil {
|
||||
tp.OutputPrice = mulDisplayPrice(*outputPrice, seg.Multiplier)
|
||||
}
|
||||
}
|
||||
result = append(result, tp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// mulDisplayPrice 展示价换算:单价 × 时段乘数(decimal 单步乘法,出口转 float64 供前端渲染)
|
||||
func mulDisplayPrice(price, multiplier float64) *float64 {
|
||||
v := billing.InexactFloat64(billing.NewFromFloat(price).Mul(billing.NewFromFloat(multiplier)))
|
||||
return &v
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type BilUsageDaily struct {
|
||||
ChannelId any // 渠道ID(0=无渠道)
|
||||
Status any // 请求状态:success/error/timeout/cancelled
|
||||
RequestCount any // 请求数(COUNT(*))
|
||||
InputTokens any // 输入Token合计
|
||||
InputTokens any // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
|
||||
OutputTokens any // 输出Token合计
|
||||
TotalCost any // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
|
||||
AccountCost any // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
|
||||
@@ -28,7 +28,7 @@ type BilUsageDaily struct {
|
||||
SumFirstTokenMs any // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
CacheCreationTokens any // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
|
||||
CacheCreationTokens any // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
|
||||
CacheReadTokens any // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
|
||||
CacheHitRequestCount any // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type BilUsageLogs struct {
|
||||
ModelName any // 调用的模型名
|
||||
RequestId any // 请求唯一ID
|
||||
RelayMode any // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens any // 输入 token 数
|
||||
InputTokens any // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
|
||||
OutputTokens any // 输出 token 数
|
||||
TotalCost any // 本次调用费用
|
||||
Currency any // 货币(USD)
|
||||
@@ -30,7 +30,7 @@ type BilUsageLogs struct {
|
||||
ClientIp any // 客户端 IP
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CacheCreationTokens any // 缓存创建 token 数 (Claude)
|
||||
CacheCreationTokens any // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
|
||||
CacheReadTokens any // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost any // 输入 token 费用
|
||||
OutputCost any // 输出 token 费用
|
||||
|
||||
@@ -25,4 +25,5 @@ type MdlPricing struct {
|
||||
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 表示跨零点
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type BilUsageDaily struct {
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"渠道ID(0=无渠道)"` // 渠道ID(0=无渠道)
|
||||
Status string `json:"status" orm:"status" description:"请求状态:success/error/timeout/cancelled"` // 请求状态:success/error/timeout/cancelled
|
||||
RequestCount int `json:"request_count" orm:"request_count" description:"请求数(COUNT(*))"` // 请求数(COUNT(*))
|
||||
InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计"` // 输入Token合计
|
||||
InputTokens int64 `json:"input_tokens" orm:"input_tokens" description:"输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)"` // 输入Token合计(含缓存总输入,源自 bil_usage_logs.input_tokens)
|
||||
OutputTokens int64 `json:"output_tokens" orm:"output_tokens" description:"输出Token合计"` // 输出Token合计
|
||||
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"客户侧成本合计(USD,源自 bil_usage_logs.total_cost)"` // 客户侧成本合计(USD,源自 bil_usage_logs.total_cost)
|
||||
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)"` // 上游账户成本合计(USD,源自 bil_usage_logs.account_cost,用于利润分析)
|
||||
@@ -27,7 +27,7 @@ type BilUsageDaily struct {
|
||||
SumFirstTokenMs int64 `json:"sum_first_token_ms" orm:"sum_first_token_ms" description:"首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)"` // 首Token延迟合计(ms,源自 bil_usage_logs.first_token_ms;视图按 SUM/COUNT 求均值)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数(Claude cache_creation,SUM 聚合)"` // 缓存创建 token 数(Claude cache_creation,SUM 聚合)
|
||||
CacheCreationTokens int64 `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)"` // 写入缓存的 token 数(Claude cache_creation / OpenAI cache_write,SUM 聚合)
|
||||
CacheReadTokens int64 `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)"` // 缓存命中读取 token 数(Claude cache_read / OpenAI cached_tokens,SUM 聚合)
|
||||
CacheHitRequestCount int64 `json:"cache_hit_request_count" orm:"cache_hit_request_count" description:"命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行)
|
||||
}
|
||||
@@ -11,61 +11,61 @@ import (
|
||||
|
||||
// BilUsageLogs is the golang structure for table bil_usage_logs.
|
||||
type BilUsageLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost decimal.Decimal `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost decimal.Decimal `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier decimal.Decimal `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount decimal.Decimal `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount decimal.Decimal `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
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,普通请求为空
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)"` // 输入 token 数(含缓存总输入:base + cache_read + cache_creation,跨渠道统一口径)
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)"` // 写入缓存的 token 数(Claude cache_creation_input_tokens / OpenAI Responses cache_write_tokens)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost decimal.Decimal `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost decimal.Decimal `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier decimal.Decimal `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount decimal.Decimal `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount decimal.Decimal `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
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,普通请求为空
|
||||
}
|
||||
@@ -11,16 +11,17 @@ import (
|
||||
|
||||
// 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 价格(直接定价)
|
||||
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 表示跨零点
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
-- +goose Up
|
||||
-- 模型时段定价:mdl_pricing 增加时段定价 JSONB 列(峰谷定价/工作时段/限时促销)
|
||||
ALTER TABLE mdl_pricing
|
||||
ADD COLUMN IF NOT EXISTS time_segments JSONB;
|
||||
|
||||
COMMENT ON COLUMN mdl_pricing.time_segments IS '时段定价(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 表示跨零点';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE mdl_pricing
|
||||
DROP COLUMN IF EXISTS time_segments;
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrModelPricingNotConfigured 模型未配置定价(fail-closed 哨兵错误)。
|
||||
@@ -17,8 +18,8 @@ type BillingProvider interface {
|
||||
// 检查余额 → 冻结预扣金额 → 返回是否成功
|
||||
PreDeduct(ctx context.Context, tenantID int64, modelName string, inputTokens, maxTokens int, isStream bool, requestID string) (preDeductAmount float64, err error)
|
||||
|
||||
// Settle 结算费用(成功请求)
|
||||
Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error)
|
||||
// Settle 结算费用(成功请求)。billAt 为请求受理时刻(时段定价按该时刻评估)
|
||||
Settle(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error)
|
||||
|
||||
// SettleWithUsage 完整 Usage 结算(含 cache token + 计费快照)
|
||||
SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID int64,
|
||||
@@ -28,8 +29,8 @@ type BillingProvider interface {
|
||||
// SettleFailed 失败请求结算(退还预扣)
|
||||
SettleFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error
|
||||
|
||||
// SettleStreamInterrupted 流式中断结算
|
||||
SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64) (*SettlementResult, error)
|
||||
// SettleStreamInterrupted 流式中断结算。billAt 为请求受理时刻(时段定价按该时刻评估)
|
||||
SettleStreamInterrupted(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID, relayMode string, usage *Usage, preDeductAmount float64, projectID int64, billAt time.Time) (*SettlementResult, error)
|
||||
|
||||
// CheckRateLimit QPS 限流检查
|
||||
CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (allowed bool, limitLevel string, limit int, remaining int, resetAt int64)
|
||||
|
||||
@@ -2,6 +2,7 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -27,8 +28,9 @@ type TaskBillingProvider interface {
|
||||
// CheckApiKeyQuota 检查 API Key 额度是否足够
|
||||
CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount decimal.Decimal) error
|
||||
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)
|
||||
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) (*SettlementResult, error)
|
||||
// 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)
|
||||
|
||||
// SettleTaskFailed 任务失败退还预扣
|
||||
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error
|
||||
@@ -39,8 +41,8 @@ type TaskBillingProvider interface {
|
||||
// AdjustTaskBilling 调整预扣金额(提交后上游确认了新参数)
|
||||
AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost decimal.Decimal) (decimal.Decimal, error)
|
||||
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用(billAt 为任务受理时刻,时段定价用)
|
||||
// totalTokens: 上游返回的 token 计费单位
|
||||
// ratios: 提交时保存的计费比率(如 video_input 折扣)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64, billAt time.Time) (decimal.Decimal, error)
|
||||
}
|
||||
@@ -321,14 +321,14 @@ func HandleRealtime(w http.ResponseWriter, r *http.Request, rc *RealtimeContext,
|
||||
streamUsage = &common.Usage{}
|
||||
}
|
||||
settleResult, _ := billing.SettleStreamInterrupted(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||||
modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID)
|
||||
modelName, rc.RequestID, "realtime", streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
|
||||
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
|
||||
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||||
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
|
||||
}
|
||||
} else if usage != nil {
|
||||
settleResult, _ := billing.Settle(ctx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||||
modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID)
|
||||
modelName, rc.RequestID, "realtime", usage, preDeductAmount, rc.ProjectID, info.StartTime)
|
||||
if settleResult != nil && settleResult.ActualCost > 0 && !settleResult.DuplicateSkip {
|
||||
billing.IncrMemberQuotaUsed(ctx, rc.TenantID, rc.UserID, settleResult.ActualCost)
|
||||
billing.IncrApiKeyQuotaUsed(ctx, rc.ApiKeyID, settleResult.ActualCost)
|
||||
|
||||
@@ -678,7 +678,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
}
|
||||
if billing != nil && preDeductAmount > 0 {
|
||||
settleResult, err := billing.SettleStreamInterrupted(settleCtx, rc.TenantID, rc.UserID, rc.ApiKeyID, selection.ChannelID,
|
||||
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID)
|
||||
v.modelName, rc.RequestID, v.relayModeStr, streamUsage, preDeductAmount, rc.ProjectID, info.StartTime)
|
||||
if err != nil {
|
||||
g.Log().Warningf(settleCtx, "[RelayHandler] Stream interrupted settlement failed: tenant=%d project=%d request=%s err=%v",
|
||||
rc.TenantID, rc.ProjectID, rc.RequestID, err)
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
modelId: number | null
|
||||
modelIdStr: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
// ============================================================
|
||||
// Pricing Editor State
|
||||
// ============================================================
|
||||
const editorLoading = ref(false)
|
||||
const editorSaving = ref(false)
|
||||
const editorBillingMode = ref('token')
|
||||
const editorItems = reactive<any[]>([])
|
||||
|
||||
const billingModeOptions = [
|
||||
{ label: '按量计费 (Token)', value: 'token' },
|
||||
{ label: '按次计费', value: 'per_request' },
|
||||
{ label: '阶梯计费', value: 'tiered' },
|
||||
]
|
||||
|
||||
function createEmptyItem(mode: string) {
|
||||
return {
|
||||
billing_mode: mode,
|
||||
min_tokens: 0,
|
||||
max_tokens: null,
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
per_request_price: null,
|
||||
cache_read_price: 0,
|
||||
cache_creation_price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditorDefaults() {
|
||||
editorBillingMode.value = 'token'
|
||||
editorItems.length = 0
|
||||
editorItems.push(createEmptyItem('token'))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Official Pricing Fetch
|
||||
// ============================================================
|
||||
const officialLoading = ref(false)
|
||||
const officialData = ref<any>(null)
|
||||
const officialError = ref('')
|
||||
|
||||
function resetOfficialData() {
|
||||
officialData.value = null
|
||||
officialError.value = ''
|
||||
}
|
||||
|
||||
async function fetchOfficialPricing() {
|
||||
if (!props.modelId) return
|
||||
officialLoading.value = true
|
||||
officialError.value = ''
|
||||
officialData.value = null
|
||||
try {
|
||||
const res: any = await request.get(`/admin/models/${props.modelId}/official-pricing`)
|
||||
officialData.value = res.data?.data
|
||||
} catch {
|
||||
officialError.value = '获取官方定价失败'
|
||||
} finally {
|
||||
officialLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyOfficialPricing(pricing: any) {
|
||||
if (!pricing) return
|
||||
if (editorItems.length > 0) {
|
||||
editorItems[0].input_price = pricing.input_price
|
||||
editorItems[0].output_price = pricing.output_price
|
||||
editorItems[0].cache_read_price = pricing.cache_read_price
|
||||
editorItems[0].cache_creation_price = pricing.cache_creation_price
|
||||
}
|
||||
if (pricing.billing_mode && pricing.billing_mode !== editorBillingMode.value) {
|
||||
editorBillingMode.value = pricing.billing_mode
|
||||
}
|
||||
Message.success('已填入官方参考定价')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Load Pricing Data
|
||||
// ============================================================
|
||||
async function loadPricing() {
|
||||
if (!props.modelId) return
|
||||
editorItems.length = 0
|
||||
editorBillingMode.value = 'token'
|
||||
resetOfficialData()
|
||||
editorLoading.value = true
|
||||
|
||||
try {
|
||||
const res: any = await request.get(`/admin/models/${props.modelId}/pricing`)
|
||||
const list: any[] = res.data?.data?.list || []
|
||||
if (list.length > 0) {
|
||||
editorBillingMode.value = list[0].billing_mode || 'token'
|
||||
editorItems.length = 0
|
||||
for (const item of list) {
|
||||
editorItems.push({
|
||||
billing_mode: item.billing_mode,
|
||||
min_tokens: item.min_tokens || 0,
|
||||
max_tokens: item.max_tokens ?? null,
|
||||
input_price: item.input_price ?? 0,
|
||||
output_price: item.output_price ?? 0,
|
||||
per_request_price: item.per_request_price ?? null,
|
||||
cache_read_price: item.cache_read_price ?? 0,
|
||||
cache_creation_price: item.cache_creation_price ?? 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resetEditorDefaults()
|
||||
}
|
||||
} catch {
|
||||
resetEditorDefaults()
|
||||
} finally {
|
||||
editorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tiered Mode Helpers
|
||||
// ============================================================
|
||||
watch(editorBillingMode, (mode) => {
|
||||
if (mode === 'tiered') {
|
||||
if (editorItems.length === 0) {
|
||||
editorItems.push(createEmptyItem('tiered'))
|
||||
}
|
||||
} else {
|
||||
if (editorItems.length === 0) {
|
||||
editorItems.push(createEmptyItem(mode))
|
||||
} else {
|
||||
editorItems[0].billing_mode = mode
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function addTier() {
|
||||
const last = editorItems[editorItems.length - 1]
|
||||
const newMin = last?.max_tokens ?? 0
|
||||
editorItems.push(createEmptyItem('tiered'))
|
||||
const newItem = editorItems[editorItems.length - 1]
|
||||
newItem.min_tokens = newMin
|
||||
if (last && last.max_tokens === null) {
|
||||
last.max_tokens = newMin
|
||||
}
|
||||
}
|
||||
|
||||
function removeTier(index: number) {
|
||||
editorItems.splice(index, 1)
|
||||
if (editorItems.length > 0) {
|
||||
editorItems[editorItems.length - 1].max_tokens = null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Save
|
||||
// ============================================================
|
||||
async function savePricing() {
|
||||
if (!props.modelId) return
|
||||
editorSaving.value = true
|
||||
try {
|
||||
const items = editorItems.map((item, index) => ({
|
||||
billing_mode: editorBillingMode.value,
|
||||
min_tokens: editorBillingMode.value === 'tiered' ? item.min_tokens : 0,
|
||||
max_tokens: editorBillingMode.value === 'tiered' ? (index < editorItems.length - 1 ? item.max_tokens : null) : null,
|
||||
input_price: item.input_price,
|
||||
output_price: item.output_price,
|
||||
per_request_price: editorBillingMode.value === 'per_request' ? (item.per_request_price ?? null) : null,
|
||||
cache_read_price: item.cache_read_price,
|
||||
cache_creation_price: item.cache_creation_price,
|
||||
}))
|
||||
await request.put(`/admin/models/${props.modelId}/pricing`, { items })
|
||||
Message.success('定价已保存')
|
||||
emit('update:visible', false)
|
||||
emit('saved')
|
||||
} catch {
|
||||
// error handled by interceptor
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Watchers
|
||||
// ============================================================
|
||||
// 打开时自动加载定价数据
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val && props.modelId) {
|
||||
loadPricing()
|
||||
}
|
||||
})
|
||||
|
||||
// 关闭时重置状态
|
||||
watch(() => props.visible, (val) => {
|
||||
if (!val) {
|
||||
resetEditorDefaults()
|
||||
resetOfficialData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ADrawer
|
||||
:visible="visible"
|
||||
:width="580"
|
||||
:title="modelId ? `定价设置 - ${modelIdStr}` : '定价设置'"
|
||||
:mask-closable="false"
|
||||
:footer="true"
|
||||
@cancel="emit('update:visible', false)"
|
||||
>
|
||||
<template #footer>
|
||||
<AButton @click="emit('update:visible', false)">取消</AButton>
|
||||
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
|
||||
</template>
|
||||
<ASpin :loading="editorLoading" style="width: 100%">
|
||||
<AForm :model="{}" layout="vertical">
|
||||
<div class="pricing-editor">
|
||||
<!-- Official Pricing Reference -->
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>官方参考定价</h3>
|
||||
<span class="section-hint">数据来源: LiteLLM + models.dev</span>
|
||||
</div>
|
||||
<AButton
|
||||
type="outline"
|
||||
size="small"
|
||||
:loading="officialLoading"
|
||||
@click="fetchOfficialPricing"
|
||||
>拉取官方定价</AButton>
|
||||
|
||||
<div v-if="officialData" class="official-pricing-card mt-3">
|
||||
<div v-for="src in officialData.sources" :key="src.source" class="official-source-block">
|
||||
<div class="official-source-header">
|
||||
<ATag size="small" :color="src.source === 'litellm' ? 'arcoblue' : 'green'">{{ src.source === 'litellm' ? 'LiteLLM' : 'models.dev' }}</ATag>
|
||||
<template v-if="src.found && src.pricing">
|
||||
<ATag v-if="src.provider" size="small" color="orangered">{{ src.provider }}</ATag>
|
||||
<ATag v-if="src.mode" size="small">{{ src.mode }}</ATag>
|
||||
<span v-if="src.max_context_tokens" class="official-meta-text">上下文: {{ (src.max_context_tokens / 1000).toFixed(0) }}K</span>
|
||||
<span v-if="src.max_output_tokens" class="official-meta-text">输出上限: {{ (src.max_output_tokens / 1000).toFixed(0) }}K</span>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="src.found && src.pricing">
|
||||
<div class="official-prices mt-2">
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输入价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.input_price.toFixed(2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">输出价格</span>
|
||||
<span class="official-price-value">${{ src.pricing.output_price.toFixed(2) }} / 1M</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存读取</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_read_price ? '$' + src.pricing.cache_read_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
<div class="official-price-row">
|
||||
<span class="official-price-label">缓存创建</span>
|
||||
<span class="official-price-value">{{ src.pricing.cache_creation_price ? '$' + src.pricing.cache_creation_price.toFixed(2) + ' / 1M' : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="primary" size="small" class="mt-2" @click="applyOfficialPricing(src.pricing)">应用此价格</AButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="src.error" class="official-not-found" style="color: rgb(var(--danger-6))">获取远程数据失败: {{ src.error }}</div>
|
||||
<div v-else class="official-not-found">未找到定价数据</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="officialError" class="official-error mt-2">{{ officialError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Mode -->
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>计费模式</h3>
|
||||
</div>
|
||||
<ARadioGroup v-model="editorBillingMode" type="button">
|
||||
<ARadio v-for="opt in billingModeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</ARadio>
|
||||
</ARadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- Per-request Mode -->
|
||||
<template v-if="editorBillingMode === 'per_request' && editorItems.length > 0">
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>按次定价</h3>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<AFormItem label="按次单价" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="editorItems[0].per_request_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="每次调用价格"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 次</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Token Mode -->
|
||||
<template v-else-if="editorBillingMode === 'token' && editorItems.length > 0">
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>按量定价</h3>
|
||||
<span class="section-hint">价格单位为 $ / 1M Token</span>
|
||||
</div>
|
||||
<div class="form-row form-row--two">
|
||||
<AFormItem label="输入价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="editorItems[0].input_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="editorItems[0].output_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="form-row form-row--two mt-3">
|
||||
<AFormItem label="缓存读取价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="editorItems[0].cache_read_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="editorItems[0].cache_creation_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$ / 1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tiered Mode -->
|
||||
<template v-else>
|
||||
<div class="editor-section">
|
||||
<div class="editor-section-header">
|
||||
<h3>阶梯定价</h3>
|
||||
<span class="section-hint">按 Token 用量分段设置不同价格</span>
|
||||
</div>
|
||||
<div v-for="(tier, index) in editorItems" :key="index" class="tier-card">
|
||||
<div class="tier-header">
|
||||
<span class="tier-label">第 {{ index + 1 }} 梯</span>
|
||||
<AButton
|
||||
v-if="editorItems.length > 1"
|
||||
size="mini"
|
||||
status="danger"
|
||||
@click="removeTier(index)"
|
||||
>删除</AButton>
|
||||
</div>
|
||||
<div class="form-row form-row--two">
|
||||
<AFormItem label="起始 Token" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="tier.min_tokens"
|
||||
:min="0"
|
||||
:step="1000"
|
||||
placeholder="0"
|
||||
class="w-full"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="结束 Token" class="flex-1">
|
||||
<AInputNumber
|
||||
v-if="index < editorItems.length - 1"
|
||||
v-model="tier.max_tokens"
|
||||
:min="0"
|
||||
:step="1000"
|
||||
placeholder="上限"
|
||||
class="w-full"
|
||||
/>
|
||||
<AInput v-else model-value="无上限" disabled class="w-full" />
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="form-row form-row--two mt-2">
|
||||
<AFormItem label="输入价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="tier.input_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="输出价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="tier.output_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
<div class="form-row form-row--two mt-2">
|
||||
<AFormItem label="缓存读取价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="tier.cache_read_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
<AFormItem label="缓存创建价格" class="flex-1">
|
||||
<AInputNumber
|
||||
v-model="tier.cache_creation_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
class="w-full"
|
||||
>
|
||||
<template #suffix>$/1M</template>
|
||||
</AInputNumber>
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
<AButton type="dashed" long @click="addTier" class="mt-3">+ 添加梯度</AButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AForm>
|
||||
</ASpin>
|
||||
</ADrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.editor-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.editor-section-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ta-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-tertiary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-row--two {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tier-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tier-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ta-text-secondary);
|
||||
}
|
||||
|
||||
.official-pricing-card {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ta-border-light);
|
||||
}
|
||||
|
||||
.official-source-block {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed var(--ta-border-light);
|
||||
}
|
||||
|
||||
.official-source-block:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.official-source-block:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.official-source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.official-meta-text {
|
||||
font-size: 12px;
|
||||
color: var(--ta-text-tertiary);
|
||||
}
|
||||
|
||||
.official-price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.official-price-label {
|
||||
font-size: 13px;
|
||||
color: var(--ta-text-secondary);
|
||||
}
|
||||
|
||||
.official-price-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ta-text-primary);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.official-not-found {
|
||||
font-size: 13px;
|
||||
color: var(--ta-text-tertiary);
|
||||
}
|
||||
|
||||
.official-error {
|
||||
font-size: 13px;
|
||||
color: rgb(var(--danger-6));
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ import {
|
||||
import type { TableColumnData, FormInstance } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import TableStats from '@/components/TableStats.vue'
|
||||
import ModelPricingDrawer from '@/components/ModelPricingDrawer.vue'
|
||||
import ModelPricingModal from '@/components/ModelPricingModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
import ResponsiveTable from '@/components/ResponsiveTable.vue'
|
||||
@@ -914,8 +914,8 @@ function resetImport() {
|
||||
</div>
|
||||
</AModal>
|
||||
|
||||
<!-- Pricing Drawer -->
|
||||
<ModelPricingDrawer
|
||||
<!-- Pricing Modal -->
|
||||
<ModelPricingModal
|
||||
v-model:visible="pricingDrawerVisible"
|
||||
:model-id="pricingDrawerModelId"
|
||||
:model-id-str="pricingDrawerModelIdStr"
|
||||
|
||||
@@ -27,6 +27,9 @@ function handleMaintenanceModeChange(enabled: boolean) {
|
||||
<AFormItem label="租户控制台地址" help="用于生成邀请链接等,如 https://console.example.com" class="field-full">
|
||||
<AInput v-model="values['tenant_console_url']" placeholder="https://console.example.com" />
|
||||
</AFormItem>
|
||||
<AFormItem label="计费时区" help="模型时段定价(峰谷/促销)按此时区解释,IANA 时区名" class="field-full">
|
||||
<AInput v-model="values['pricing_time_timezone']" placeholder="Asia/Shanghai" />
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,6 +18,19 @@ interface PricingTierItem {
|
||||
output_price: number
|
||||
}
|
||||
|
||||
interface TimePriceItem {
|
||||
name: string
|
||||
days?: number[] | null
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
valid_from?: string
|
||||
valid_to?: string
|
||||
multiplier: number
|
||||
input_price?: number | null
|
||||
output_price?: number | null
|
||||
per_request_price?: number | null
|
||||
}
|
||||
|
||||
interface ModelItem {
|
||||
id: number
|
||||
model_id: string
|
||||
@@ -37,6 +50,7 @@ interface ModelItem {
|
||||
cache_read_price: number | null
|
||||
cache_creation_price: number | null
|
||||
pricing_tiers: PricingTierItem[]
|
||||
time_prices?: TimePriceItem[] | null
|
||||
}
|
||||
|
||||
const capabilityLabel: Record<string, string> = {
|
||||
@@ -182,6 +196,31 @@ function getTieredStartPrice(m: ModelItem): string {
|
||||
return formatPrice(first.output_price)
|
||||
}
|
||||
|
||||
// ── 时段价目展示辅助(价格为后端换算好的展示价,前端直接渲染) ──
|
||||
|
||||
function daysLabel(days?: number[] | null): string {
|
||||
if (!days || days.length === 0) return '每天'
|
||||
const sorted = [...days].sort((a, b) => a - b)
|
||||
if (sorted.join(',') === '1,2,3,4,5') return '工作日'
|
||||
if (sorted.join(',') === '6,7') return '周末'
|
||||
const names = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']
|
||||
return sorted.map((d) => names[d] || String(d)).join('、')
|
||||
}
|
||||
|
||||
function timeWindow(tp: TimePriceItem): string {
|
||||
const parts: string[] = [daysLabel(tp.days)]
|
||||
if (tp.start_time && tp.end_time) parts.push(`${tp.start_time}~${tp.end_time}`)
|
||||
else parts.push('全天')
|
||||
if (tp.valid_from) parts.push(tp.valid_to ? `${tp.valid_from}~${tp.valid_to}` : `${tp.valid_from} 起`)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function timePriceText(m: ModelItem, tp: TimePriceItem): string {
|
||||
if (m.billing_mode === 'per_request') return `${formatPrice(tp.per_request_price ?? null)} /次`
|
||||
if (m.billing_mode === 'tiered') return `${formatPrice(tp.input_price ?? null)} 起`
|
||||
return `输入 ${formatPrice(tp.input_price ?? null)} · 输出 ${formatPrice(tp.output_price ?? null)}`
|
||||
}
|
||||
|
||||
async function fetchModels() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -401,6 +440,21 @@ onMounted(fetchModels)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 时段价目(平台配置了时段定价时展示,价格为换算后的展示价) -->
|
||||
<div v-if="m.time_prices?.length" class="mt-2 space-y-1 border-t border-dashed border-gray-100 pt-2">
|
||||
<div
|
||||
v-for="(tp, idx) in m.time_prices"
|
||||
:key="idx"
|
||||
class="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span class="text-gray-500 truncate">
|
||||
{{ tp.name }}
|
||||
<span class="text-gray-400 ml-1">{{ timeWindow(tp) }}</span>
|
||||
</span>
|
||||
<span class="shrink-0 text-gray-600">{{ timePriceText(m, tp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user