feat(model):模型定价分时功能

This commit is contained in:
qian committed 2026-08-20 01:03:47 +08:00
1 parent 330d02f19d
commit add95ca5ab
41 files changed
+2481 -817

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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 导入结果响应
+17
View File
@@ -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"`
+2 -2
View File
@@ -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,含失败状态行)
}
+2 -2
View File
@@ -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 费用
+2
View File
@@ -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.
+106 -2
View File
@@ -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 {
+33 -13
View File
@@ -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)
}
+121 -48
View File
@@ -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
+71 -2
View File
@@ -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) {
+5 -4
View File
@@ -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
}
+34 -16
View File
@@ -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)
+23 -5
View File
@@ -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))
}
// 结算信息
+51
View File
@@ -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)
}
}
+24 -16
View File
@@ -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{
+202
View File
@@ -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
}
+211
View File
@@ -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)
}
}
+26 -2
View File
@@ -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 {
+55
View File
@@ -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",
+4 -4
View File
@@ -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 {
+4 -3
View File
@@ -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)
+96
View File
@@ -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
}
+2 -2
View File
@@ -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,含失败状态行)
}
+2 -2
View File
@@ -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 费用
+1
View File
@@ -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 表示跨零点
}
+2 -2
View File
@@ -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,含失败状态行)
}
+57 -57
View File
@@ -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,普通请求为空
}
+13 -12
View File
@@ -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;
+5 -4
View File
@@ -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)
+6 -4
View File
@@ -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)
}
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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>