mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:57:53 +08: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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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