diff --git a/CLAUDE.md b/CLAUDE.md index c75284c..21c5865 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 物化(强约束) diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 5fd9a07..60bf6cf 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -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;end0 计 1,含失败状态行) } diff --git a/internal/dao/internal/bil_usage_logs.go b/internal/dao/internal/bil_usage_logs.go index 3c5cfdb..8b5b155 100644 --- a/internal/dao/internal/bil_usage_logs.go +++ b/internal/dao/internal/bil_usage_logs.go @@ -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 费用 diff --git a/internal/dao/internal/mdl_pricing.go b/internal/dao/internal/mdl_pricing.go index 7cec603..5467d00 100644 --- a/internal/dao/internal/mdl_pricing.go +++ b/internal/dao/internal/mdl_pricing.go @@ -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 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 { diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index e8106cf..0f95085 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -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++ } } diff --git a/internal/logic/billing/e2e_time_segment_test.go b/internal/logic/billing/e2e_time_segment_test.go new file mode 100644 index 0000000..a0b3138 --- /dev/null +++ b/internal/logic/billing/e2e_time_segment_test.go @@ -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) +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index bf89c6b..948d21f 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -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 diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index e9ebeaf..71b90e8 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -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) { diff --git a/internal/logic/billing/provider.go b/internal/logic/billing/provider.go index ce670ac..0f92268 100644 --- a/internal/logic/billing/provider.go +++ b/internal/logic/billing/provider.go @@ -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 } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 187dd0b..ce24fd4 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -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) diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 38c5646..214dea0 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -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)) } // 结算信息 diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index fd50ffe..7e6640e 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -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) + } +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 27fd7bc..28a0b2c 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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 数中,不应再乘 diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 2c3e3dd..50d401e 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -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{ diff --git a/internal/logic/billing/time_pricing.go b/internal/logic/billing/time_pricing.go new file mode 100644 index 0000000..6572df3 --- /dev/null +++ b/internal/logic/billing/time_pricing.go @@ -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 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 +} diff --git a/internal/logic/billing/time_pricing_test.go b/internal/logic/billing/time_pricing_test.go new file mode 100644 index 0000000..74375ca --- /dev/null +++ b/internal/logic/billing/time_pricing_test.go @@ -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) + } +} diff --git a/internal/logic/common/cache.go b/internal/logic/common/cache.go index 40b7dd8..699e0ff 100644 --- a/internal/logic/common/cache.go +++ b/internal/logic/common/cache.go @@ -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 { diff --git a/internal/logic/common/cache_test.go b/internal/logic/common/cache_test.go index 9e13419..f034775 100644 --- a/internal/logic/common/cache_test.go +++ b/internal/logic/common/cache_test.go @@ -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 应返回错误") + } +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index 673c249..beaad81 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -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", diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index ca04824..1cb0864 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -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 { diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index e2a7a95..155e7de 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -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) diff --git a/internal/logic/tenant/model.go b/internal/logic/tenant/model.go index b59f29e..4f569fb 100644 --- a/internal/logic/tenant/model.go +++ b/internal/logic/tenant/model.go @@ -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 +} diff --git a/internal/model/do/bil_usage_daily.go b/internal/model/do/bil_usage_daily.go index 09720bd..9dbbc59 100644 --- a/internal/model/do/bil_usage_daily.go +++ b/internal/model/do/bil_usage_daily.go @@ -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,含失败状态行) } diff --git a/internal/model/do/bil_usage_logs.go b/internal/model/do/bil_usage_logs.go index d45a98b..84240bc 100644 --- a/internal/model/do/bil_usage_logs.go +++ b/internal/model/do/bil_usage_logs.go @@ -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 费用 diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 5e2c67e..c4ef2e0 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -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=周日 空=每天,end0 计 1,含失败状态行)"` // 命中缓存的请求数(明细 cache_read_tokens>0 计 1,含失败状态行) } diff --git a/internal/model/entity/bil_usage_logs.go b/internal/model/entity/bil_usage_logs.go index a84e811..9ae55e1 100644 --- a/internal/model/entity/bil_usage_logs.go +++ b/internal/model/entity/bil_usage_logs.go @@ -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,普通请求为空 } diff --git a/internal/model/entity/mdl_pricing.go b/internal/model/entity/mdl_pricing.go index 8d662d8..a89e557 100644 --- a/internal/model/entity/mdl_pricing.go +++ b/internal/model/entity/mdl_pricing.go @@ -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 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) diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index 4198d1d..8a287e1 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -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) diff --git a/web/admin/src/components/ModelPricingDrawer.vue b/web/admin/src/components/ModelPricingDrawer.vue deleted file mode 100644 index 53dd53e..0000000 --- a/web/admin/src/components/ModelPricingDrawer.vue +++ /dev/null @@ -1,578 +0,0 @@ - - - - - diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue new file mode 100644 index 0000000..9b6efd6 --- /dev/null +++ b/web/admin/src/components/ModelPricingModal.vue @@ -0,0 +1,1029 @@ + + + + + diff --git a/web/admin/src/views/ai/ModelsPage.vue b/web/admin/src/views/ai/ModelsPage.vue index 6e68f9b..2e40753 100644 --- a/web/admin/src/views/ai/ModelsPage.vue +++ b/web/admin/src/views/ai/ModelsPage.vue @@ -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() { - - + + + + diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index c00469d..a57155e 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -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 = { @@ -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) + + +
+
+ + {{ tp.name }} + {{ timeWindow(tp) }} + + {{ timePriceText(m, tp) }} +
+