From 7c7292935e8cefb4f2a2ebbf732bd923cb55c8e7 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 14 Apr 2026 08:03:27 +0800 Subject: [PATCH] feat: websearch quota enhancements and balance notify hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuotaLimit changed to *int64 (null=unlimited, >0=limited) - Add reset-usage endpoint (POST /admin/settings/web-search-emulation/reset-usage) - Show quota usage in header always (collapsed and expanded) - Add reset quota button in expanded provider view - Quota input: empty=unlimited with ∞ placeholder, must be >0 if set - Add email verification hint on balance notify card --- backend/cmd/server/VERSION | 2 +- .../internal/handler/admin/setting_handler.go | 23 +++++++++- backend/internal/pkg/websearch/manager.go | 9 ++++ backend/internal/server/http.go | 9 +++- backend/internal/server/routes/admin.go | 1 + backend/internal/service/websearch_config.go | 15 +++++-- .../internal/service/websearch_config_test.go | 18 ++++---- frontend/src/api/admin/settings.ts | 11 ++++- .../user/profile/ProfileBalanceNotifyCard.vue | 1 + frontend/src/i18n/locales/en.ts | 9 +++- frontend/src/i18n/locales/zh.ts | 9 +++- frontend/src/views/admin/SettingsView.vue | 42 +++++++++++++++---- 12 files changed, 121 insertions(+), 28 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 5657b5e3..630554d9 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.112.3 +0.1.112.4 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index b50cad96..9b49150c 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1962,7 +1962,28 @@ func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, service.SanitizeWebSearchConfig(c.Request.Context(), updated)) + response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated)) +} + +// ResetWebSearchUsage 重置指定 provider 的配额用量 +// POST /api/v1/admin/settings/web-search-emulation/reset-usage +func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) { + var req struct { + ProviderType string `json:"provider_type"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.ProviderType == "" { + response.BadRequest(c, "provider_type is required") + return + } + if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, nil) } // TestWebSearchEmulation 测试 Web Search 搜索 diff --git a/backend/internal/pkg/websearch/manager.go b/backend/internal/pkg/websearch/manager.go index 61faa616..307aa1e9 100644 --- a/backend/internal/pkg/websearch/manager.go +++ b/backend/internal/pkg/websearch/manager.go @@ -447,6 +447,15 @@ func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 { return result } +// ResetUsage deletes the Redis quota key for the given provider, resetting usage to 0. +func (m *Manager) ResetUsage(ctx context.Context, providerType string) error { + if m.redis == nil { + return nil + } + key := quotaRedisKey(providerType) + return m.redis.Del(ctx, key).Err() +} + // --- Provider factory --- func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider { diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index d203bab2..023e40bb 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -73,7 +73,7 @@ func ProvideRouter( pc := websearch.ProviderConfig{ Type: p.Type, APIKey: p.APIKey, - QuotaLimit: p.QuotaLimit, + QuotaLimit: derefInt64(p.QuotaLimit), ExpiresAt: p.ExpiresAt, } if p.SubscribedAt != nil { @@ -141,3 +141,10 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { // 不设置 ReadTimeout,因为大请求体可能需要较长时间读取 } } + +func derefInt64(p *int64) int64 { + if p == nil { + return 0 + } + return *p +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 0a7b7a8b..9af0fd8e 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -411,6 +411,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig) adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig) adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation) + adminSettings.POST("/web-search-emulation/reset-usage", h.Admin.Setting.ResetWebSearchUsage) } } diff --git a/backend/internal/service/websearch_config.go b/backend/internal/service/websearch_config.go index 239e882a..f528a35b 100644 --- a/backend/internal/service/websearch_config.go +++ b/backend/internal/service/websearch_config.go @@ -24,7 +24,7 @@ type WebSearchProviderConfig struct { Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses APIKeyConfigured bool `json:"api_key_configured"` // read-only mask - QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited + QuotaLimit *int64 `json:"quota_limit"` // nil = unlimited, >0 = limited SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current usage from Redis ProxyID *int64 `json:"proxy_id"` // optional proxy association @@ -52,8 +52,8 @@ func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error { if !validProviderTypes[p.Type] { return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type) } - if p.QuotaLimit < 0 { - return fmt.Errorf("provider[%d]: quota_limit must be >= 0", i) + if p.QuotaLimit != nil && *p.QuotaLimit < 0 { + return fmt.Errorf("provider[%d]: quota_limit must be > 0 or null", i) } if seen[p.Type] { return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type) @@ -299,6 +299,15 @@ func PopulateWebSearchUsage(ctx context.Context, cfg *WebSearchEmulationConfig) return &out } +// ResetWebSearchUsage deletes the Redis quota key for the given provider type. +func ResetWebSearchUsage(ctx context.Context, providerType string) error { + mgr := getWebSearchManager() + if mgr == nil { + return fmt.Errorf("web search manager not initialized") + } + return mgr.ResetUsage(ctx, providerType) +} + // SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated. func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig { if cfg == nil { diff --git a/backend/internal/service/websearch_config_test.go b/backend/internal/service/websearch_config_test.go index 4aea98b7..8cd50d0d 100644 --- a/backend/internal/service/websearch_config_test.go +++ b/backend/internal/service/websearch_config_test.go @@ -17,8 +17,8 @@ func TestValidateWebSearchConfig_Valid(t *testing.T) { cfg := &WebSearchEmulationConfig{ Enabled: true, Providers: []WebSearchProviderConfig{ - {Type: "brave", QuotaLimit: 1000}, - {Type: "tavily", QuotaLimit: 500}, + {Type: "brave", QuotaLimit: int64Ptr(1000)}, + {Type: "tavily", QuotaLimit: int64Ptr(500)}, }, } require.NoError(t, validateWebSearchConfig(cfg)) @@ -42,9 +42,9 @@ func TestValidateWebSearchConfig_InvalidType(t *testing.T) { func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) { cfg := &WebSearchEmulationConfig{ - Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: -1}}, + Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: int64Ptr(-1)}}, } - require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be >= 0") + require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be > 0 or null") } func TestValidateWebSearchConfig_DuplicateType(t *testing.T) { @@ -57,9 +57,9 @@ func TestValidateWebSearchConfig_DuplicateType(t *testing.T) { require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type") } -func TestValidateWebSearchConfig_ZeroQuotaLimit(t *testing.T) { +func TestValidateWebSearchConfig_NilQuotaLimit(t *testing.T) { cfg := &WebSearchEmulationConfig{ - Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: 0}}, + Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: nil}}, } require.NoError(t, validateWebSearchConfig(cfg)) } @@ -92,7 +92,7 @@ func TestParseWebSearchConfigJSON_BackwardCompatibility(t *testing.T) { cfg := parseWebSearchConfigJSON(raw) require.True(t, cfg.Enabled) require.Len(t, cfg.Providers, 1) - require.Equal(t, int64(1000), cfg.Providers[0].QuotaLimit) + require.Equal(t, int64(1000), *cfg.Providers[0].QuotaLimit) } // --- SanitizeWebSearchConfig --- @@ -126,12 +126,12 @@ func TestSanitizeWebSearchConfig_PreservesOtherFields(t *testing.T) { cfg := &WebSearchEmulationConfig{ Enabled: true, Providers: []WebSearchProviderConfig{ - {Type: "brave", APIKey: "secret", QuotaLimit: 1000}, + {Type: "brave", APIKey: "secret", QuotaLimit: int64Ptr(1000)}, }, } out := SanitizeWebSearchConfig(context.Background(), cfg) require.True(t, out.Enabled) - require.Equal(t, int64(1000), out.Providers[0].QuotaLimit) + require.Equal(t, int64(1000), *out.Providers[0].QuotaLimit) } func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) { diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4b5eb242..aa1d0f82 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -502,7 +502,7 @@ export interface WebSearchProviderConfig { type: 'brave' | 'tavily' api_key: string api_key_configured: boolean - quota_limit: number + quota_limit: number | null subscribed_at: number | null quota_used?: number proxy_id: number | null @@ -547,6 +547,12 @@ export async function testWebSearchEmulation( return data } +export async function resetWebSearchUsage( + payload: { provider_type: string } +): Promise { + await apiClient.post('/admin/settings/web-search-emulation/reset-usage', payload) +} + export const settingsAPI = { getSettings, updateSettings, @@ -565,7 +571,8 @@ export const settingsAPI = { updateBetaPolicySettings, getWebSearchEmulationConfig, updateWebSearchEmulationConfig, - testWebSearchEmulation + testWebSearchEmulation, + resetWebSearchUsage } export default settingsAPI diff --git a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue index 3a84fd6b..c4d04153 100644 --- a/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue +++ b/frontend/src/components/user/profile/ProfileBalanceNotifyCard.vue @@ -48,6 +48,7 @@
+

