fix(group): harden peak-rate config handling and label peak windows with server timezone

Follow-up fixes to #3569 based on code audit:

- Expose server_timezone / server_utc_offset in public settings (and the
  __APP_CONFIG__ injection payload) and label every peak-window display
  with the server UTC offset, so users don't misread the billing window
  as browser-local time
- Unify CreateGroup/UpdateGroup peak-config sanitization via a single
  NormalizePeakRateConfig chokepoint: non-subscription groups always get
  peak fields cleared; unparseable window strings and negative
  multipliers are scrubbed when peak is disabled
- Replace hot-path time.Parse in PeakMultiplierAt with a manual HH:MM
  parser (accept set verified byte-for-byte identical to
  time.Parse("15:04") by exhaustive fuzzing) and reuse it in validation
- Revert the zero-behavior CalculateCost indirection churn in
  billing_service/gateway_service introduced by #3569
- Remove dead GetGroupPlatformMap and the duplicate deref helper in the
  admin handler package
- Share frontend peak formatting via utils/peak-rate.ts, unify the ×N
  label format, and move hardcoded Chinese tooltips to i18n keys
This commit is contained in:
shaw
2026-07-02 16:11:07 +08:00
parent a632cb0050
commit 11a3da65c0
21 changed files with 190 additions and 79 deletions
@@ -178,6 +178,8 @@ import SupportedModelChip from './SupportedModelChip.vue'
import type { UserAvailableChannel, UserAvailableGroup, UserChannelPlatformSection } from '@/api/channels'
import type { GroupPlatform, SubscriptionType } from '@/types'
import { platformBadgeClass } from '@/utils/platformColors'
import { useAppStore } from '@/stores/app'
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
const props = defineProps<{
columns: {
@@ -211,15 +213,17 @@ function publicGroups(section: UserChannelPlatformSection): UserAvailableGroup[]
return section.groups.filter((g) => !g.is_exclusive)
}
const appStore = useAppStore()
function hasPeakRate(group: UserAvailableGroup): boolean {
return Boolean(group.peak_rate_enabled && group.peak_start && group.peak_end)
return groupHasPeakRate(group)
}
function peakRateLabel(group: UserAvailableGroup): string {
return `${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x`
return formatPeakRateWindow(group, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
}
function peakRateTitle(group: UserAvailableGroup): string {
return `高峰倍率:${group.peak_start}-${group.peak_end} ${group.peak_rate_multiplier}x;token 计费的图片 token 同样适用,图片按次计费不受高峰影响`
return t('common.peakRateTooltip', { window: peakRateLabel(group) }) + t('common.peakRateImageNote')
}
</script>
+14 -2
View File
@@ -30,6 +30,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionType, GroupPlatform } from '@/types'
import { useAppStore } from '@/stores/app'
import { formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import PlatformIcon from './PlatformIcon.vue'
interface Props {
@@ -75,16 +77,26 @@ const hasCustomRate = computed(() => {
)
})
const appStore = useAppStore()
const hasPeakRate = computed(() => {
return Boolean(props.showRate && props.peakRateEnabled && props.peakStart && props.peakEnd)
})
const peakRateText = computed(() => {
return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}`
return formatPeakRateWindow(
{
peak_rate_enabled: props.peakRateEnabled,
peak_start: props.peakStart,
peak_end: props.peakEnd,
peak_rate_multiplier: props.peakRateMultiplier
},
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
)
})
const peakRateTitle = computed(() => {
return `高峰倍率:${peakRateText.value}`
return t('common.peakRateTooltip', { window: peakRateText.value })
})
// 是否显示右侧标签
@@ -63,6 +63,8 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue'
import type { SubscriptionType, GroupPlatform } from '@/types'
import { useAppStore } from '@/stores/app'
import { formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
const { t } = useI18n()
@@ -99,16 +101,26 @@ const hasCustomRate = computed(() => {
)
})
const appStore = useAppStore()
const hasPeakRate = computed(() => {
return Boolean(props.peakRateEnabled && props.peakStart && props.peakEnd)
})
const peakRateText = computed(() => {
return `${props.peakStart}-${props.peakEnd} ×${props.peakRateMultiplier ?? 1}`
return formatPeakRateWindow(
{
peak_rate_enabled: props.peakRateEnabled,
peak_start: props.peakStart,
peak_end: props.peakEnd,
peak_rate_multiplier: props.peakRateMultiplier
},
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
)
})
const peakRateTitle = computed(() => {
return `高峰倍率:${peakRateText.value}`
return t('common.peakRateTooltip', { window: peakRateText.value })
})
// Rate pill color matches platform badge color
@@ -103,6 +103,8 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import { useAppStore } from '@/stores/app'
import { hasPeakRate as groupHasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import {
platformAccentBarClass,
platformBadgeLightClass,
@@ -144,12 +146,12 @@ const rateDisplay = computed(() => {
return `×${Number(rate.toPrecision(10))}`
})
const hasPeakRate = computed(() => {
return Boolean(props.plan.peak_rate_enabled && props.plan.peak_start && props.plan.peak_end)
})
const appStore = useAppStore()
const hasPeakRate = computed(() => groupHasPeakRate(props.plan))
const peakRateDisplay = computed(() => {
return `${props.plan.peak_start}-${props.plan.peak_end} ×${props.plan.peak_rate_multiplier ?? 1}`
return formatPeakRateWindow(props.plan, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
})
const MODEL_SCOPE_LABELS: Record<string, string> = {
@@ -1,5 +1,6 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import { createPinia } from "pinia";
import { createI18n } from "vue-i18n";
import SubscriptionPlanCard from "../SubscriptionPlanCard.vue";
@@ -42,7 +43,7 @@ const mountPlanCard = (groupPlatform: string) =>
is_active: true,
},
},
global: { plugins: [i18n] },
global: { plugins: [i18n, createPinia()] },
});
describe("SubscriptionPlanCard", () => {