diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 551b8cc99..829ef94af 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -54,9 +54,11 @@ type modelPlazaTimePricingPeriod struct { } // modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 type modelPlazaTimePricing struct { - Timezone string `json:"timezone"` - Periods []modelPlazaTimePricingPeriod `json:"periods"` + Timezone string `json:"timezone"` + WeekdaysOnly bool `json:"weekdays_only,omitempty"` + Periods []modelPlazaTimePricingPeriod `json:"periods"` } // modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。 @@ -226,7 +228,7 @@ func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePric Multiplier: period.Multiplier, }) } - return &modelPlazaTimePricing{Timezone: p.Timezone, Periods: periods} + return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods} } // toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。 diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index 724ca1903..ab37a6db2 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -192,6 +192,13 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", Periods: []service.TimePricingPeriod{ {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, }}, + }, { + Name: "deepseek-reasoner", + Platform: "deepseek", + Pricing: &service.ChannelModelPricing{BillingMode: service.BillingModeToken, InputPrice: testPtr(0.56e-6)}, + TimePricing: &service.TimePricingSchedule{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []service.TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }}, }}, } raw, err := json.Marshal(toModelPlazaGroupDTO(&g, nil)) @@ -201,10 +208,16 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { model := decoded["models"].([]any)[0].(map[string]any) tp := model["time_pricing"].(map[string]any) require.Equal(t, "Asia/Shanghai", tp["timezone"]) + _, hasWeekdaysOnly := tp["weekdays_only"] + require.False(t, hasWeekdaysOnly, "未开启仅工作日时字段省略") 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) + + weekdaysModel := decoded["models"].([]any)[1].(map[string]any) + weekdaysTP := weekdaysModel["time_pricing"].(map[string]any) + require.Equal(t, true, weekdaysTP["weekdays_only"]) } diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index 8a43283f2..05f2a181b 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -39,9 +39,11 @@ type TimePricingPeriod struct { } // TimePricingSchedule 分组+模型生效的分时倍率(仅列出倍率 ≠ 1 的时段,按开始时间升序)。 +// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。 type TimePricingSchedule struct { - Timezone string - Periods []TimePricingPeriod + Timezone string + WeekdaysOnly bool + Periods []TimePricingPeriod } // ContextPricingSchedule 分组+模型按上下文长度分档的有效单价表。 @@ -168,8 +170,10 @@ func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule if err != nil { continue } - // 时段按每日循环,取任意一天该时段开始后 1 秒作为探针时刻。 - at := time.Date(2026, time.January, 1, 0, 0, start+1, 0, location) + // 时段按每日循环,取该时段开始后 1 秒作为探针时刻。 + // 锚点日必须是工作日(2026-01-05 为周一):weekdays_only 配置在周末恒为 1, + // 锚点落在周末会把时段整组剔除。 + at := time.Date(2026, time.January, 5, 0, 0, start+1, 0, location) multiplier := resolvedChannelTimeMultiplier(resolved, at) if multiplier == 1 { continue @@ -184,7 +188,11 @@ func resolvedTimePricingSchedule(resolved *ResolvedPricing) *TimePricingSchedule 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))} + out := &TimePricingSchedule{ + Timezone: cfg.Timezone, + WeekdaysOnly: cfg.WeekdaysOnly, + Periods: make([]TimePricingPeriod, 0, len(probed)), + } for _, p := range probed { out.Periods = append(out.Periods, p.period) } diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index 68ba3c855..b13606da9 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -515,6 +515,7 @@ func TestResolveContextPricingSchedule_TimePricing(t *testing.T) { require.NoError(t, err) require.NotNil(t, sched.TimePricing) require.Equal(t, "Asia/Shanghai", sched.TimePricing.Timezone) + require.False(t, sched.TimePricing.WeekdaysOnly) require.Equal(t, []TimePricingPeriod{ {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, {StartTime: "18:00", EndTime: "22:00:00", Multiplier: 1.2}, @@ -546,6 +547,43 @@ func TestResolveContextPricingSchedule_TimePricing(t *testing.T) { } }) + t.Run("仅工作日配置透传标注且时段仍列出", func(t *testing.T) { + weekdays := &ChannelTimePricing{Timezone: "Asia/Shanghai", WeekdaysOnly: true, Periods: []ChannelTimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }} + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, sonnetChannelWithTimePricing(weekdays), 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.True(t, sched.TimePricing.WeekdaysOnly) + require.Equal(t, []TimePricingPeriod{ + {StartTime: "00:30", EndTime: "08:30", Multiplier: 0.5}, + }, sched.TimePricing.Periods) + + // 对账:工作日时段内乘倍率,周末同一时段按标准价 + 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, 24, 3, 0, 0, 0, loc), 0.5}, // 周一 + {time.Date(2026, 8, 23, 3, 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}, diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 2c13ed113..e96a62193 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -38,6 +38,8 @@ export interface PlazaTimePricingPeriod { export interface PlazaTimePricing { /** IANA 时区名,如 Asia/Shanghai。 */ timezone: string + /** true 时时段仅周一至周五生效,周末整天按标准价计费。 */ + weekdays_only?: boolean periods: PlazaTimePricingPeriod[] } diff --git a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue index 86a5a41b0..7a5154910 100644 --- a/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue +++ b/frontend/src/components/modelPlaza/PlazaModelPricingTable.vue @@ -70,8 +70,11 @@ + {{ + t('modelPlaza.table.timePricingWeekdays') + }} {{ formatTimeWindow(period) }} v.replace(/^(\d{2}:\d{2}):00$/, '$1') diff --git a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts index 180789ae4..78a5c0c76 100644 --- a/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts +++ b/frontend/src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts @@ -582,6 +582,26 @@ describe('PlazaModelPricingTable 分时计价', () => { expect(nightCells[4].text()).toContain('$3.00') }) + it('仅工作日生效时时段行带工作日前缀,tooltip 换用周末回落文案', () => { + const model = timePricedModel() + model.time_pricing!.weekdays_only = true + const wrapper = mountTable([model], 1) + const trs = wrapper.findAll('tbody tr') + expect(trs).toHaveLength(3) + + const nightCells = trs[1].findAll('td') + expect(nightCells[0].text()).toContain('modelPlaza.table.timePricingWeekdays') + expect(nightCells[0].text()).toContain('00:30–08:30') + expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHintWeekdays"]').exists()).toBe(true) + expect(nightCells[0].find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(false) + }) + + it('每日生效(无 weekdays_only)不渲染工作日前缀', () => { + const wrapper = mountTable([timePricedModel()], 1) + expect(wrapper.find('tbody').text()).not.toContain('modelPlaza.table.timePricingWeekdays') + expect(wrapper.find('[title="modelPlaza.table.timePricingRowHint"]').exists()).toBe(true) + }) + it('无分时倍率时只有一行,不渲染时段标注', () => { const wrapper = mountTable([tokenModel()], 1) expect(wrapper.findAll('tbody tr')).toHaveLength(1) diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 2bfe635cf..6e9a901cc 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -605,6 +605,9 @@ export default { tierHintMarginal: 'Only the portion above the threshold is billed at this tier; output is unaffected', marginalBadge: 'excess-only tiers', timePricingRowHint: 'Requests made within this period ({timezone} time) are billed at the prices in this row', + timePricingRowHintWeekdays: + 'On weekdays (Mon–Fri) only, requests made within this period ({timezone} time) are billed at the prices in this row; weekends use the standard prices', + timePricingWeekdays: 'Weekdays', timePricingRateHint: 'Effective rate {rate} × period multiplier {multiplier}', paidPrice: 'Your Price (Discounted)', officialPrice: 'Official Price', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index ac8266ca2..c741aa60f 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -610,6 +610,9 @@ export default { tierHintMarginal: '仅超过阈值的部分按该档计价,输出不加价', marginalBadge: '超出部分计价', timePricingRowHint: '按 {timezone} 时间,在该时段内发起的请求按本行价格计费', + timePricingRowHintWeekdays: + '按 {timezone} 时间,仅工作日(周一至周五)在该时段内发起的请求按本行价格计费,周末全天按标准价', + timePricingWeekdays: '工作日', timePricingRateHint: '生效倍率 {rate} × 时段倍率 {multiplier}', paidPrice: '实付价格(折后)', officialPrice: '官方价格',