diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index c1aea314d7..377fd7feaa 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -361,6 +361,11 @@ func (h *ChannelHandler) Create(c *gin.Context) { fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1))) return } + if len(r.Pricing) == 0 { + response.ErrorFrom(c, infraerrors.BadRequest("PRICING_RULE_EMPTY_PRICING", + fmt.Sprintf("pricing rule #%d must have at least one pricing entry", i+1))) + return + } rule := accountStatsPricingRuleRequestToService(r) rule.SortOrder = i statsRules = append(statsRules, rule) @@ -426,6 +431,11 @@ func (h *ChannelHandler) Update(c *gin.Context) { fmt.Sprintf("pricing rule #%d must have at least one group or account", i+1))) return } + if len(r.Pricing) == 0 { + response.ErrorFrom(c, infraerrors.BadRequest("PRICING_RULE_EMPTY_PRICING", + fmt.Sprintf("pricing rule #%d must have at least one pricing entry", i+1))) + return + } rule := accountStatsPricingRuleRequestToService(r) rule.SortOrder = i statsRules = append(statsRules, rule) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b22c616017..6413761199 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1139,6 +1139,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) { changed = append(changed, "registration_email_suffix_whitelist") } + if before.PromoCodeEnabled != after.PromoCodeEnabled { + changed = append(changed, "promo_code_enabled") + } + if before.InvitationCodeEnabled != after.InvitationCodeEnabled { + changed = append(changed, "invitation_code_enabled") + } if before.PasswordResetEnabled != after.PasswordResetEnabled { changed = append(changed, "password_reset_enabled") } @@ -1349,6 +1355,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.CustomMenuItems != after.CustomMenuItems { changed = append(changed, "custom_menu_items") } + if before.CustomEndpoints != after.CustomEndpoints { + changed = append(changed, "custom_endpoints") + } if before.EnableFingerprintUnification != after.EnableFingerprintUnification { changed = append(changed, "enable_fingerprint_unification") } diff --git a/backend/internal/repository/email_cache.go b/backend/internal/repository/email_cache.go index 1356163d88..0eb6bef105 100644 --- a/backend/internal/repository/email_cache.go +++ b/backend/internal/repository/email_cache.go @@ -20,8 +20,9 @@ const ( ) // verifyCodeKey generates the Redis key for email verification code. +// Email is lowercased for case-insensitive consistency. func verifyCodeKey(email string) string { - return verifyCodeKeyPrefix + email + return verifyCodeKeyPrefix + strings.ToLower(email) } // notifyVerifyKey generates the Redis key for notify email verification code. @@ -33,12 +34,12 @@ func notifyVerifyKey(email string) string { // passwordResetKey generates the Redis key for password reset token. func passwordResetKey(email string) string { - return passwordResetKeyPrefix + email + return passwordResetKeyPrefix + strings.ToLower(email) } // passwordResetSentAtKey generates the Redis key for password reset email sent timestamp. func passwordResetSentAtKey(email string) string { - return passwordResetSentAtKeyPrefix + email + return passwordResetSentAtKeyPrefix + strings.ToLower(email) } type emailCache struct { diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 9a75d6be5a..5e9afcc82d 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -283,6 +283,20 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) return nil } + return filterVerifiedEmails(entries) +} + +// getSiteName reads site name from settings with fallback. +func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { + name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) + if err != nil || name == "" { + return defaultSiteName + } + return name +} + +// filterVerifiedEmails returns deduplicated, non-disabled, verified emails. +func filterVerifiedEmails(entries []NotifyEmailEntry) []string { var recipients []string seen := make(map[string]bool) for _, entry := range entries { @@ -303,38 +317,10 @@ func (s *BalanceNotifyService) getAccountQuotaNotifyEmails(ctx context.Context) return recipients } -// getSiteName reads site name from settings with fallback. -func (s *BalanceNotifyService) getSiteName(ctx context.Context) string { - name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) - if err != nil || name == "" { - return defaultSiteName - } - return name -} - // collectBalanceNotifyRecipients returns verified, non-disabled email recipients. // Only emails with verified=true and disabled=false are included. func (s *BalanceNotifyService) collectBalanceNotifyRecipients(user *User) []string { - var recipients []string - seen := make(map[string]bool) - - for _, entry := range user.BalanceNotifyExtraEmails { - if entry.Disabled || !entry.Verified { - continue - } - email := strings.TrimSpace(entry.Email) - if email == "" { - continue - } - lower := strings.ToLower(email) - if seen[lower] { - continue - } - seen[lower] = true - recipients = append(recipients, email) - } - - return recipients + return filterVerifiedEmails(user.BalanceNotifyExtraEmails) } // sendEmails sends an email to all recipients with shared timeout and error logging. diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 9fbda5a6af..d5356ee6d8 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -59,6 +59,7 @@ type VerificationCodeData struct { Code string Attempts int CreatedAt time.Time + ExpiresAt time.Time // absolute expiry; used to preserve remaining TTL when updating attempts } // PasswordResetTokenData represents password reset token data @@ -267,6 +268,7 @@ func (s *EmailService) SendVerifyCode(ctx context.Context, email, siteName strin Code: code, Attempts: 0, CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(verifyCodeTTL), } if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { return fmt.Errorf("save verify code: %w", err) @@ -299,7 +301,11 @@ func (s *EmailService) VerifyCode(ctx context.Context, email, code string) error // 验证码不匹配 (constant-time comparison to prevent timing attacks) if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 { data.Attempts++ - if err := s.cache.SetVerificationCode(ctx, email, data, verifyCodeTTL); err != nil { + remaining := time.Until(data.ExpiresAt) + if remaining <= 0 { + return ErrInvalidVerifyCode + } + if err := s.cache.SetVerificationCode(ctx, email, data, remaining); err != nil { slog.Error("failed to update verification attempt count", "email", email, "error", err) } if data.Attempts >= maxVerifyCodeAttempts { diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 40f45e364e..c65e828a0d 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -7831,6 +7831,8 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage if apiKey.GroupID != nil { applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, + // Anthropic's input_tokens excludes cache_read and cache_creation (billed separately); + // OpenAI gateway uses actualInputTokens which also excludes cache_read for the same reason. UsageTokens{ InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, diff --git a/backend/internal/service/payment_config_plans_validation_test.go b/backend/internal/service/payment_config_plans_validation_test.go index 9a2d871603..efdbdb1052 100644 --- a/backend/internal/service/payment_config_plans_validation_test.go +++ b/backend/internal/service/payment_config_plans_validation_test.go @@ -128,3 +128,66 @@ func TestValidatePlanPatch_NilOriginalPrice(t *testing.T) { err := validatePlanPatch(UpdatePlanRequest{OriginalPrice: nil}) require.NoError(t, err) } + +// --- validatePlanPatch: other fields --- + +func ptrStr(s string) *string { return &s } +func ptrInt(i int) *int { return &i } +func ptrInt64(i int64) *int64 { return &i } +func ptrFloat(f float64) *float64 { return &f } + +func TestValidatePlanPatch_EmptyName(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{Name: ptrStr("")}) + require.Error(t, err) + require.Contains(t, err.Error(), "plan name") +} + +func TestValidatePlanPatch_ValidName(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{Name: ptrStr("Basic")}) + require.NoError(t, err) +} + +func TestValidatePlanPatch_ZeroGroupID(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{GroupID: ptrInt64(0)}) + require.Error(t, err) + require.Contains(t, err.Error(), "group") +} + +func TestValidatePlanPatch_NegativePrice(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(-1)}) + require.Error(t, err) + require.Contains(t, err.Error(), "price") +} + +func TestValidatePlanPatch_ZeroPrice(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(0)}) + require.Error(t, err) + require.Contains(t, err.Error(), "price") +} + +func TestValidatePlanPatch_ValidPrice(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{Price: ptrFloat(9.99)}) + require.NoError(t, err) +} + +func TestValidatePlanPatch_ZeroValidityDays(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{ValidityDays: ptrInt(0)}) + require.Error(t, err) + require.Contains(t, err.Error(), "validity days") +} + +func TestValidatePlanPatch_EmptyValidityUnit(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{ValidityUnit: ptrStr("")}) + require.Error(t, err) + require.Contains(t, err.Error(), "validity unit") +} + +func TestValidatePlanPatch_ValidValidityUnit(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{ValidityUnit: ptrStr("days")}) + require.NoError(t, err) +} + +func TestValidatePlanPatch_AllNil(t *testing.T) { + err := validatePlanPatch(UpdatePlanRequest{}) + require.NoError(t, err) +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 7602d16264..a7724a5a81 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -330,6 +330,7 @@ func saveNotifyVerifyCode(ctx context.Context, cache EmailCache, email, code str Code: code, Attempts: 0, CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(verifyCodeTTL), } if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil { return fmt.Errorf("save verify code: %w", err) @@ -370,7 +371,11 @@ func verifyNotifyCode(ctx context.Context, cache EmailCache, email, code string) } if subtle.ConstantTimeCompare([]byte(data.Code), []byte(code)) != 1 { data.Attempts++ - if err := cache.SetNotifyVerifyCode(ctx, email, data, verifyCodeTTL); err != nil { + remaining := time.Until(data.ExpiresAt) + if remaining <= 0 { + return ErrInvalidVerifyCode + } + if err := cache.SetNotifyVerifyCode(ctx, email, data, remaining); err != nil { slog.Error("failed to update notify verify code attempts", "email", email, "error", err) } if data.Attempts >= maxVerifyCodeAttempts { @@ -418,11 +423,17 @@ func (s *UserService) RemoveNotifyEmail(ctx context.Context, userID int64, email } filtered := make([]NotifyEmailEntry, 0, len(user.BalanceNotifyExtraEmails)) + found := false for _, e := range user.BalanceNotifyExtraEmails { - if !strings.EqualFold(e.Email, email) { + if strings.EqualFold(e.Email, email) { + found = true + } else { filtered = append(filtered, e) } } + if !found { + return infraerrors.BadRequest("EMAIL_NOT_FOUND", "notification email not found") + } user.BalanceNotifyExtraEmails = filtered return s.userRepo.Update(ctx, user) } diff --git a/frontend/src/components/account/QuotaDimensionRow.vue b/frontend/src/components/account/QuotaDimensionRow.vue index 1406faa9a8..e7fe2d0bc8 100644 --- a/frontend/src/components/account/QuotaDimensionRow.vue +++ b/frontend/src/components/account/QuotaDimensionRow.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index 77e437a852..68a68f2903 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -2,6 +2,7 @@ import { ref, watch, computed } from 'vue' import { useI18n } from 'vue-i18n' import QuotaDimensionRow from './QuotaDimensionRow.vue' +import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account' const { t } = useI18n() @@ -9,22 +10,22 @@ const props = withDefaults(defineProps<{ totalLimit: number | null dailyLimit: number | null weeklyLimit: number | null - dailyResetMode: 'rolling' | 'fixed' | null + dailyResetMode: QuotaResetMode | null dailyResetHour: number | null - weeklyResetMode: 'rolling' | 'fixed' | null + weeklyResetMode: QuotaResetMode | null weeklyResetDay: number | null weeklyResetHour: number | null resetTimezone: string | null quotaNotifyGlobalEnabled?: boolean quotaNotifyDailyEnabled?: boolean | null quotaNotifyDailyThreshold?: number | null - quotaNotifyDailyThresholdType?: string | null + quotaNotifyDailyThresholdType?: QuotaThresholdType | null quotaNotifyWeeklyEnabled?: boolean | null quotaNotifyWeeklyThreshold?: number | null - quotaNotifyWeeklyThresholdType?: string | null + quotaNotifyWeeklyThresholdType?: QuotaThresholdType | null quotaNotifyTotalEnabled?: boolean | null quotaNotifyTotalThreshold?: number | null - quotaNotifyTotalThresholdType?: string | null + quotaNotifyTotalThresholdType?: QuotaThresholdType | null }>(), { quotaNotifyGlobalEnabled: false, quotaNotifyDailyEnabled: null, @@ -42,21 +43,21 @@ const emit = defineEmits<{ 'update:totalLimit': [value: number | null] 'update:dailyLimit': [value: number | null] 'update:weeklyLimit': [value: number | null] - 'update:dailyResetMode': [value: 'rolling' | 'fixed' | null] + 'update:dailyResetMode': [value: QuotaResetMode | null] 'update:dailyResetHour': [value: number | null] - 'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null] + 'update:weeklyResetMode': [value: QuotaResetMode | null] 'update:weeklyResetDay': [value: number | null] 'update:weeklyResetHour': [value: number | null] 'update:resetTimezone': [value: string | null] 'update:quotaNotifyDailyEnabled': [value: boolean | null] 'update:quotaNotifyDailyThreshold': [value: number | null] - 'update:quotaNotifyDailyThresholdType': [value: string | null] + 'update:quotaNotifyDailyThresholdType': [value: QuotaThresholdType | null] 'update:quotaNotifyWeeklyEnabled': [value: boolean | null] 'update:quotaNotifyWeeklyThreshold': [value: number | null] - 'update:quotaNotifyWeeklyThresholdType': [value: string | null] + 'update:quotaNotifyWeeklyThresholdType': [value: QuotaThresholdType | null] 'update:quotaNotifyTotalEnabled': [value: boolean | null] 'update:quotaNotifyTotalThreshold': [value: number | null] - 'update:quotaNotifyTotalThresholdType': [value: string | null] + 'update:quotaNotifyTotalThresholdType': [value: QuotaThresholdType | null] }>() const enabled = computed(() => @@ -89,11 +90,6 @@ watch(localEnabled, (val) => { } }) -// Whether any fixed mode is active (to show timezone selector) -const hasFixedMode = computed(() => - props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed' -) - // Common timezone options const timezoneOptions = [ 'UTC', 'Asia/Shanghai', 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Singapore', 'Asia/Kolkata', @@ -102,18 +98,6 @@ const timezoneOptions = [ 'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland', ] -// Compute GMT offset label (e.g. "GMT+8", "GMT-5") for a given IANA timezone. -function getTimezoneOffsetLabel(tz: string): string { - try { - const dtf = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }) - const parts = dtf.formatToParts(new Date()) - const tzPart = parts.find(p => p.type === 'timeZoneName') - return tzPart ? (tzPart.value === 'GMT' ? 'GMT+0' : tzPart.value) : '' - } catch { - return '' - } -} - // Hours for dropdown (0-23) const hourOptions = Array.from({ length: 24 }, (_, i) => i) @@ -197,6 +181,7 @@ const dailyFixedHint = computed(() => :hint-fixed="dailyFixedHint" :hour-options="hourOptions" :day-options="dayOptions" + :timezone-options="timezoneOptions" @update:limit="emit('update:dailyLimit', $event)" @update:notify-enabled="emit('update:quotaNotifyDailyEnabled', $event)" @update:notify-threshold="emit('update:quotaNotifyDailyThreshold', $event)" @@ -223,6 +208,7 @@ const dailyFixedHint = computed(() => :hint-fixed="weeklyFixedHint" :hour-options="hourOptions" :day-options="dayOptions" + :timezone-options="timezoneOptions" @update:limit="emit('update:weeklyLimit', $event)" @update:notify-enabled="emit('update:quotaNotifyWeeklyEnabled', $event)" @update:notify-threshold="emit('update:quotaNotifyWeeklyThreshold', $event)" @@ -233,14 +219,6 @@ const dailyFixedHint = computed(() => @update:reset-timezone="emit('update:resetTimezone', $event)" /> - -
- - -
- -import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE } from '@/constants/account' +import { QUOTA_THRESHOLD_TYPE_FIXED, QUOTA_THRESHOLD_TYPE_PERCENTAGE, type QuotaThresholdType } from '@/constants/account' defineProps<{ enabled: boolean | null threshold: number | null - thresholdType: string | null // "fixed" (default) or "percentage" + thresholdType: QuotaThresholdType | null }>() const emit = defineEmits<{ 'update:enabled': [value: boolean | null] 'update:threshold': [value: number | null] - 'update:thresholdType': [value: string | null] + 'update:thresholdType': [value: QuotaThresholdType | null] }>() @@ -43,7 +43,7 @@ const emit = defineEmits<{ /> - {{ getGroupNameById(gid) }} + {{ getGroupNameById(gid) }}

@@ -481,7 +481,7 @@ :key="accountId" class="inline-flex items-center gap-1 rounded-md border border-primary-300 bg-primary-50 px-2 py-0.5 text-xs dark:border-primary-700 dark:bg-primary-900/20" > - {{ getRuleAccountLabel(accountId) }} + {{ getRuleAccountLabel(accountId) }} @@ -595,7 +595,7 @@ import type { PricingFormEntry } from '@/components/admin/channel/types' import { mTokToPerToken, perTokenToMTok, apiIntervalsToForm, formIntervalsToAPI, findModelConflict, validateIntervals } from '@/components/admin/channel/types' import type { AdminGroup, GroupPlatform } from '@/types' import type { Column } from '@/components/common/types' -import { platformTextClass } from '@/utils/platformColors' +import { platformTextClass, platformBadgeLightClass } from '@/utils/platformColors' import AppLayout from '@/components/layout/AppLayout.vue' import TablePageLayout from '@/components/layout/TablePageLayout.vue' import DataTable from '@/components/common/DataTable.vue' @@ -720,26 +720,6 @@ let abortController: AbortController | null = null // ── Platform config ── const platformOrder: GroupPlatform[] = ['anthropic', 'openai', 'gemini', 'antigravity'] -function getPlatformTextColor(platform: string): string { - switch (platform) { - case 'anthropic': return 'text-orange-600 dark:text-orange-400' - case 'openai': return 'text-emerald-600 dark:text-emerald-400' - case 'gemini': return 'text-blue-600 dark:text-blue-400' - case 'antigravity': return 'text-purple-600 dark:text-purple-400' - default: return 'text-gray-600 dark:text-gray-400' - } -} - -function getRateBadgeClass(platform: string): string { - switch (platform) { - case 'anthropic': return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' - case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' - case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' - case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' - default: return 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400' - } -} - // ── Helpers ── function formatDate(value: string): string { if (!value) return '-'