mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -252,6 +252,8 @@ export default {
|
||||
loading: '加载中...',
|
||||
submitting: '提交中...',
|
||||
justNow: '刚刚',
|
||||
peakRateTooltip: '高峰倍率:{window}',
|
||||
peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响',
|
||||
save: '保存',
|
||||
saved: '保存成功',
|
||||
deleted: '删除成功',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user