Merge pull request #6089 from lyen1688/feat/channel-time-pricing-weekdays
新增渠道时间段定价工作日生效规则
This commit is contained in:
@@ -74,8 +74,9 @@ type channelModelPricingRequest struct {
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []channelTimePricingPeriodRequest `json:"periods"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodRequest `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodRequest struct {
|
||||
@@ -144,8 +145,9 @@ type channelModelPricingResponse struct {
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []channelTimePricingPeriodResponse `json:"periods"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only"`
|
||||
Periods []channelTimePricingPeriodResponse `json:"periods"`
|
||||
}
|
||||
|
||||
type channelTimePricingPeriodResponse struct {
|
||||
@@ -282,7 +284,11 @@ func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricin
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &channelTimePricingResponse{Timezone: value.Timezone, Periods: periods}
|
||||
return &channelTimePricingResponse{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
@@ -374,7 +380,11 @@ func timePricingRequestToService(value *channelTimePricingRequest) *service.Chan
|
||||
Multiplier: period.Multiplier,
|
||||
})
|
||||
}
|
||||
return &service.ChannelTimePricing{Timezone: value.Timezone, Periods: periods}
|
||||
return &service.ChannelTimePricing{
|
||||
Timezone: value.Timezone,
|
||||
WeekdaysOnly: value.WeekdaysOnly,
|
||||
Periods: periods,
|
||||
}
|
||||
}
|
||||
|
||||
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
|
||||
|
||||
@@ -426,7 +426,8 @@ func TestPricingRequestToService_TimePricing(t *testing.T) {
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
TimePricing: &channelTimePricingRequest{
|
||||
Timezone: "Asia/Shanghai",
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []channelTimePricingPeriodRequest{{
|
||||
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
|
||||
}},
|
||||
@@ -435,6 +436,7 @@ func TestPricingRequestToService_TimePricing(t *testing.T) {
|
||||
|
||||
got := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
|
||||
require.True(t, got[0].TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
@@ -483,7 +485,8 @@ func TestPricingToResponse_TimePricing(t *testing.T) {
|
||||
got := pricingToResponse(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
TimePricing: &service.ChannelTimePricing{
|
||||
Timezone: "Asia/Shanghai",
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []service.ChannelTimePricingPeriod{{
|
||||
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
|
||||
}},
|
||||
@@ -492,6 +495,7 @@ func TestPricingToResponse_TimePricing(t *testing.T) {
|
||||
|
||||
require.NotNil(t, got.TimePricing)
|
||||
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
|
||||
require.True(t, got.TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,8 +109,9 @@ type ChannelModelPricing struct {
|
||||
|
||||
// ChannelTimePricing 渠道模型定价的分时倍率配置。
|
||||
type ChannelTimePricing struct {
|
||||
Timezone string `json:"timezone"`
|
||||
Periods []ChannelTimePricingPeriod `json:"periods"`
|
||||
Timezone string `json:"timezone"`
|
||||
WeekdaysOnly bool `json:"weekdays_only,omitempty"`
|
||||
Periods []ChannelTimePricingPeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// ChannelTimePricingPeriod 是秒级的左闭右开分时倍率区间,并兼容历史 HH:mm 数据。
|
||||
@@ -216,7 +217,10 @@ func (p ChannelModelPricing) Clone() ChannelModelPricing {
|
||||
copy(cp.Intervals, p.Intervals)
|
||||
}
|
||||
if p.TimePricing != nil {
|
||||
cp.TimePricing = &ChannelTimePricing{Timezone: p.TimePricing.Timezone}
|
||||
cp.TimePricing = &ChannelTimePricing{
|
||||
Timezone: p.TimePricing.Timezone,
|
||||
WeekdaysOnly: p.TimePricing.WeekdaysOnly,
|
||||
}
|
||||
if p.TimePricing.Periods != nil {
|
||||
cp.TimePricing.Periods = append([]ChannelTimePricingPeriod(nil), p.TimePricing.Periods...)
|
||||
}
|
||||
|
||||
@@ -198,7 +198,8 @@ func TestChannelModelPricingClone(t *testing.T) {
|
||||
{MinTokens: 0, TierLabel: "tier1"},
|
||||
},
|
||||
TimePricing: &ChannelTimePricing{
|
||||
Timezone: "Asia/Shanghai",
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: []ChannelTimePricingPeriod{{
|
||||
StartTime: "09:00",
|
||||
EndTime: "12:00",
|
||||
@@ -217,9 +218,11 @@ func TestChannelModelPricingClone(t *testing.T) {
|
||||
require.Equal(t, "tier1", original.Intervals[0].TierLabel)
|
||||
|
||||
cloned.TimePricing.Timezone = "America/New_York"
|
||||
cloned.TimePricing.WeekdaysOnly = false
|
||||
cloned.TimePricing.Periods[0].StartTime = "10:00"
|
||||
cloned.TimePricing.Periods[0].Multiplier = 3
|
||||
require.Equal(t, "Asia/Shanghai", original.TimePricing.Timezone)
|
||||
require.True(t, original.TimePricing.WeekdaysOnly)
|
||||
require.Equal(t, "09:00", original.TimePricing.Periods[0].StartTime)
|
||||
require.Equal(t, 2.0, original.TimePricing.Periods[0].Multiplier)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func (config *ChannelTimePricing) MultiplierAt(at time.Time) float64 {
|
||||
}
|
||||
|
||||
local := at.In(location)
|
||||
if config.WeekdaysOnly && (local.Weekday() == time.Saturday || local.Weekday() == time.Sunday) {
|
||||
return 1.0
|
||||
}
|
||||
second := local.Hour()*60*60 + local.Minute()*60 + local.Second()
|
||||
for _, period := range periods {
|
||||
if second >= period.start && second < period.end {
|
||||
|
||||
@@ -95,6 +95,30 @@ func TestChannelTimePricingMultiplierAt(t *testing.T) {
|
||||
require.Equal(t, 2.0, newYork.MultiplierAt(at))
|
||||
}
|
||||
|
||||
func TestChannelTimePricingMultiplierAtWeekdaysOnly(t *testing.T) {
|
||||
config := &ChannelTimePricing{
|
||||
Timezone: "Asia/Shanghai",
|
||||
WeekdaysOnly: true,
|
||||
Periods: onePeriod(),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
at time.Time
|
||||
want float64
|
||||
}{
|
||||
{name: "Monday in configured timezone", at: time.Date(2026, 6, 29, 1, 0, 0, 0, time.UTC), want: 2},
|
||||
{name: "Saturday in configured timezone", at: time.Date(2026, 7, 4, 1, 0, 0, 0, time.UTC), want: 1},
|
||||
{name: "Sunday in configured timezone", at: time.Date(2026, 7, 5, 1, 0, 0, 0, time.UTC), want: 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, config.MultiplierAt(tt.at))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelTimePricingMultiplierAtSecondPrecision(t *testing.T) {
|
||||
config := timeConfig(ChannelTimePricingPeriod{StartTime: "09:00:30", EndTime: "09:00:45", Multiplier: 2})
|
||||
shanghai, err := time.LoadLocation("Asia/Shanghai")
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ChannelTimePricingPeriod {
|
||||
|
||||
export interface ChannelTimePricing {
|
||||
timezone: string
|
||||
weekdays_only?: boolean
|
||||
periods: ChannelTimePricingPeriod[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
<template>
|
||||
<section class="mt-3 border-t border-gray-200 pt-3 dark:border-dark-600">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="min-w-0 flex-1 sm:max-w-sm">
|
||||
<div class="min-w-0 flex-1 sm:max-w-2xl">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.channels.form.timePricing') }}
|
||||
</label>
|
||||
<label class="mt-2 block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timezone') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.timezone"
|
||||
:options="timezoneOptions"
|
||||
:aria-label="t('admin.channels.form.timezone')"
|
||||
searchable
|
||||
creatable
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateTimezone"
|
||||
/>
|
||||
<div class="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div class="min-w-0">
|
||||
<label class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timezone') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.timezone"
|
||||
:options="timezoneOptions"
|
||||
:aria-label="t('admin.channels.form.timezone')"
|
||||
data-testid="time-pricing-timezone"
|
||||
searchable
|
||||
creatable
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateTimezone"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<label class="block text-xs text-gray-400">
|
||||
{{ t('admin.channels.form.timePricingDayScope') }}
|
||||
</label>
|
||||
<Select
|
||||
:model-value="modelValue.weekdays_only"
|
||||
:options="dayScopeOptions"
|
||||
:aria-label="t('admin.channels.form.timePricingDayScope')"
|
||||
data-testid="time-pricing-day-scope"
|
||||
class="mt-1 w-full"
|
||||
@update:model-value="updateDayScope"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -99,7 +117,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
@@ -122,10 +140,19 @@ const timezoneOptions = COMMON_TIMEZONES.map(value => {
|
||||
return { value, label: offset ? `${value} (${offset})` : value }
|
||||
})
|
||||
|
||||
const dayScopeOptions = computed(() => [
|
||||
{ value: false, label: t('admin.channels.form.timePricingEveryDay') },
|
||||
{ value: true, label: t('admin.channels.form.timePricingWeekdaysOnly') },
|
||||
])
|
||||
|
||||
function updateTimezone(value: string | number | boolean | null) {
|
||||
emit('update:modelValue', { ...props.modelValue, timezone: String(value ?? '') })
|
||||
}
|
||||
|
||||
function updateDayScope(value: string | number | boolean | null) {
|
||||
emit('update:modelValue', { ...props.modelValue, weekdays_only: value === true })
|
||||
}
|
||||
|
||||
function normalizeClockTime(value: string): string {
|
||||
const normalized = value.replace(/:/g, ':')
|
||||
return normalized === '24:00:00' ? '00:00:00' : normalized
|
||||
|
||||
@@ -12,7 +12,7 @@ const SelectStub = {
|
||||
name: 'Select',
|
||||
props: ['modelValue', 'options'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<button type="button" data-testid="timezone-select" />',
|
||||
template: '<button type="button" v-bind="$attrs" />',
|
||||
}
|
||||
|
||||
describe('TimePricingSection', () => {
|
||||
@@ -20,6 +20,28 @@ describe('TimePricingSection', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('offers every-day and weekdays-only scopes', async () => {
|
||||
const value = createDefaultTimePricingForm()
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
props: { modelValue: value },
|
||||
global: { stubs: { Select: SelectStub, Icon: true } },
|
||||
})
|
||||
|
||||
const selects = wrapper.findAllComponents(SelectStub)
|
||||
expect(selects).toHaveLength(2)
|
||||
expect(selects[1].props('modelValue')).toBe(false)
|
||||
expect(selects[1].props('options')).toEqual([
|
||||
{ value: false, label: 'admin.channels.form.timePricingEveryDay' },
|
||||
{ value: true, label: 'admin.channels.form.timePricingWeekdaysOnly' },
|
||||
])
|
||||
|
||||
selects[1].vm.$emit('update:modelValue', true)
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
...value,
|
||||
weekdays_only: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a neutral period immutably', async () => {
|
||||
const value = createDefaultTimePricingForm()
|
||||
const wrapper = mount(TimePricingSection, {
|
||||
@@ -31,6 +53,7 @@ describe('TimePricingSection', () => {
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: false,
|
||||
periods: [{ start_time: '', end_time: '', multiplier: '1.00' }],
|
||||
})
|
||||
expect(value.periods).toHaveLength(0)
|
||||
|
||||
@@ -146,15 +146,24 @@ describe('validateIntervals', () => {
|
||||
describe('time pricing', () => {
|
||||
it('uses a disabled Shanghai default', () => {
|
||||
const form = createDefaultTimePricingForm()
|
||||
expect(form).toEqual({ timezone: 'Asia/Shanghai', periods: [] })
|
||||
expect(form).toEqual({ timezone: 'Asia/Shanghai', periods: [], weekdays_only: false })
|
||||
expect(formTimePricingToAPI(form)).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips and formats multiplier', () => {
|
||||
const form = apiTimePricingToForm({
|
||||
it('defaults missing API day scope to every day', () => {
|
||||
expect(apiTimePricingToForm({
|
||||
timezone: 'Asia/Shanghai',
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: 2 }],
|
||||
}).weekdays_only).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips day scope and formats multiplier', () => {
|
||||
const form = apiTimePricingToForm({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: true,
|
||||
periods: [{ start_time: '09:00', end_time: '12:00', multiplier: 2 }],
|
||||
})
|
||||
expect(form.weekdays_only).toBe(true)
|
||||
expect(form.periods[0]).toEqual({
|
||||
start_time: '09:00:00',
|
||||
end_time: '12:00:00',
|
||||
@@ -162,6 +171,7 @@ describe('time pricing', () => {
|
||||
})
|
||||
expect(formTimePricingToAPI(form)).toEqual({
|
||||
timezone: 'Asia/Shanghai',
|
||||
weekdays_only: true,
|
||||
periods: [{ start_time: '09:00:00', end_time: '12:00:00', multiplier: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface TimePricingPeriodFormEntry {
|
||||
|
||||
export interface TimePricingFormEntry {
|
||||
timezone: string
|
||||
weekdays_only: boolean
|
||||
periods: TimePricingPeriodFormEntry[]
|
||||
}
|
||||
|
||||
@@ -66,13 +67,14 @@ export const COMMON_TIMEZONES = [
|
||||
]
|
||||
|
||||
export function createDefaultTimePricingForm(): TimePricingFormEntry {
|
||||
return { timezone: DEFAULT_TIME_PRICING_TIMEZONE, periods: [] }
|
||||
return { timezone: DEFAULT_TIME_PRICING_TIMEZONE, weekdays_only: false, periods: [] }
|
||||
}
|
||||
|
||||
export function apiTimePricingToForm(value: ChannelTimePricing | null | undefined): TimePricingFormEntry {
|
||||
if (!value) return createDefaultTimePricingForm()
|
||||
return {
|
||||
timezone: value.timezone || DEFAULT_TIME_PRICING_TIMEZONE,
|
||||
weekdays_only: value.weekdays_only === true,
|
||||
periods: (value.periods || []).map(period => ({
|
||||
start_time: LEGACY_CLOCK_TIME.test(period.start_time) ? `${period.start_time}:00` : period.start_time,
|
||||
end_time: LEGACY_CLOCK_TIME.test(period.end_time) ? `${period.end_time}:00` : period.end_time,
|
||||
@@ -86,6 +88,7 @@ export function formTimePricingToAPI(value: TimePricingFormEntry | null | undefi
|
||||
const timezone = typeof value.timezone === 'string' ? value.timezone.trim() : ''
|
||||
return {
|
||||
timezone,
|
||||
weekdays_only: value.weekdays_only === true,
|
||||
periods: value.periods.map(period => ({
|
||||
start_time: period.start_time,
|
||||
end_time: period.end_time,
|
||||
|
||||
@@ -140,6 +140,9 @@ export default {
|
||||
intervals: 'Context Intervals (optional)',
|
||||
timePricing: 'Time-based pricing (optional)',
|
||||
timezone: 'Time zone',
|
||||
timePricingDayScope: 'Effective days',
|
||||
timePricingEveryDay: 'Every day (Monday-Sunday, including weekends)',
|
||||
timePricingWeekdaysOnly: 'Weekdays only (Monday-Friday)',
|
||||
addTimePeriod: 'Add period',
|
||||
startTime: 'Start time',
|
||||
endTime: 'End time',
|
||||
|
||||
@@ -140,6 +140,9 @@ export default {
|
||||
intervals: '上下文区间定价(可选)',
|
||||
timePricing: '时间段定价(可选)',
|
||||
timezone: '时区',
|
||||
timePricingDayScope: '生效日',
|
||||
timePricingEveryDay: '每日生效(周一至周日,含周末)',
|
||||
timePricingWeekdaysOnly: '仅工作日生效(周一至周五)',
|
||||
addTimePeriod: '添加时间段',
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间',
|
||||
|
||||
Reference in New Issue
Block a user