diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 4ea24a6a20..551b8cc99f 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -46,6 +46,19 @@ type modelPlazaOfficialPricing struct { Intervals []userPricingIntervalDTO `json:"intervals,omitempty"` } +// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。 +type modelPlazaTimePricingPeriod struct { + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + Multiplier float64 `json:"multiplier"` +} + +// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。 +type modelPlazaTimePricing struct { + Timezone string `json:"timezone"` + Periods []modelPlazaTimePricingPeriod `json:"periods"` +} + // modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。 type modelPlazaModel struct { Name string `json:"name"` @@ -54,6 +67,8 @@ type modelPlazaModel struct { OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"` // LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。 LongContextBasis string `json:"long_context_basis,omitempty"` + // TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。 + TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"` } // modelPlazaGroup 广场分组条目(白名单字段)。 @@ -172,6 +187,7 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo Pricing: toUserPricing(m.Pricing), OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), LongContextBasis: string(m.LongContextBasis), + TimePricing: toModelPlazaTimePricing(m.TimePricing), }) } dto := modelPlazaGroup{ @@ -197,6 +213,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo return dto } +// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。 +func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing { + if p == nil || len(p.Periods) == 0 { + return nil + } + periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods)) + for _, period := range p.Periods { + periods = append(periods, modelPlazaTimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: period.Multiplier, + }) + } + return &modelPlazaTimePricing{Timezone: p.Timezone, Periods: periods} +} + // toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。 func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing { if p == nil { diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 97fee63b74..724ca19030 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -113,6 +113,8 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) { require.False(t, hasOfficialIntervals, "官方无阶梯时 intervals 应 omitempty") _, hasBasis := model["long_context_basis"] require.False(t, hasBasis, "单档模型不输出 long_context_basis") + _, hasTimePricing := model["time_pricing"] + require.False(t, hasTimePricing, "无分时时不输出 time_pricing") // 无专属倍率:user_rate_multiplier 整个字段省略 dtoNoRate := toModelPlazaGroupDTO(&g, nil) @@ -179,3 +181,30 @@ func TestToModelPlazaGroupDTO_LongContextTiersAndBasis(t *testing.T) { } func testPtr(v float64) *float64 { return &v } + +func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { + g := service.PlazaGroup{ + ID: 4, Name: "cn", Platform: "deepseek", SubscriptionType: "standard", RateMultiplier: 1, + Models: []service.PlazaModel{{ + Name: "deepseek-chat", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.28e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + } + raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) + require.NoError(t, err) + var decoded map[string]any + require.NoError(t, json.Unmarshal(raw, &decoded)) + model := decoded["models"].([]any)[0].(map[string]any) + tp := model["time_pricing"].(map[string]any) + require.Equal(t, "Asia/Shanghai", tp["timezone"]) + periods := tp["periods"].([]any) + require.Len(t, periods, 1) + first := periods[0].(map[string]any) + require.Equal(t, "00:30", first["start_time"]) + require.Equal(t, "08:30", first["end_time"]) + require.InDelta(t, 0.5, first["multiplier"].(float64), 1e-12) +} diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index ea4869d9ca..8a43283f20 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -6,6 +6,7 @@ import ( "math" "sort" "strconv" + "time" ) // ContextPricingBasis 阶梯的计价基准。 @@ -30,11 +31,26 @@ type ContextPricingTier struct { CacheRead *float64 } +// TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。 +type TimePricingPeriod struct { + StartTime string + EndTime string + Multiplier float64 +} + +// TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。 +type TimePricingSchedule struct { + Timezone string + Periods []TimePricingPeriod +} + // ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。 // 单价由真实计费函数探针得出,与扣费同源;单档表示无阶梯。 +// Tiers 为标准时段单价;TimePricing 非 nil 时,落在时段内的请求整单再乘对应倍率。 type ContextPricingSchedule struct { - Basis ContextPricingBasis - Tiers []ContextPricingTier + Basis ContextPricingBasis + Tiers []ContextPricingTier + TimePricing *TimePricingSchedule } // ContextPricingScheduleInput 阶梯表查询输入。 @@ -126,7 +142,53 @@ func (s *BillingService) ResolveContextPricingSchedule(ctx context.Context, reso if legacy != nil { basis = ContextPricingBasisMarginal } - return &ContextPricingSchedule{Basis: basis, Tiers: tiers}, nil + return &ContextPricingSchedule{Basis: basis, Tiers: tiers, TimePricing: resolvedTimePricingSchedule(resolved)}, nil +} + +// resolvedTimePricingSchedule 列出计费会生效的分时倍率时段。 +// 时段来自解析到的渠道定价配置,每个时段的倍率用计费自己的 resolvedChannelTimeMultiplier +// 在时段内取值:定价来源不是渠道(分组价卡覆盖)、配置非法等情况下计费按 1 计, +// 这里也就自然得到"无分时"。倍率为 1 的时段不列出。 +func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule { + if resolved == nil || resolved.channelPricing == nil || resolved.channelPricing.TimePricing == nil { + return nil + } + cfg := resolved.channelPricing.TimePricing + location, err := loadChannelTimePricingLocation(cfg.Timezone) + if err != nil { + return nil + } + type probedPeriod struct { + start int + period TimePricingPeriod + } + probed := make([]probedPeriod, 0, len(cfg.Periods)) + for _, period := range cfg.Periods { + start, err := parseChannelTime(period.StartTime, false) + if err != nil { + continue + } + // 时段按每日循环,取任意一天该时段开始后 1 秒作为探针时刻。 + at := time.Date(2026, time.January, 1, 0, 0, start+1, 0, location) + multiplier := resolvedChannelTimeMultiplier(resolved, at) + if multiplier == 1 { + continue + } + probed = append(probed, probedPeriod{start: start, period: TimePricingPeriod{ + StartTime: period.StartTime, + EndTime: period.EndTime, + Multiplier: multiplier, + }}) + } + if len(probed) == 0 { + return nil + } + sort.SliceStable(probed, func(i, j int) bool { return probed[i].start < probed[j].start }) + out := &TimePricingSchedule{Timezone: cfg.Timezone, Periods: make([]TimePricingPeriod, 0, len(probed))} + for _, p := range probed { + out.Periods = append(out.Periods, p.period) + } + return out } // contextBreakpointPlan 描述断点来源。 diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 9223c78352..68ba3c8559 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -7,6 +7,7 @@ import ( "math" "math/rand" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/stretchr/testify/require" @@ -491,3 +492,91 @@ func assertCostClose(t *testing.T, want, got float64, format string, args ...any tolerance := 1e-12 + 1e-9*math.Abs(want) require.InDeltaf(t, want, got, tolerance, format, args...) } + +func sonnetChannelWithTimePricing(tp *ChannelTimePricing) []ChannelModelPricing { + return []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(2e-6), TimePricing: tp, + }} +} + +func TestResolveContextPricingSchedule_TimePricing(t *testing.T) { + valid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "12:00", EndTime: "13:00", Multiplier: 1}, + }} + + t.Run("渠道分时按开始时间升序列出且跳过倍率 1 的时段", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.NotNil(t, sched.TimePricing) + require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, + }, sched.TimePricing.Periods) + // 阶梯表单价是标准时段价,不含分时倍率 + requirePrice(t, testPtrFloat64(2e-6), sched.Tiers[0].Input, "input") + + // 对账:时段内的真实计费 = 标准单价 × token × 倍率 + group := enabledGroup(PlatformAnthropic) + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: &gid, Group: group}) + loc, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + for _, tc := range []struct { + at time.Time + want float64 + }{ + {time.Date(2026, 8, 23, 3, 0, 0, 0, loc), 0.5}, + {time.Date(2026, 8, 23, 12, 30, 0, 0, loc), 1}, + {time.Date(2026, 8, 23, 21, 59, 59, 0, loc), 1.2}, + {time.Date(2026, 8, 23, 22, 0, 0, 0, loc), 1}, + } { + cost, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-sonnet-4", Group: group, Tokens: UsageTokens{InputTokens: 1000}, + RateMultiplier: 1, PricingAt: tc.at, Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + assertCostClose(t, 1000*2e-6*tc.want, cost.ActualCost, "at %s", tc.at) + } + }) + + t.Run("配置非法时计费按 1 计,阶梯表不列分时", func(t *testing.T) { + invalid := &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + {StartTime: "08:00", EndTime: "09:00", Multiplier: 0.8}, // 与上一段重叠 + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(invalid), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("分组价卡覆盖后渠道分时不再生效", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(valid), nil) + group := &Group{ID: 100, Platform: PlatformAnthropic, LongContextPricingEnabled: true, ModelPricing: []ChannelModelPricing{{ + Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, InputPrice: testPtrFloat64(1e-6), + }}} + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) + + t.Run("无分时配置为 nil", func(t *testing.T) { + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(nil), nil) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{ + Model: "claude-sonnet-4", Group: enabledGroup(PlatformAnthropic), Platform: PlatformAnthropic, + }) + require.NoError(t, err) + require.Nil(t, sched.TimePricing) + }) +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 10a8eda559..fe2252c594 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -27,6 +27,8 @@ type PlazaModel struct { OfficialPricing *PlazaOfficialPricing // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。 LongContextBasis ContextPricingBasis + // TimePricing 计费会生效的分时倍率时段;无分时为 nil。 + TimePricing *TimePricingSchedule } // PlazaGroup 模型广场中以分组为顶层的条目。 @@ -233,6 +235,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } + m.TimePricing = sched.TimePricing return } } diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index df4a20cdf7..d54aca70ad 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -469,3 +469,27 @@ func TestListGroups_CatalogMissingStillShowsChannelFlatPricing(t *testing.T) { require.Nil(t, m.Pricing.CacheWritePrice, "目录无价且渠道未配置 → 无价") require.Nil(t, m.OfficialPricing) } + +func TestListGroups_TimePricingPassthrough(t *testing.T) { + channels := []Channel{{ + ID: 1, Name: "ch", Status: StatusActive, GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + Platform: PlatformDeepseek, Models: []string{"deepseek-chat"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(0.28e-6), OutputPrice: testPtrFloat64(0.42e-6), + TimePricing: &ChannelTimePricing{Timezone: "Asia/Shanghai", Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, + }}, + }} + groups := []Group{{ID: 10, Name: "cn", Platform: PlatformDeepseek, RateMultiplier: 1, LongContextPricingEnabled: true}} + svc := newPlazaServiceWithBilling(channels, groups, map[int64]string{10: PlatformDeepseek}, nil) + out, err := svc.ListGroups(context.Background()) + require.NoError(t, err) + m := out[0].Models[0] + require.NotNil(t, m.TimePricing) + require.Equal(t, "Asia/Shanghai", m.TimePricing.Timezone) + require.Len(t, m.TimePricing.Periods, 1) + require.InDelta(t, 0.5, m.TimePricing.Periods[0].Multiplier, 1e-12) + // 展示单价为标准时段价 + require.InDelta(t, 0.28e-6, *m.Pricing.InputPrice, 1e-15) +} diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index f881eab708..2c13ed1139 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -27,14 +27,30 @@ export interface PlazaOfficialPricing { */ export type PlazaLongContextBasis = 'whole_request' | 'marginal' +/** 分时倍率时段:配置时区当天 [start_time, end_time) 内整单实付乘 multiplier。 */ +export interface PlazaTimePricingPeriod { + start_time: string + end_time: string + multiplier: number +} + +/** 计费会生效的分时倍率(仅倍率 ≠ 1 的时段,已按开始时间升序)。 */ +export interface PlazaTimePricing { + /** IANA 时区名,如 Asia/Shanghai。 */ + timezone: string + periods: PlazaTimePricingPeriod[] +} + export interface PlazaModel { name: string platform: string - /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算)。 */ + /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null /** 仅多档模型返回。 */ long_context_basis?: PlazaLongContextBasis + /** 仅配置了分时倍率的模型返回。 */ + time_pricing?: PlazaTimePricing } export interface ModelPlazaGroup { diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 66082a0dcc..86a5a41b03 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -2,14 +2,14 @@
- + - + - - - + + + - +
{{ m.name }} + + + {{ formatTimeWindow(period) }} + {{ tierLabel(iv) }} - {{ paidPerMillion(iv.input_price) }} + {{ paidPerMillion(iv.input_price, period) }}
- +
- +