模型广场:增加渠道分时段计价展示

- 阶梯表查询附带分时倍率时段:时段取自计费解析到的渠道定价,
  每个时段的倍率由计费的 resolvedChannelTimeMultiplier 在时段内取值,
  分组价卡覆盖或配置非法时自然不出现;倍率为 1 的时段不列
- 广场模型条目新增 time_pricing(时区 + 时段 + 倍率)
- 前端把分时时段展开为独立行:模型名旁标注时段,价格按时段倍率折算,
  倍率列显示生效倍率;时区与计算口径放在提示中
This commit is contained in:
feeeei
2026-08-24 10:50:52 +08:00
parent ecce0769c0
commit 83d4eb6a43
11 changed files with 390 additions and 26 deletions
@@ -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 {
@@ -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)
}
@@ -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 描述断点来源。
@@ -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)
})
}
@@ -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
}
}
@@ -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)
}