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
@@ -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
}
+8 -4
View File
@@ -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"`
@@ -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,
@@ -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()
+10 -12
View File
@@ -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
}
+4 -8
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+54 -8
View File
@@ -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 共用此函数,
@@ -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))
+10 -4
View File
@@ -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,
@@ -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() {