From 8fe7110d7fedd92bce7f89a690a76cbdee684bbc Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 12 Apr 2026 14:43:12 +0800 Subject: [PATCH] fix: address audit findings for websearch and balance notification - Fix GetByKeyForAuth not selecting balance notify fields (notifications never triggered in gateway path) - Fix provider-level ProxyURL never resolved: inject ProxyRepository into SettingService, resolve proxy URLs when building Manager - Fix admin manual balance adjustment not updating total_recharged - Add threshold_type input validation (reject invalid values) - Fix user threshold_type inheritance: custom threshold defaults to "fixed" instead of inheriting global type (prevents $5 being treated as 5%) - Add try-catch for clipboard.writeText (fails on non-HTTPS) - Add SetTotalRecharged to user Update for admin balance operations --- backend/cmd/server/wire_gen.go | 4 +-- backend/internal/repository/api_key_repo.go | 5 ++++ backend/internal/repository/user_repo.go | 3 +- backend/internal/server/http.go | 5 +++- backend/internal/service/admin_service.go | 6 ++++ .../service/balance_notify_service.go | 4 +-- backend/internal/service/setting_service.go | 11 +++++-- backend/internal/service/user_service.go | 4 ++- backend/internal/service/websearch_config.go | 30 +++++++++++++++++-- backend/internal/service/wire.go | 5 ++-- frontend/src/views/admin/SettingsView.vue | 8 +++-- 11 files changed, 70 insertions(+), 15 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 00e3923f5b..cff8a459f2 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -50,7 +50,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { refreshTokenCache := repository.NewRefreshTokenCache(redisClient) settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) - settingService := service.ProvideSettingService(settingRepository, groupRepository, configConfig) + proxyRepository := repository.NewProxyRepository(client, db) + settingService := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig) emailCache := repository.NewEmailCache(redisClient) emailService := service.NewEmailService(settingRepository, emailCache) turnstileVerifier := repository.NewTurnstileVerifier() @@ -100,7 +101,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) - proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) privacyClientFactory := providePrivacyClientFactory() diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 4ecab47abe..11eac7a89c 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -143,6 +143,11 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se user.FieldRole, user.FieldBalance, user.FieldConcurrency, + user.FieldBalanceNotifyEnabled, + user.FieldBalanceNotifyThresholdType, + user.FieldBalanceNotifyThreshold, + user.FieldBalanceNotifyExtraEmails, + user.FieldTotalRecharged, ) }). WithGroup(func(q *dbent.GroupQuery) { diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 63168fb111..1792ef8dbf 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -150,7 +150,8 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled). SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType). SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold). - SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)) + SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)). + SetTotalRecharged(userIn.TotalRecharged) if userIn.BalanceNotifyThreshold == nil { updateOp = updateOp.ClearBalanceNotifyThreshold() } diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index ba45c31beb..5165b05951 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -59,7 +59,7 @@ func ProvideRouter( } // Wire up websearch Manager builder so it initializes on startup and rebuilds on config save. - settingService.SetWebSearchManagerBuilder(context.Background(), func(cfg *service.WebSearchEmulationConfig) { + settingService.SetWebSearchManagerBuilder(context.Background(), func(cfg *service.WebSearchEmulationConfig, proxyURLs map[int64]string) { if cfg == nil || !cfg.Enabled || len(cfg.Providers) == 0 { service.SetWebSearchManager(nil) return @@ -80,6 +80,9 @@ func ProvideRouter( } if p.ProxyID != nil { pc.ProxyID = *p.ProxyID + if u, ok := proxyURLs[*p.ProxyID]; ok { + pc.ProxyURL = u + } } configs = append(configs, pc) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 97b42c2458..a4e22b228b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -709,6 +709,12 @@ func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) } + // Track cumulative recharge for percentage-based balance notifications + balanceDelta := user.Balance - oldBalance + if balanceDelta > 0 { + user.TotalRecharged += balanceDelta + } + if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } diff --git a/backend/internal/service/balance_notify_service.go b/backend/internal/service/balance_notify_service.go index 7fbdd25403..e1f6bd8bbc 100644 --- a/backend/internal/service/balance_notify_service.go +++ b/backend/internal/service/balance_notify_service.go @@ -77,12 +77,12 @@ func (s *BalanceNotifyService) CheckBalanceAfterDeduction(ctx context.Context, u } // resolveEffectiveThreshold computes the actual USD threshold based on type and user settings. +// When user sets a custom threshold, their type is used independently (defaults to "fixed" if unset). func (s *BalanceNotifyService) resolveEffectiveThreshold(user *User, globalType string, globalValue float64) float64 { - // User-level override takes full precedence if user.BalanceNotifyThreshold != nil { thresholdType := user.BalanceNotifyThresholdType if thresholdType == "" { - thresholdType = globalType + thresholdType = ThresholdTypeFixed // user custom value defaults to fixed, not inherited } return computeThreshold(thresholdType, *user.BalanceNotifyThreshold, user.TotalRecharged) } diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index a584b485f0..9b307426fc 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -100,12 +100,14 @@ type DefaultSubscriptionGroupReader interface { } // WebSearchManagerBuilder creates a websearch.Manager from config (injected by infra layer). -type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig) +// proxyURLs maps proxy ID to resolved URL for provider-level proxy support. +type WebSearchManagerBuilder func(cfg *WebSearchEmulationConfig, proxyURLs map[int64]string) // SettingService 系统设置服务 type SettingService struct { settingRepo SettingRepository defaultSubGroupReader DefaultSubscriptionGroupReader + proxyRepo ProxyRepository // for resolving websearch provider proxy URLs cfg *config.Config onUpdate func() // Callback when settings are updated (for cache invalidation) version string // Application version @@ -125,6 +127,11 @@ func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscri s.defaultSubGroupReader = reader } +// SetProxyRepository injects a proxy repo for resolving websearch provider proxy URLs. +func (s *SettingService) SetProxyRepository(repo ProxyRepository) { + s.proxyRepo = repo +} + // GetAllSettings 获取所有系统设置 func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) { settings, err := s.settingRepo.GetAll(ctx) @@ -602,7 +609,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Balance low notification updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled) thresholdType := settings.BalanceLowNotifyThresholdType - if thresholdType == "" { + if thresholdType != ThresholdTypeFixed && thresholdType != ThresholdTypePercentage { thresholdType = ThresholdTypeFixed } updates[SettingKeyBalanceLowNotifyThresholdType] = thresholdType diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 4669cb2b7d..26021a9bde 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -145,7 +145,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, req Updat user.BalanceNotifyEnabled = *req.BalanceNotifyEnabled } if req.BalanceNotifyThresholdType != nil { - user.BalanceNotifyThresholdType = *req.BalanceNotifyThresholdType + if *req.BalanceNotifyThresholdType == ThresholdTypeFixed || *req.BalanceNotifyThresholdType == ThresholdTypePercentage { + user.BalanceNotifyThresholdType = *req.BalanceNotifyThresholdType + } } if req.BalanceNotifyThreshold != nil { if *req.BalanceNotifyThreshold <= 0 { diff --git a/backend/internal/service/websearch_config.go b/backend/internal/service/websearch_config.go index 626715a058..346faf1f7c 100644 --- a/backend/internal/service/websearch_config.go +++ b/backend/internal/service/websearch_config.go @@ -205,7 +205,7 @@ func (s *SettingService) SetWebSearchManagerBuilder(ctx context.Context, builder s.rebuildWebSearchManager(ctx) } -// rebuildWebSearchManager reads the current config and invokes the builder. +// rebuildWebSearchManager reads the current config, resolves proxy URLs, and invokes the builder. func (s *SettingService) rebuildWebSearchManager(ctx context.Context) { if s.webSearchManagerBuilder == nil { return @@ -215,7 +215,33 @@ func (s *SettingService) rebuildWebSearchManager(ctx context.Context) { SetWebSearchManager(nil) return } - s.webSearchManagerBuilder(cfg) + proxyURLs := s.resolveProviderProxyURLs(ctx, cfg) + s.webSearchManagerBuilder(cfg, proxyURLs) +} + +// resolveProviderProxyURLs collects proxy IDs from providers and resolves them to URLs. +func (s *SettingService) resolveProviderProxyURLs(ctx context.Context, cfg *WebSearchEmulationConfig) map[int64]string { + if cfg == nil || s.proxyRepo == nil { + return nil + } + var ids []int64 + for _, p := range cfg.Providers { + if p.ProxyID != nil && *p.ProxyID > 0 { + ids = append(ids, *p.ProxyID) + } + } + if len(ids) == 0 { + return nil + } + proxies, err := s.proxyRepo.ListByIDs(ctx, ids) + if err != nil { + return nil + } + result := make(map[int64]string, len(proxies)) + for _, px := range proxies { + result[px.ID] = px.URL() + } + return result } // WebSearchTestResult holds the result of a search test. diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 2827f135aa..b4e330391b 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -373,10 +373,11 @@ func ProvideBackupService( return svc } -// ProvideSettingService wires SettingService with group reader for default subscription validation. -func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, cfg *config.Config) *SettingService { +// ProvideSettingService wires SettingService with group reader and proxy repo. +func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupRepository, proxyRepo ProxyRepository, cfg *config.Config) *SettingService { svc := NewSettingService(settingRepo, cfg) svc.SetDefaultSubscriptionGroupReader(groupRepo) + svc.SetProxyRepository(proxyRepo) return svc } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index af84b67de4..50b532fbea 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3109,8 +3109,12 @@ async function copyApiKey(idx: number) { appStore.showError(t('admin.settings.webSearchEmulation.apiKeyPlaceholder')) return } - await navigator.clipboard.writeText(key) - appStore.showSuccess(t('admin.settings.webSearchEmulation.copied')) + try { + await navigator.clipboard.writeText(key) + appStore.showSuccess(t('admin.settings.webSearchEmulation.copied')) + } catch { + appStore.showError(t('common.error')) + } } async function testWebSearchProvider() {