fix: round-2 audit fixes — security, code quality, and UI improvements

Security (HIGH):
- Normalize all Redis cache keys to lowercase (verifyCode, passwordReset)
- Fix verify code TTL renewal on failed attempts: use remaining TTL via
  ExpiresAt field instead of resetting to full 15-minute window
- Add 3 missing fields to diffSettings audit log (promo_code, invitation_code,
  custom_endpoints)

Code quality (MEDIUM):
- Extract filterVerifiedEmails shared helper (balance_notify_service.go)
- Add Pricing array non-empty validation for channel pricing rules
- Add platform token semantics comment in gateway_service.go
- Complete validatePlanPatch test coverage (+10 test cases)
- Replace string types with QuotaThresholdType/QuotaResetMode across frontend
- Remove duplicate getPlatformTextColor/getRateBadgeClass in ChannelsView
- Return EMAIL_NOT_FOUND error on RemoveNotifyEmail miss

UI improvements:
- Reorder cost tooltip: user billing above separator, account billing below
- Add NaN guard to accountBilled function
- Move timezone selector inline into reset-mode row (no longer standalone)
This commit is contained in:
erio
2026-04-14 00:26:20 +08:00
parent 74f8a30f86
commit a9880ee7b9
15 changed files with 605 additions and 291 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import QuotaNotifyToggle from './QuotaNotifyToggle.vue'
import type { QuotaThresholdType, QuotaResetMode } from '@/constants/account'
const { t } = useI18n()
@@ -11,9 +12,9 @@ const props = defineProps<{
quotaNotifyGlobalEnabled: boolean
notifyEnabled: boolean | null
notifyThreshold: number | null
notifyThresholdType: string | null
notifyThresholdType: QuotaThresholdType | null
// Reset mode (only for daily/weekly, null for total)
resetMode: 'rolling' | 'fixed' | null
resetMode: QuotaResetMode | null
resetHour: number | null
resetDay: number | null // weekly only
resetTimezone: string | null
@@ -22,14 +23,15 @@ const props = defineProps<{
// Shared options passed from parent
hourOptions: number[]
dayOptions: { value: number; key: string }[]
timezoneOptions?: string[]
}>()
const emit = defineEmits<{
'update:limit': [value: number | null]
'update:notifyEnabled': [value: boolean | null]
'update:notifyThreshold': [value: number | null]
'update:notifyThresholdType': [value: string | null]
'update:resetMode': [value: 'rolling' | 'fixed' | null]
'update:notifyThresholdType': [value: QuotaThresholdType | null]
'update:resetMode': [value: QuotaResetMode | null]
'update:resetHour': [value: number | null]
'update:resetDay': [value: number | null]
'update:resetTimezone': [value: string | null]
@@ -43,7 +45,7 @@ const onLimitInput = (e: Event) => {
}
const onModeChange = (e: Event) => {
const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed'
const val = (e.target as HTMLSelectElement).value as QuotaResetMode
emit('update:resetMode', val)
if (val === 'fixed') {
if (props.resetHour == null) emit('update:resetHour', 0)
@@ -51,6 +53,17 @@ const onModeChange = (e: Event) => {
if (!props.resetTimezone) emit('update:resetTimezone', 'UTC')
}
}
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 ''
}
}
</script>
<template>
@@ -95,6 +108,11 @@ const onModeChange = (e: Event) => {
<select :value="resetHour ?? 0" @change="emit('update:resetHour', Number(($event.target as HTMLSelectElement).value))" class="input py-1 text-xs w-24">
<option v-for="h in hourOptions" :key="h" :value="h">{{ String(h).padStart(2, '0') }}:00</option>
</select>
<template v-if="timezoneOptions && timezoneOptions.length > 0">
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input py-1 text-xs w-auto">
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
</select>
</template>
</template>
<span class="text-[11px] text-gray-500 dark:text-gray-400">
<template v-if="resetMode === 'fixed'">{{ hintFixed }}</template>

View File

@@ -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)"
/>
<!-- Timezone selector (shared by daily/weekly when fixed mode is active) -->
<div v-if="hasFixedMode">
<label class="input-label">{{ t('admin.accounts.quotaResetTimezone') }}</label>
<select :value="resetTimezone || 'UTC'" @change="emit('update:resetTimezone', ($event.target as HTMLSelectElement).value)" class="input text-sm">
<option v-for="tz in timezoneOptions" :key="tz" :value="tz">{{ tz }} ({{ getTimezoneOffsetLabel(tz) }})</option>
</select>
</div>
<!-- Total quota -->
<QuotaDimensionRow
dim="total"

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
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]
}>()
</script>
@@ -43,7 +43,7 @@ const emit = defineEmits<{
/>
<select
:value="thresholdType || QUOTA_THRESHOLD_TYPE_FIXED"
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value)"
@change="emit('update:thresholdType', ($event.target as HTMLSelectElement).value as QuotaThresholdType)"
class="input py-1 text-xs w-[4.5rem] flex-shrink-0 text-center"
>
<option :value="QUOTA_THRESHOLD_TYPE_FIXED">$</option>

View File

@@ -313,10 +313,6 @@
<span class="text-gray-400">{{ t('usage.rate') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.rate_multiplier || 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.original') }}</span>
<span class="font-medium text-white">${{ tooltipData?.total_cost?.toFixed(6) || '0.000000' }}</span>
@@ -325,7 +321,12 @@
<span class="text-gray-400">{{ t('usage.userBilled') }}</span>
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
@@ -355,7 +356,8 @@ import { getBillingModeLabel, getBillingModeBadgeClass, BILLING_MODE_TOKEN, BILL
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
return base * (row.account_rate_multiplier ?? 1)
const result = base * (row.account_rate_multiplier ?? 1)
return Number.isNaN(result) ? 0 : result
}
import DataTable from '@/components/common/DataTable.vue'