"frontend/src/vscode:/vscode.git/clone" did not exist on "7c7292935e8cefb4f2a2ebbf732bd923cb55c8e7"
Commit f694afbb authored by erio's avatar erio
Browse files

feat(notify): add percentage threshold type for balance low notification

- Add threshold_type field (fixed/percentage) to system and user settings
- Add total_recharged field to users table, auto-incremented on balance credit
- Percentage mode: effective threshold = total_recharged × percentage / 100
- User-level threshold_type inherits from system default when not set
- Update admin settings UI with radio selector (fixed amount / percentage)
- Migration: 102_add_balance_notify_threshold_type.sql
parent d0674e0f
...@@ -32,8 +32,10 @@ type User struct { ...@@ -32,8 +32,10 @@ type User struct {
// 余额不足通知 // 余额不足通知
BalanceNotifyEnabled bool BalanceNotifyEnabled bool
BalanceNotifyThresholdType string // "fixed" (default) | "percentage"
BalanceNotifyThreshold *float64 BalanceNotifyThreshold *float64
BalanceNotifyExtraEmails []string BalanceNotifyExtraEmails []string
TotalRecharged float64
APIKeys []APIKey APIKeys []APIKey
Subscriptions []UserSubscription Subscriptions []UserSubscription
......
...@@ -66,6 +66,7 @@ type UpdateProfileRequest struct { ...@@ -66,6 +66,7 @@ type UpdateProfileRequest struct {
Username *string `json:"username"` Username *string `json:"username"`
Concurrency *int `json:"concurrency"` Concurrency *int `json:"concurrency"`
BalanceNotifyEnabled *bool `json:"balance_notify_enabled"` BalanceNotifyEnabled *bool `json:"balance_notify_enabled"`
BalanceNotifyThresholdType *string `json:"balance_notify_threshold_type"`
BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"` BalanceNotifyThreshold *float64 `json:"balance_notify_threshold"`
} }
...@@ -143,6 +144,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat ...@@ -143,6 +144,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat
if req.BalanceNotifyEnabled != nil { if req.BalanceNotifyEnabled != nil {
user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled
} }
if req.BalanceNotifyThresholdType != nil {
user.BalanceNotifyThresholdType = *req.BalanceNotifyThresholdType
}
if req.BalanceNotifyThreshold != nil { if req.BalanceNotifyThreshold != nil {
if *req.BalanceNotifyThreshold <= 0 { if *req.BalanceNotifyThreshold <= 0 {
user.BalanceNotifyThreshold = nil // clear to system default user.BalanceNotifyThreshold = nil // clear to system default
......
-- Add threshold type support (fixed / percentage) to balance notification
ALTER TABLE users ADD COLUMN IF NOT EXISTS balance_notify_threshold_type VARCHAR(10) NOT NULL DEFAULT 'fixed';
-- Track cumulative recharge amount for percentage threshold calculation
ALTER TABLE users ADD COLUMN IF NOT EXISTS total_recharged DECIMAL(20,8) NOT NULL DEFAULT 0;
...@@ -137,6 +137,7 @@ export interface SystemSettings { ...@@ -137,6 +137,7 @@ export interface SystemSettings {
// Balance & quota notification // Balance & quota notification
balance_low_notify_enabled: boolean balance_low_notify_enabled: boolean
balance_low_notify_threshold_type: 'fixed' | 'percentage'
balance_low_notify_threshold: number balance_low_notify_threshold: number
account_quota_notify_emails: string[] account_quota_notify_emails: string[]
} }
...@@ -240,6 +241,7 @@ export interface UpdateSettingsRequest { ...@@ -240,6 +241,7 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_window_mode?: string payment_cancel_rate_limit_window_mode?: string
// Balance & quota notification // Balance & quota notification
balance_low_notify_enabled?: boolean balance_low_notify_enabled?: boolean
balance_low_notify_threshold_type?: 'fixed' | 'percentage'
balance_low_notify_threshold?: number balance_low_notify_threshold?: number
account_quota_notify_emails?: string[] account_quota_notify_emails?: string[]
} }
......
...@@ -4633,8 +4633,12 @@ export default { ...@@ -4633,8 +4633,12 @@ export default {
title: 'Balance Low Notification', title: 'Balance Low Notification',
description: 'Send email notification when user balance falls below threshold', description: 'Send email notification when user balance falls below threshold',
enabled: 'Enable Balance Low Notification', enabled: 'Enable Balance Low Notification',
thresholdType: 'Threshold Type',
typeFixed: 'Fixed Amount',
typePercentage: 'Percentage of Recharged',
threshold: 'Default Threshold', threshold: 'Default Threshold',
thresholdHint: 'Used when user has not set a custom value', thresholdHint: 'Used when user has not set a custom value',
percentageHint: 'Notify when balance falls below this percentage of total recharged amount',
thresholdPlaceholder: 'Enter amount', thresholdPlaceholder: 'Enter amount',
}, },
quotaNotify: { quotaNotify: {
......
...@@ -4797,8 +4797,12 @@ export default { ...@@ -4797,8 +4797,12 @@ export default {
title: '余额不足提醒', title: '余额不足提醒',
description: '当用户余额低于阈值时发送邮件提醒', description: '当用户余额低于阈值时发送邮件提醒',
enabled: '启用余额不足提醒', enabled: '启用余额不足提醒',
threshold: '默认提醒阈值', thresholdType: '阈值类型',
typeFixed: '固定金额',
typePercentage: '充值百分比',
threshold: '提醒阈值',
thresholdHint: '用户未自定义时使用此值', thresholdHint: '用户未自定义时使用此值',
percentageHint: '当余额低于累计充值额的此百分比时提醒',
thresholdPlaceholder: '输入金额', thresholdPlaceholder: '输入金额',
}, },
quotaNotify: { quotaNotify: {
......
...@@ -2660,6 +2660,90 @@ ...@@ -2660,6 +2660,90 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Balance Low Notification -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.balanceNotify.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.balanceNotify.description') }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div class="flex items-center justify-between">
<label class="mb-0 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.enabled') }}</label>
<Toggle v-model="form.balance_low_notify_enabled" />
</div>
<div v-if="form.balance_low_notify_enabled" class="space-y-3">
<!-- Threshold type selector -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.thresholdType') }}</label>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input type="radio" v-model="form.balance_low_notify_threshold_type" value="fixed" class="accent-primary-500" />
{{ t('admin.settings.balanceNotify.typeFixed') }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input type="radio" v-model="form.balance_low_notify_threshold_type" value="percentage" class="accent-primary-500" />
{{ t('admin.settings.balanceNotify.typePercentage') }}
</label>
</div>
</div>
<!-- Threshold value -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.balanceNotify.threshold') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
{{ form.balance_low_notify_threshold_type === 'percentage' ? '%' : '$' }}
</span>
<input
v-model.number="form.balance_low_notify_threshold"
type="number"
:min="0"
:max="form.balance_low_notify_threshold_type === 'percentage' ? 100 : undefined"
:step="form.balance_low_notify_threshold_type === 'percentage' ? 1 : 0.01"
class="input pl-7"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ form.balance_low_notify_threshold_type === 'percentage'
? t('admin.settings.balanceNotify.percentageHint')
: t('admin.settings.balanceNotify.thresholdHint') }}
</p>
</div>
</div>
</div>
</div>
<!-- Account Quota Notification -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h3 class="text-base font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.quotaNotify.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.quotaNotify.description') }}
</p>
</div>
<div class="px-6 py-6 space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.settings.quotaNotify.emails') }}</label>
<div class="space-y-2">
<div v-for="(_, index) in (form.account_quota_notify_emails || [])" :key="index" class="flex items-center gap-2">
<input v-model="form.account_quota_notify_emails[index]" type="email" class="input flex-1" />
<button @click="form.account_quota_notify_emails.splice(index, 1)" class="btn btn-secondary px-2" type="button">
<Icon name="x" size="xs" class="h-4 w-4" />
</button>
</div>
<button @click="addQuotaNotifyEmail" class="btn btn-secondary btn-sm" type="button">
+ {{ t('admin.settings.quotaNotify.addEmail') }}
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.quotaNotify.emailsHint') }}</p>
</div>
</div>
</div>
</div><!-- /Tab: Email --> </div><!-- /Tab: Email -->
<!-- Tab: Backup --> <!-- Tab: Backup -->
...@@ -2939,7 +3023,12 @@ const form = reactive<SettingsForm>({ ...@@ -2939,7 +3023,12 @@ const form = reactive<SettingsForm>({
// Gateway forwarding behavior // Gateway forwarding behavior
enable_fingerprint_unification: true, enable_fingerprint_unification: true,
enable_metadata_passthrough: false, enable_metadata_passthrough: false,
enable_cch_signing: false enable_cch_signing: false,
// Balance & quota notification
balance_low_notify_enabled: false,
balance_low_notify_threshold_type: 'fixed' as 'fixed' | 'percentage',
balance_low_notify_threshold: 0,
account_quota_notify_emails: [] as string[]
}) })
// Proxies for web search emulation ProxySelector // Proxies for web search emulation ProxySelector
...@@ -3149,6 +3238,14 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) { ...@@ -3149,6 +3238,14 @@ function handleRegistrationEmailSuffixWhitelistPaste(event: ClipboardEvent) {
} }
} }
// Quota notify email helpers
const addQuotaNotifyEmail = () => {
if (!form.account_quota_notify_emails) {
form.account_quota_notify_emails = []
}
form.account_quota_notify_emails.push('')
}
// LinuxDo OAuth redirect URL suggestion // LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => { const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return '' if (typeof window === 'undefined') return ''
...@@ -3488,6 +3585,11 @@ async function saveSettings() { ...@@ -3488,6 +3585,11 @@ async function saveSettings() {
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1, payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit, payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode, payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
// Balance & quota notification
balance_low_notify_enabled: form.balance_low_notify_enabled,
balance_low_notify_threshold_type: form.balance_low_notify_threshold_type,
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e: string) => e.trim() !== ''),
} }
const updated = await adminAPI.settings.updateSettings(payload) const updated = await adminAPI.settings.updateSettings(payload)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment