From 11a3da65c088c915812d753261a009bae3179448 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 2 Jul 2026 16:11:07 +0800 Subject: [PATCH] fix(group): harden peak-rate config handling and label peak windows with server timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/handler/admin/group_handler.go | 9 +-- backend/internal/handler/dto/settings.go | 12 ++-- backend/internal/handler/setting_handler.go | 3 + backend/internal/pkg/timezone/timezone.go | 5 ++ backend/internal/service/admin_service.go | 22 +++---- backend/internal/service/billing_service.go | 12 ++-- backend/internal/service/gateway_service.go | 2 +- backend/internal/service/group.go | 62 ++++++++++++++++--- .../internal/service/payment_config_plans.go | 10 --- backend/internal/service/setting_service.go | 14 +++-- .../channels/AvailableChannelsTable.vue | 10 ++- frontend/src/components/common/GroupBadge.vue | 16 ++++- .../src/components/common/GroupOptionItem.vue | 16 ++++- .../payment/SubscriptionPlanCard.vue | 10 +-- .../__tests__/SubscriptionPlanCard.spec.ts | 3 +- frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/types/index.ts | 4 ++ frontend/src/utils/peak-rate.ts | 33 ++++++++++ frontend/src/views/user/PaymentView.vue | 15 +++-- frontend/src/views/user/SubscriptionsView.vue | 7 +-- 21 files changed, 190 insertions(+), 79 deletions(-) create mode 100644 frontend/src/utils/peak-rate.ts diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 9a1c2b87fe..0a98ad6784 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -71,13 +71,6 @@ func (f optionalLimitField) ToServiceInput() *float64 { return &zero } -func derefFloat64Default(p *float64, def float64) float64 { - if p != nil { - return *p - } - return def -} - // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { return &GroupHandler{ @@ -292,7 +285,7 @@ func (h *GroupHandler) Create(c *gin.Context) { return } - if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, derefFloat64Default(req.PeakRateMultiplier, 1.0)); err != nil { + if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil { response.BadRequest(c, err.Error()) return } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index e117d19ba3..1d6f73bb99 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -321,10 +321,14 @@ type PublicSettings struct { BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 + // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 + ServerTimezone string `json:"server_timezone"` + ServerUTCOffset string `json:"server_utc_offset"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 7c79a19e76..f6a5de4f1d 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -7,6 +7,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -85,6 +86,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: h.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, diff --git a/backend/internal/pkg/timezone/timezone.go b/backend/internal/pkg/timezone/timezone.go index 40f6e38f89..9bced31ab5 100644 --- a/backend/internal/pkg/timezone/timezone.go +++ b/backend/internal/pkg/timezone/timezone.go @@ -81,6 +81,11 @@ func Name() string { return tzName } +// UTCOffset returns the current UTC offset of the configured timezone, e.g. "+08:00". +func UTCOffset() string { + return getUTCOffset(Location()) +} + // StartOfDay returns the start of the given day (00:00:00) in the configured timezone. func StartOfDay(t time.Time) time.Time { loc := Location() diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 2a3189f61f..22319c4f41 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1850,7 +1850,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if input.PeakRateMultiplier != nil { peakRateMultiplier = *input.PeakRateMultiplier } - if err := ValidatePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier); err != nil { + // 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。 + peakRateEnabled, peakStart, peakEnd, peakRateMultiplier := NormalizePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier) + if err := ValidatePeakRateConfig(subscriptionType, peakRateEnabled, peakStart, peakEnd, peakRateMultiplier); err != nil { return nil, err } @@ -1923,9 +1925,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn AllowImageGeneration: input.AllowImageGeneration, ImageRateIndependent: input.ImageRateIndependent, ImageRateMultiplier: imageRateMultiplier, - PeakRateEnabled: input.PeakRateEnabled, - PeakStart: input.PeakStart, - PeakEnd: input.PeakEnd, + PeakRateEnabled: peakRateEnabled, + PeakStart: peakStart, + PeakEnd: peakEnd, PeakRateMultiplier: peakRateMultiplier, ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, @@ -2128,14 +2130,10 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.PeakRateMultiplier != nil { group.PeakRateMultiplier = *input.PeakRateMultiplier } - if group.SubscriptionType != SubscriptionTypeSubscription { - group.PeakRateEnabled = false - group.PeakStart = "" - group.PeakEnd = "" - group.PeakRateMultiplier = 1.0 - } - // 收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, - // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。 + // 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段), + // 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, + // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。 + group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier = NormalizePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier) if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { return nil, err } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 891b46c878..a781936598 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -1080,19 +1080,15 @@ func (s *BillingService) CalculateCostWithConfig(model string, tokens UsageToken // 拆分为:范围内 (200k, 0) + 范围外 (10k, 10k) // 范围内正常计费,范围外 × 2 计费 func (s *BillingService) CalculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { - return s.calculateCostWithLongContext(model, tokens, rateMultiplier, threshold, extraMultiplier) -} - -func (s *BillingService) calculateCostWithLongContext(model string, tokens UsageTokens, rateMultiplier float64, threshold int, extraMultiplier float64) (*CostBreakdown, error) { // 未启用长上下文计费,直接走正常计费 if threshold <= 0 || extraMultiplier <= 1 { - return s.calculateCostInternal(model, tokens, rateMultiplier, "", nil) + return s.CalculateCost(model, tokens, rateMultiplier) } // 计算总输入 token(缓存读取 + 新输入) total := tokens.CacheReadTokens + tokens.InputTokens if total <= threshold { - return s.calculateCostInternal(model, tokens, rateMultiplier, "", nil) + return s.CalculateCost(model, tokens, rateMultiplier) } // 拆分成范围内和范围外 @@ -1123,7 +1119,7 @@ func (s *BillingService) calculateCostWithLongContext(model string, tokens Usage CacheCreation1hTokens: tokens.CacheCreation1hTokens, ImageOutputTokens: tokens.ImageOutputTokens, } - inRangeCost, err := s.calculateCostInternal(model, inRangeTokens, rateMultiplier, "", nil) + inRangeCost, err := s.CalculateCost(model, inRangeTokens, rateMultiplier) if err != nil { return nil, err } @@ -1133,7 +1129,7 @@ func (s *BillingService) calculateCostWithLongContext(model string, tokens Usage InputTokens: outRangeInputTokens, CacheReadTokens: outRangeCacheTokens, } - outRangeCost, err := s.calculateCostInternal(model, outRangeTokens, rateMultiplier*extraMultiplier, "", nil) + outRangeCost, err := s.CalculateCost(model, outRangeTokens, rateMultiplier*extraMultiplier) if err != nil { return inRangeCost, fmt.Errorf("out-range cost: %w", err) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index e4ccced84b..903aaf5bfb 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -9681,7 +9681,7 @@ func (s *GatewayService) calculateTokenCost( // 长上下文双倍计费(如 Gemini 200K 阈值) cost, err = s.billingService.CalculateCostWithLongContext(billingModel, tokens, multiplier, opts.LongContextThreshold, opts.LongContextMultiplier) } else { - cost, err = s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, "") + cost, err = s.billingService.CalculateCost(billingModel, tokens, multiplier) } if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index f759e83073..6d0a11f766 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -178,12 +178,32 @@ func matchModelPattern(pattern, model string) bool { } // parseMinutes 把 "HH:MM" 解析为当日分钟数(0..1439),格式非法返回 (0,false)。 +// 手工解析而非 time.Parse:本函数位于每请求的计费热路径(PeakMultiplierAt), +// 避免对静态配置字符串重复走 layout 解析与 time.Time 分配。 +// 接受集与 time.Parse("15:04", s) 完全一致(存量数据按旧解析写入,不得收窄): +// 小时 1–2 位数字(0..23,允许不补零如 "1:30"),分钟固定 2 位数字(00..59)。 func parseMinutes(hhmm string) (int, bool) { - t, err := time.Parse("15:04", hhmm) - if err != nil { + colon := strings.IndexByte(hhmm, ':') + if (colon != 1 && colon != 2) || len(hhmm)-colon-1 != 2 { return 0, false } - return t.Hour()*60 + t.Minute(), true + h := 0 + for i := 0; i < colon; i++ { + d := hhmm[i] - '0' + if d > 9 { + return 0, false + } + h = h*10 + int(d) + } + m1, m2 := hhmm[colon+1]-'0', hhmm[colon+2]-'0' + if m1 > 9 || m2 > 9 { + return 0, false + } + m := int(m1)*10 + int(m2) + if h > 23 || m > 59 { + return 0, false + } + return h*60 + m, true } // PeakMultiplierAt 返回指定时刻 now 的高峰因子。 @@ -223,15 +243,15 @@ func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end st if start == "" || end == "" { return errors.New("peak_rate_enabled 为 true 时 peak_start 与 peak_end 必填") } - st, err1 := time.Parse("15:04", start) - if err1 != nil { + st, okStart := parseMinutes(start) + if !okStart { return fmt.Errorf("peak_start 格式应为 HH:MM,got %q", start) } - en, err2 := time.Parse("15:04", end) - if err2 != nil { + en, okEnd := parseMinutes(end) + if !okEnd { return fmt.Errorf("peak_end 格式应为 HH:MM,got %q", end) } - if st.Hour()*60+st.Minute() >= en.Hour()*60+en.Minute() { + if st >= en { return errors.New("peak_end 必须大于 peak_start(不支持跨天区间,如 22:00-02:00)") } if multiplier < 0 { @@ -240,6 +260,32 @@ func ValidatePeakRateConfig(subscriptionType string, enabled bool, start, end st return nil } +// NormalizePeakRateConfig 归一化最终落库的高峰配置,CreateGroup 与 UpdateGroup 两条写路径共用(唯一收口): +// - 非订阅类型分组不携带任何高峰配置,一律清空(enabled=false、窗口置空、倍率归 1.0); +// - 订阅分组关闭高峰时保留已配置的合法窗口(便于临时停用后再启用), +// 但清掉无法解析的脏字符串与负倍率,避免脏数据入库。 +// +// 与 ValidatePeakRateConfig 的分工:enabled=true 时校验已保证各字段合法,本函数为无操作; +// enabled=false 时校验放行,由本函数兜底清洗。调用顺序为先归一化、后校验, +// 使"订阅转标准"这类更新能静默清空高峰配置而不是被校验拒绝。 +func NormalizePeakRateConfig(subscriptionType string, enabled bool, start, end string, multiplier float64) (bool, string, string, float64) { + if subscriptionType != SubscriptionTypeSubscription { + return false, "", "", 1.0 + } + if !enabled { + if _, ok := parseMinutes(start); !ok { + start = "" + } + if _, ok := parseMinutes(end); !ok { + end = "" + } + if multiplier < 0 { + multiplier = 1.0 + } + } + return enabled, start, end, multiplier +} + // computePeakAwareMultipliers 把"基础 token 倍率 base"(已含系统/分组/用户级倍率,但不含高峰) // 拆分为最终 token 倍率与图片按次倍率:图片按次倍率基于 base 现算、不受高峰影响;token 倍率在 base 上叠加高峰因子。 // gateway_service.recordUsageCore 与 openai_gateway_service.RecordUsage 共用此函数, diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 8801a55900..0191539457 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -74,16 +74,6 @@ type PlanGroupInfo struct { ModelScopes []string `json:"supported_model_scopes"` } -// GetGroupPlatformMap returns a map of group_id → platform for the given plans. -func (s *PaymentConfigService) GetGroupPlatformMap(ctx context.Context, plans []*dbent.SubscriptionPlan) map[int64]string { - info := s.GetGroupInfoMap(ctx, plans) - m := make(map[int64]string, len(info)) - for id, gi := range info { - m[id] = gi.Platform - } - return m -} - // GetGroupInfoMap returns a map of group_id → PlanGroupInfo for the given plans. func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbent.SubscriptionPlan) map[int64]PlanGroupInfo { ids := make([]int64, 0, len(plans)) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index fed1a437a2..1a5e677a12 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -21,6 +21,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/imroc/req/v3" "golang.org/x/sync/singleflight" ) @@ -1461,10 +1462,13 @@ type PublicSettingsInjectionPayload struct { BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` Version string `json:"version"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 + ServerTimezone string `json:"server_timezone"` + ServerUTCOffset string `json:"server_utc_offset"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug @@ -1527,6 +1531,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: s.version, + ServerTimezone: timezone.Name(), + ServerUTCOffset: timezone.UTCOffset(), BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, diff --git a/frontend/src/components/channels/AvailableChannelsTable.vue b/frontend/src/components/channels/AvailableChannelsTable.vue index 02d1f2d1af..0e8cea139b 100644 --- a/frontend/src/components/channels/AvailableChannelsTable.vue +++ b/frontend/src/components/channels/AvailableChannelsTable.vue @@ -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') } diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue index 1be00ce6c9..3589974012 100644 --- a/frontend/src/components/common/GroupBadge.vue +++ b/frontend/src/components/common/GroupBadge.vue @@ -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 }) }) // 是否显示右侧标签 diff --git a/frontend/src/components/common/GroupOptionItem.vue b/frontend/src/components/common/GroupOptionItem.vue index 44724fdd08..83acde5386 100644 --- a/frontend/src/components/common/GroupOptionItem.vue +++ b/frontend/src/components/common/GroupOptionItem.vue @@ -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 diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index c69aabd898..5f8161677b 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -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 = { diff --git a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts index ebe695ee31..230692d7fb 100644 --- a/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts +++ b/frontend/src/components/payment/__tests__/SubscriptionPlanCard.spec.ts @@ -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", () => { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 06ff918a16..ac79f16862 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 87e78422b8..9520bb4c21 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -252,6 +252,8 @@ export default { loading: '加载中...', submitting: '提交中...', justNow: '刚刚', + peakRateTooltip: '高峰倍率:{window}', + peakRateImageNote: ';token 计费的图片 token 同样适用,图片按次计费不受高峰影响', save: '保存', saved: '保存成功', deleted: '删除成功', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a3dd887279..a2b8dd67e1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/utils/peak-rate.ts b/frontend/src/utils/peak-rate.ts new file mode 100644 index 0000000000..1135d4fe37 --- /dev/null +++ b/frontend/src/utils/peak-rate.ts @@ -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 +} diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 092efac9fd..e3901ea0af 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -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) { diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue index 3af64b29e9..b76f87582b 100644 --- a/frontend/src/views/user/SubscriptionsView.vue +++ b/frontend/src/views/user/SubscriptionsView.vue @@ -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([]) 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() {