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
This commit is contained in:
erio
2026-04-12 14:43:12 +08:00
parent 72c836141e
commit 8fe7110d7f
11 changed files with 70 additions and 15 deletions
+2 -2
View File
@@ -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()
@@ -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) {
+2 -1
View File
@@ -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()
}
+4 -1
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
+9 -2
View File
@@ -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
+3 -1
View File
@@ -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 {
+28 -2
View File
@@ -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.
+3 -2
View File
@@ -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
}
+6 -2
View File
@@ -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() {