{{ t('profile.balanceNotify.extraEmailsHint') }}

diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index c8acf6c0..9baddc43 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -914,6 +914,7 @@ export default { thresholdPlaceholder: 'Enter amount', systemDefault: 'System Default', extraEmails: 'Notification Emails', + extraEmailsHint: 'You must add and verify an email address to receive low balance alerts', primaryEmail: 'Primary', noExtraEmails: 'No extra notification emails', enterEmail: 'Enter email address', @@ -4435,10 +4436,14 @@ export default { copyApiKey: 'Copy', copied: 'Copied', quotaLimit: 'Quota Limit', - quotaLimitHint: '0 = unlimited', + quotaLimitHint: 'Leave empty for unlimited; must be > 0 if set', + quotaLimitMustBePositive: 'Quota limit must be greater than 0', subscribedAt: 'Subscribed At', - subscribedAtHint: 'Quota resets monthly from this date', + subscribedAtHint: 'Quota resets monthly from this date; leave empty to disable auto-reset', quotaUsage: 'Usage', + resetUsage: 'Reset', + resetUsageConfirm: 'Reset usage counter for this provider?', + resetUsageSuccess: 'Usage counter reset', proxy: 'Proxy', removeProvider: 'Remove', noProviders: 'No search providers configured', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 499ed9cb..af8da265 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -918,6 +918,7 @@ export default { thresholdPlaceholder: '输入金额', systemDefault: '系统默认值', extraEmails: '通知邮箱', + extraEmailsHint: '必须添加并验证邮箱后,余额不足时才能收到提醒邮件', primaryEmail: '主邮箱', noExtraEmails: '暂无额外通知邮箱', enterEmail: '输入邮箱地址', @@ -4597,10 +4598,14 @@ export default { copyApiKey: '复制', copied: '已复制', quotaLimit: '配额上限', - quotaLimitHint: '0 表示无限制', + quotaLimitHint: '留空表示无限制;填写时必须大于 0', + quotaLimitMustBePositive: '配额上限必须大于 0', subscribedAt: '订阅时间', - subscribedAtHint: '配额从此日期起每月自动重置', + subscribedAtHint: '配额从此日期起每月自动重置;留空则不自动重置', quotaUsage: '用量', + resetUsage: '重置', + resetUsageConfirm: '确定要重置此服务商的用量计数吗?', + resetUsageSuccess: '用量已重置', proxy: '代理', removeProvider: '删除', noProviders: '未配置搜索服务商', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 12f67187..c57d2033 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1774,9 +1774,9 @@ class="w-36" @click.stop /> - - - {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit > 0 ? provider.quota_limit : '∞' }} + + + {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit != null && provider.quota_limit > 0 ? provider.quota_limit : '∞' }} {{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }} @@ -1835,7 +1835,7 @@
- +

{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}

@@ -1853,7 +1853,7 @@
{{ t('admin.settings.webSearchEmulation.quotaUsage') }}: -
+
- {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit > 0 ? provider.quota_limit : '∞' }} + {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit != null && provider.quota_limit > 0 ? provider.quota_limit : '∞' }} +
@@ -3118,6 +3126,19 @@ function quotaPercentage(provider: WebSearchProviderConfig): number { return ((provider.quota_used ?? 0) / provider.quota_limit) * 100 } +async function resetWebSearchUsage(idx: number) { + const provider = webSearchConfig.providers[idx] + if (!provider) return + if (!confirm(t('admin.settings.webSearchEmulation.resetUsageConfirm'))) return + try { + await adminAPI.settings.resetWebSearchUsage({ provider_type: provider.type }) + provider.quota_used = 0 + appStore.showSuccess(t('admin.settings.webSearchEmulation.resetUsageSuccess')) + } catch (err: unknown) { + appStore.showError(extractApiErrorMessage(err, t('common.error'))) + } +} + async function copyApiKey(idx: number) { const key = webSearchConfig.providers[idx]?.api_key if (!key) { @@ -3167,9 +3188,16 @@ async function loadWebSearchConfig() { async function saveWebSearchConfig(): Promise { try { + for (const p of webSearchConfig.providers) { + const raw = p.quota_limit + if (raw != null && Number(raw) !== 0 && Number(raw) < 1) { + appStore.showError(t('admin.settings.webSearchEmulation.quotaLimitMustBePositive')) + return false + } + } const providers = webSearchConfig.providers.map((p: WebSearchProviderConfig) => ({ ...p, - quota_limit: typeof p.quota_limit === 'number' && p.quota_limit > 0 ? p.quota_limit : 0, + quota_limit: Number(p.quota_limit) > 0 ? Number(p.quota_limit) : null, })) await adminAPI.settings.updateWebSearchEmulationConfig({ enabled: webSearchConfig.enabled,