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", () => {
+2
View File
@@ -252,6 +252,8 @@ export default {
loading: 'Loading...',
submitting: 'Submitting...',
justNow: 'just now',
peakRateTooltip: 'Peak rate: {window}',
peakRateImageNote: '; image tokens billed as tokens are also affected, per-image billing is unaffected',
save: 'Save',
saved: 'Saved successfully',
deleted: 'Deleted successfully',
+2
View File
@@ -252,6 +252,8 @@ export default {
loading: '加载中...',
submitting: '提交中...',
justNow: '刚刚',
peakRateTooltip: '高峰倍率:{window}',
peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响',
save: '保存',
saved: '保存成功',
deleted: '删除成功',
+4
View File
@@ -227,6 +227,10 @@ export interface PublicSettings {
google_oauth_enabled: boolean
backend_mode_enabled: boolean
version: string
// 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用;
// 可选:注入的 __APP_CONFIG__ 旧缓存可能缺失
server_timezone?: string
server_utc_offset?: string
balance_low_notify_enabled: boolean
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
+33
View File
@@ -0,0 +1,33 @@
/**
* 高峰时段倍率的共享展示逻辑。
*
* 高峰窗口由后端按服务器全局时区判定(Group.PeakMultiplierAt),
* 前端展示必须带上服务器时区标注(来自公共设置 server_utc_offset),
* 避免用户按浏览器本地时间误读计费窗口。
*/
export interface PeakRateFields {
peak_rate_enabled?: boolean
peak_start?: string
peak_end?: string
peak_rate_multiplier?: number
}
export function hasPeakRate(fields?: PeakRateFields | null): boolean {
return Boolean(fields?.peak_rate_enabled && fields.peak_start && fields.peak_end)
}
/** "+08:00" → "UTC+08:00";旧缓存无该字段时返回空串,调用方降级为不带时区标注 */
export function serverTimezoneLabel(utcOffset?: string | null): string {
return utcOffset ? `UTC${utcOffset}` : ''
}
/** "14:00-18:00 ×2 (UTC+08:00)",tzLabel 为空时省略括号部分 */
export function formatPeakRateWindow(
fields: PeakRateFields | null | undefined,
tzLabel?: string
): string {
if (!hasPeakRate(fields) || !fields) return ''
const base = `${fields.peak_start}-${fields.peak_end} ×${fields.peak_rate_multiplier ?? 1}`
return tzLabel ? `${base} (${tzLabel})` : base
}
+7 -8
View File
@@ -262,6 +262,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device'
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel, type PeakRateFields } from '@/utils/peak-rate'
import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderType } from '@/types/payment'
import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
@@ -304,14 +305,12 @@ function getDaysRemaining(expiresAt: string): number {
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)))
}
function subscriptionHasPeakRate(sub: { group?: { peak_rate_enabled?: boolean; peak_start?: string; peak_end?: string } | null }): boolean {
const group = sub.group
return Boolean(group?.peak_rate_enabled && group.peak_start && group.peak_end)
function subscriptionHasPeakRate(sub: { group?: PeakRateFields | null }): boolean {
return hasPeakRate(sub.group)
}
function subscriptionPeakRateLabel(sub: { group?: { peak_start?: string; peak_end?: string; peak_rate_multiplier?: number } | null }): string {
const group = sub.group
return `${group?.peak_start}-${group?.peak_end} ×${group?.peak_rate_multiplier ?? 1}`
function subscriptionPeakRateLabel(sub: { group?: PeakRateFields | null }): string {
return formatPeakRateWindow(sub.group, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
}
const loading = ref(true)
@@ -713,11 +712,11 @@ const planValiditySuffix = computed(() => {
})
function planHasPeakRate(plan: SubscriptionPlan): boolean {
return Boolean(plan.peak_rate_enabled && plan.peak_start && plan.peak_end)
return hasPeakRate(plan)
}
function planPeakRateLabel(plan: SubscriptionPlan): string {
return `${plan.peak_start}-${plan.peak_end} ×${plan.peak_rate_multiplier ?? 1}`
return formatPeakRateWindow(plan, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
}
function selectPlan(plan: SubscriptionPlan) {
@@ -257,6 +257,7 @@ import type { UserSubscription } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateOnly } from '@/utils/format'
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
@@ -278,13 +279,11 @@ const subscriptions = ref<UserSubscription[]>([])
const loading = ref(true)
function subscriptionHasPeakRate(subscription: UserSubscription): boolean {
const group = subscription.group
return Boolean(group?.peak_rate_enabled && group.peak_start && group.peak_end)
return hasPeakRate(subscription.group)
}
function subscriptionPeakRateLabel(subscription: UserSubscription): string {
const group = subscription.group
return `${group?.peak_start}-${group?.peak_end} ×${group?.peak_rate_multiplier ?? 1}`
return formatPeakRateWindow(subscription.group, serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset))
}
async function loadSubscriptions() {