From d0674e0ff94028f32c5f0882b16b66013719d5b0 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 12 Apr 2026 13:11:46 +0800 Subject: [PATCH] feat(websearch): settings UI overhaul and quota improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Priority field, auto load-balance by quota remaining - Replace QuotaRefreshInterval (daily/weekly/monthly) with SubscribedAt (subscription date, monthly lazy refresh via Redis TTL) - Add collapsible provider cards, API key show/copy, usage progress bar - Add test endpoint (POST /web-search-emulation/test) bypassing quota - Wire WebSearchManagerBuilder on startup (was never called before) - Fix nextMonthlyReset day-of-month overflow (Jan 31 → Feb 28) - Fix non-deterministic sort in selectByQuotaWeight - Map ProxyID in builder for provider-level proxy tracking - Fix frontend timezone drift in subscribed_at date picker - Fix provider deletion index shift for expandedProviders state --- .../internal/handler/admin/setting_handler.go | 86 +++-- backend/internal/pkg/websearch/manager.go | 173 ++++++--- .../internal/pkg/websearch/manager_test.go | 136 ++++--- backend/internal/server/http.go | 30 ++ backend/internal/server/routes/admin.go | 1 + backend/internal/service/websearch_config.go | 69 ++-- .../internal/service/websearch_config_test.go | 45 +-- frontend/src/api/admin/settings.ts | 22 +- frontend/src/i18n/locales/en.ts | 21 +- frontend/src/i18n/locales/zh.ts | 21 +- frontend/src/views/admin/SettingsView.vue | 351 ++++++++++++------ 11 files changed, 627 insertions(+), 328 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 459eade9..e5e024c6 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -175,9 +175,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { EnableFingerprintUnification: settings.EnableFingerprintUnification, EnableMetadataPassthrough: settings.EnableMetadataPassthrough, EnableCCHSigning: settings.EnableCCHSigning, - BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, - AccountQuotaNotifyEmails: settings.AccountQuotaNotifyEmails, + WebSearchEmulationEnabled: settings.WebSearchEmulationEnabled, PaymentEnabled: paymentCfg.Enabled, PaymentMinAmount: paymentCfg.MinAmount, PaymentMaxAmount: paymentCfg.MaxAmount, @@ -307,11 +305,6 @@ type UpdateSettingsRequest struct { EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"` EnableCCHSigning *bool `json:"enable_cch_signing"` - // Balance low notification - BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"` - BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"` - AccountQuotaNotifyEmails *[]string `json:"account_quota_notify_emails"` - // Payment configuration (integrated into settings, full replace) PaymentEnabled *bool `json:"payment_enabled"` PaymentMinAmount *float64 `json:"payment_min_amount"` @@ -889,24 +882,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.EnableCCHSigning }(), - BalanceLowNotifyEnabled: func() bool { - if req.BalanceLowNotifyEnabled != nil { - return *req.BalanceLowNotifyEnabled - } - return previousSettings.BalanceLowNotifyEnabled - }(), - BalanceLowNotifyThreshold: func() float64 { - if req.BalanceLowNotifyThreshold != nil { - return *req.BalanceLowNotifyThreshold - } - return previousSettings.BalanceLowNotifyThreshold - }(), - AccountQuotaNotifyEmails: func() []string { - if req.AccountQuotaNotifyEmails != nil { - return *req.AccountQuotaNotifyEmails - } - return previousSettings.AccountQuotaNotifyEmails - }(), } if err := h.settingService.UpdateSettings(c.Request.Context(), settings); err != nil { @@ -1053,9 +1028,6 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification, EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough, EnableCCHSigning: updatedSettings.EnableCCHSigning, - BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled, - BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold, - AccountQuotaNotifyEmails: updatedSettings.AccountQuotaNotifyEmails, PaymentEnabled: updatedPaymentCfg.Enabled, PaymentMinAmount: updatedPaymentCfg.MinAmount, PaymentMaxAmount: updatedPaymentCfg.MaxAmount, @@ -1876,3 +1848,59 @@ func (h *SettingHandler) UpdateStreamTimeoutSettings(c *gin.Context) { ThresholdWindowMinutes: updatedSettings.ThresholdWindowMinutes, }) } + +// GetWebSearchEmulationConfig 获取 Web Search 模拟配置 +// GET /api/v1/admin/settings/web-search-emulation +func (h *SettingHandler) GetWebSearchEmulationConfig(c *gin.Context) { + cfg, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, service.SanitizeWebSearchConfig(c.Request.Context(), cfg)) +} + +// UpdateWebSearchEmulationConfig 更新 Web Search 模拟配置 +// PUT /api/v1/admin/settings/web-search-emulation +func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) { + var cfg service.WebSearchEmulationConfig + if err := c.ShouldBindJSON(&cfg); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + if err := h.settingService.SaveWebSearchEmulationConfig(c.Request.Context(), &cfg); err != nil { + response.ErrorFrom(c, err) + return + } + + // Re-read (with sanitized api keys) to return current state + updated, err := h.settingService.GetWebSearchEmulationConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, service.SanitizeWebSearchConfig(c.Request.Context(), updated)) +} + +// TestWebSearchEmulation 测试 Web Search 搜索 +// POST /api/v1/admin/settings/web-search-emulation/test +func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) { + var req struct { + Query string `json:"query"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if strings.TrimSpace(req.Query) == "" { + req.Query = "搜索今年世界大事件" + } + + result, err := service.TestWebSearch(c.Request.Context(), req.Query) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} diff --git a/backend/internal/pkg/websearch/manager.go b/backend/internal/pkg/websearch/manager.go index 7db3d9a2..ae0683ad 100644 --- a/backend/internal/pkg/websearch/manager.go +++ b/backend/internal/pkg/websearch/manager.go @@ -19,26 +19,18 @@ import ( "github.com/redis/go-redis/v9" ) -// Quota refresh interval constants. -const ( - QuotaRefreshDaily = "daily" - QuotaRefreshWeekly = "weekly" - QuotaRefreshMonthly = "monthly" -) - // ProviderConfig holds the configuration for a single search provider. type ProviderConfig struct { - Type string `json:"type"` // ProviderTypeBrave | ProviderTypeTavily - APIKey string `json:"api_key"` // secret - Priority int `json:"priority"` // lower = higher priority - QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited - QuotaRefreshInterval string `json:"quota_refresh_interval"` // QuotaRefreshDaily / Weekly / Monthly - ProxyURL string `json:"-"` // resolved proxy URL (not persisted) - ProxyID int64 `json:"-"` // resolved proxy ID for unavailability tracking - ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration (unix seconds) + Type string `json:"type"` // ProviderTypeBrave | ProviderTypeTavily + APIKey string `json:"api_key"` // secret + QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited + SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly from this date + ProxyURL string `json:"-"` // resolved proxy URL (not persisted) + ProxyID int64 `json:"-"` // resolved proxy ID for unavailability tracking + ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration (unix seconds) } -// Manager selects providers by priority and tracks quota via Redis. +// Manager selects providers by quota-weighted load balancing and tracks quota via Redis. type Manager struct { configs []ProviderConfig redis *redis.Client @@ -58,6 +50,7 @@ const ( proxyUnavailableKey = "websearch:proxy_unavailable:%d" proxyUnavailableTTL = 5 * time.Minute quotaTTLBuffer = 24 * time.Hour + defaultQuotaTTL = 31*24*time.Hour + quotaTTLBuffer // fallback when no subscription date maxCachedClients = 100 ) @@ -80,14 +73,12 @@ return val `) // NewManager creates a Manager with the given provider configs and Redis client. +// Provider order is preserved as-is; selectByQuotaWeight handles load balancing. func NewManager(configs []ProviderConfig, redisClient *redis.Client) *Manager { - sorted := make([]ProviderConfig, len(configs)) - copy(sorted, configs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Priority < sorted[j].Priority - }) + copied := make([]ProviderConfig, len(configs)) + copy(copied, configs) return &Manager{ - configs: sorted, + configs: copied, redis: redisClient, clientCache: make(map[string]*http.Client), } @@ -162,21 +153,28 @@ type weighted struct { // Providers with quota_limit=0 (no limit set) get weight 0 and are placed last. // Among providers with quota, higher remaining quota = higher priority. func (m *Manager) selectByQuotaWeight(ctx context.Context, candidates []ProviderConfig) []ProviderConfig { + items := m.computeWeights(ctx, candidates) + withQuota, withoutQuota := partitionByQuota(items) + sortByStableRandomWeight(withQuota) + return mergeWeightedResults(withQuota, withoutQuota, len(candidates)) +} + +func (m *Manager) computeWeights(ctx context.Context, candidates []ProviderConfig) []weighted { items := make([]weighted, 0, len(candidates)) for _, cfg := range candidates { w := int64(0) if cfg.QuotaLimit > 0 { - used, _ := m.GetUsage(ctx, cfg.Type, cfg.QuotaRefreshInterval) - remaining := cfg.QuotaLimit - used - if remaining > 0 { + used, _ := m.GetUsage(ctx, cfg.Type) + if remaining := cfg.QuotaLimit - used; remaining > 0 { w = remaining } } items = append(items, weighted{cfg: cfg, weight: w}) } + return items +} - // Separate providers with quota (weight > 0) from those without (weight == 0) - var withQuota, withoutQuota []weighted +func partitionByQuota(items []weighted) (withQuota, withoutQuota []weighted) { for _, item := range items { if item.weight > 0 { withQuota = append(withQuota, item) @@ -184,18 +182,26 @@ func (m *Manager) selectByQuotaWeight(ctx context.Context, candidates []Provider withoutQuota = append(withoutQuota, item) } } + return +} - // Within quota group: weighted random sort (higher remaining = more likely first) - if len(withQuota) > 1 { - sort.Slice(withQuota, func(i, j int) bool { - wi := float64(withQuota[i].weight) * (0.5 + rand.Float64()) - wj := float64(withQuota[j].weight) * (0.5 + rand.Float64()) - return wi > wj - }) +// sortByStableRandomWeight assigns a fixed random factor to each item before sorting, +// ensuring deterministic sort behavior (transitivity) within a single call. +func sortByStableRandomWeight(items []weighted) { + if len(items) <= 1 { + return } + factors := make([]float64, len(items)) + for i, item := range items { + factors[i] = float64(item.weight) * (0.5 + rand.Float64()) + } + sort.Slice(items, func(i, j int) bool { + return factors[i] > factors[j] + }) +} - // Build final order: quota providers first, then no-quota providers (original priority order) - result := make([]ProviderConfig, 0, len(candidates)) +func mergeWeightedResults(withQuota, withoutQuota []weighted, capacity int) []ProviderConfig { + result := make([]ProviderConfig, 0, capacity) for _, item := range withQuota { result = append(result, item.cfg) } @@ -294,8 +300,8 @@ func (m *Manager) tryReserveQuota(ctx context.Context, cfg ProviderConfig) (bool slog.Warn("websearch: Redis unavailable, quota check skipped", "provider", cfg.Type) return true, false } - key := quotaRedisKey(cfg.Type, cfg.QuotaRefreshInterval) - ttlSec := int(quotaTTL(cfg.QuotaRefreshInterval).Seconds()) + key := quotaRedisKey(cfg.Type) + ttlSec := int(quotaTTLFromSubscription(cfg.SubscribedAt).Seconds()) newVal, err := quotaIncrScript.Run(ctx, m.redis, []string{key}, ttlSec).Int64() if err != nil { slog.Warn("websearch: quota Lua INCR failed, allowing request", @@ -318,7 +324,7 @@ func (m *Manager) rollbackQuota(ctx context.Context, cfg ProviderConfig) { if cfg.QuotaLimit <= 0 || m.redis == nil { return } - key := quotaRedisKey(cfg.Type, cfg.QuotaRefreshInterval) + key := quotaRedisKey(cfg.Type) if err := m.redis.Decr(ctx, key).Err(); err != nil { slog.Warn("websearch: quota rollback DECR failed", "provider", cfg.Type, "error", err) @@ -327,6 +333,25 @@ func (m *Manager) rollbackQuota(ctx context.Context, cfg ProviderConfig) { // --- Search execution --- +// TestSearch executes a search using the first available provider without reserving quota. +// Intended for admin test functionality only. +func (m *Manager) TestSearch(ctx context.Context, req SearchRequest) (*SearchResponse, string, error) { + if strings.TrimSpace(req.Query) == "" { + return nil, "", fmt.Errorf("websearch: empty search query") + } + for _, cfg := range m.configs { + if !m.isProviderAvailable(cfg) { + continue + } + resp, err := m.executeSearch(ctx, cfg, req) + if err != nil { + continue + } + return resp, cfg.Type, nil + } + return nil, "", fmt.Errorf("websearch: no available provider") +} + func (m *Manager) executeSearch(ctx context.Context, cfg ProviderConfig, req SearchRequest) (*SearchResponse, error) { proxyURL := cfg.ProxyURL if req.ProxyURL != "" { @@ -384,11 +409,11 @@ func newHTTPClient(proxyURL string) (*http.Client, error) { } // GetUsage returns the current usage count for the given provider. -func (m *Manager) GetUsage(ctx context.Context, providerType, refreshInterval string) (int64, error) { +func (m *Manager) GetUsage(ctx context.Context, providerType string) (int64, error) { if m.redis == nil { return 0, nil } - key := quotaRedisKey(providerType, refreshInterval) + key := quotaRedisKey(providerType) val, err := m.redis.Get(ctx, key).Int64() if err == redis.Nil { return 0, nil @@ -400,7 +425,7 @@ func (m *Manager) GetUsage(ctx context.Context, providerType, refreshInterval st func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 { result := make(map[string]int64, len(m.configs)) for _, cfg := range m.configs { - used, _ := m.GetUsage(ctx, cfg.Type, cfg.QuotaRefreshInterval) + used, _ := m.GetUsage(ctx, cfg.Type) result[cfg.Type] = used } return result @@ -423,30 +448,56 @@ func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provide // --- Redis key helpers --- -func quotaRedisKey(providerType, refreshInterval string) string { - return quotaKeyPrefix + providerType + ":" + periodKey(refreshInterval) +func quotaRedisKey(providerType string) string { + return quotaKeyPrefix + providerType } -func periodKey(refreshInterval string) string { +// quotaTTLFromSubscription calculates the TTL for the quota counter based on +// the provider's subscription start date. Quota resets monthly from that date. +// When the Redis key expires naturally, the next INCR creates a fresh counter (lazy refresh). +func quotaTTLFromSubscription(subscribedAt *int64) time.Duration { + if subscribedAt == nil || *subscribedAt == 0 { + return defaultQuotaTTL + } + next := nextMonthlyReset(time.Unix(*subscribedAt, 0).UTC()) + ttl := time.Until(next) + quotaTTLBuffer + if ttl <= quotaTTLBuffer { + // Already past the reset — next cycle + ttl = defaultQuotaTTL + } + return ttl +} + +// nextMonthlyReset returns the next monthly reset time based on the subscription start date. +// E.g., subscribed on Jan 15 → resets on Feb 15, Mar 15, etc. +// Handles day-of-month overflow: Jan 31 → Feb 28 (not Mar 3). +func nextMonthlyReset(subscribedAt time.Time) time.Time { now := time.Now().UTC() - switch refreshInterval { - case QuotaRefreshDaily: - return now.Format("2006-01-02") - case QuotaRefreshWeekly: - year, week := now.ISOWeek() - return fmt.Sprintf("%d-W%02d", year, week) - default: - return now.Format("2006-01") + if subscribedAt.IsZero() { + return now.AddDate(0, 1, 0) } + months := (now.Year()-subscribedAt.Year())*12 + int(now.Month()-subscribedAt.Month()) + if months < 0 { + months = 0 + } + candidate := addMonthsClamped(subscribedAt, months) + if candidate.After(now) { + return candidate + } + return addMonthsClamped(subscribedAt, months+1) } -func quotaTTL(refreshInterval string) time.Duration { - switch refreshInterval { - case QuotaRefreshDaily: - return 24*time.Hour + quotaTTLBuffer - case QuotaRefreshWeekly: - return 7*24*time.Hour + quotaTTLBuffer - default: - return 31*24*time.Hour + quotaTTLBuffer +// addMonthsClamped adds N months to a date, clamping the day to the last day of the target month. +// E.g., Jan 31 + 1 month = Feb 28 (not Mar 3). +func addMonthsClamped(t time.Time, months int) time.Time { + y, m, d := t.Date() + targetMonth := time.Month(int(m) + months) + targetYear := y + int(targetMonth-1)/12 + targetMonth = (targetMonth-1)%12 + 1 + // Last day of the target month + lastDay := time.Date(targetYear, targetMonth+1, 0, 0, 0, 0, 0, time.UTC).Day() + if d > lastDay { + d = lastDay } + return time.Date(targetYear, targetMonth, d, 0, 0, 0, 0, time.UTC) } diff --git a/backend/internal/pkg/websearch/manager_test.go b/backend/internal/pkg/websearch/manager_test.go index d3cd29d6..a4beef68 100644 --- a/backend/internal/pkg/websearch/manager_test.go +++ b/backend/internal/pkg/websearch/manager_test.go @@ -12,14 +12,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewManager_SortsByPriority(t *testing.T) { +func TestNewManager_PreservesOrder(t *testing.T) { configs := []ProviderConfig{ - {Type: "brave", APIKey: "k3", Priority: 30}, - {Type: "tavily", APIKey: "k1", Priority: 10}, + {Type: "brave", APIKey: "k3"}, + {Type: "tavily", APIKey: "k1"}, } m := NewManager(configs, nil) - require.Equal(t, 10, m.configs[0].Priority) - require.Equal(t, 30, m.configs[1].Priority) + require.Equal(t, "brave", m.configs[0].Type) + require.Equal(t, "tavily", m.configs[1].Type) } func TestManager_SearchWithBestProvider_EmptyQuery(t *testing.T) { @@ -46,8 +46,7 @@ func TestManager_SearchWithBestProvider_SkipExpired(t *testing.T) { require.ErrorContains(t, err, "no available provider") } -func TestManager_SearchWithBestProvider_PriorityOrder(t *testing.T) { - // Create two mock servers that return different results +func TestManager_SearchWithBestProvider_UsesFirstAvailable(t *testing.T) { srvBrave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { resp := braveResponse{} resp.Web.Results = []braveResult{{URL: "https://brave.com", Title: "Brave", Description: "from brave"}} @@ -55,17 +54,15 @@ func TestManager_SearchWithBestProvider_PriorityOrder(t *testing.T) { })) defer srvBrave.Close() - // Override brave endpoint for test origURL := *braveSearchURL u, _ := http.NewRequest("GET", srvBrave.URL, nil) *braveSearchURL = *u.URL defer func() { *braveSearchURL = origURL }() m := NewManager([]ProviderConfig{ - {Type: "brave", APIKey: "k1", Priority: 1}, - {Type: "tavily", APIKey: "k2", Priority: 2}, + {Type: "brave", APIKey: "k1"}, + {Type: "tavily", APIKey: "k2"}, }, nil) - // Inject the test server's client m.clientCache[srvBrave.URL] = srvBrave.Client() m.clientCache[""] = srvBrave.Client() @@ -77,7 +74,6 @@ func TestManager_SearchWithBestProvider_PriorityOrder(t *testing.T) { } func TestManager_SearchWithBestProvider_NilRedis(t *testing.T) { - // With nil Redis, quota check is skipped (always allowed) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { resp := braveResponse{} resp.Web.Results = []braveResult{{URL: "https://test.com", Title: "Test", Description: "result"}} @@ -91,8 +87,8 @@ func TestManager_SearchWithBestProvider_NilRedis(t *testing.T) { defer func() { *braveSearchURL = origURL }() m := NewManager([]ProviderConfig{ - {Type: "brave", APIKey: "k", Priority: 1, QuotaLimit: 100}, - }, nil) // nil Redis + {Type: "brave", APIKey: "k", QuotaLimit: 100}, + }, nil) m.clientCache[""] = srv.Client() resp, _, err := m.SearchWithBestProvider(context.Background(), SearchRequest{Query: "test"}) @@ -102,51 +98,98 @@ func TestManager_SearchWithBestProvider_NilRedis(t *testing.T) { func TestManager_GetUsage_NilRedis(t *testing.T) { m := NewManager(nil, nil) - used, err := m.GetUsage(context.Background(), "brave", "monthly") + used, err := m.GetUsage(context.Background(), "brave") require.NoError(t, err) require.Equal(t, int64(0), used) } func TestManager_GetAllUsage_NilRedis(t *testing.T) { m := NewManager([]ProviderConfig{ - {Type: "brave", QuotaRefreshInterval: "monthly"}, + {Type: "brave"}, }, nil) usage := m.GetAllUsage(context.Background()) require.Equal(t, int64(0), usage["brave"]) } -// --- Key/TTL helpers --- +// --- Quota TTL from subscription --- -func TestQuotaTTL_Daily(t *testing.T) { - require.Equal(t, 24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshDaily)) +func TestQuotaTTLFromSubscription_NilSubscription(t *testing.T) { + ttl := quotaTTLFromSubscription(nil) + require.Equal(t, defaultQuotaTTL, ttl) } -func TestQuotaTTL_Weekly(t *testing.T) { - require.Equal(t, 7*24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshWeekly)) +func TestQuotaTTLFromSubscription_ZeroSubscription(t *testing.T) { + zero := int64(0) + ttl := quotaTTLFromSubscription(&zero) + require.Equal(t, defaultQuotaTTL, ttl) } -func TestQuotaTTL_Monthly(t *testing.T) { - require.Equal(t, 31*24*time.Hour+quotaTTLBuffer, quotaTTL(QuotaRefreshMonthly)) +func TestQuotaTTLFromSubscription_ValidSubscription(t *testing.T) { + // Subscribed 10 days ago — next reset in ~20 days + sub := time.Now().Add(-10 * 24 * time.Hour).Unix() + ttl := quotaTTLFromSubscription(&sub) + require.Greater(t, ttl, 15*24*time.Hour) // at least 15 days + require.Less(t, ttl, 25*24*time.Hour+quotaTTLBuffer) } -func TestPeriodKey_Daily(t *testing.T) { - key := periodKey(QuotaRefreshDaily) - require.Regexp(t, `^\d{4}-\d{2}-\d{2}$`, key) +func TestNextMonthlyReset_SubscribedRecentPast(t *testing.T) { + // Subscribed on the 10th of this month (always valid day) + now := time.Now().UTC() + sub := time.Date(now.Year(), now.Month(), 10, 0, 0, 0, 0, time.UTC) + next := nextMonthlyReset(sub) + require.True(t, next.After(now) || next.Equal(now), "next reset should be in the future or now") + require.True(t, next.Before(now.AddDate(0, 1, 1))) } -func TestPeriodKey_Weekly(t *testing.T) { - key := periodKey(QuotaRefreshWeekly) - require.Regexp(t, `^\d{4}-W\d{2}$`, key) +func TestNextMonthlyReset_SubscribedLongAgo(t *testing.T) { + // Subscribed 6 months ago on the 1st + sub := time.Now().UTC().AddDate(0, -6, 0) + sub = time.Date(sub.Year(), sub.Month(), 1, 0, 0, 0, 0, time.UTC) + next := nextMonthlyReset(sub) + require.True(t, next.After(time.Now().UTC())) + // Should be within the next 31 days + require.True(t, next.Before(time.Now().UTC().AddDate(0, 1, 1))) } -func TestPeriodKey_Monthly(t *testing.T) { - key := periodKey(QuotaRefreshMonthly) - require.Regexp(t, `^\d{4}-\d{2}$`, key) +func TestNextMonthlyReset_FutureSubscription(t *testing.T) { + sub := time.Now().UTC().AddDate(0, 0, 5) + next := nextMonthlyReset(sub) + require.True(t, next.After(time.Now().UTC())) } +func TestAddMonthsClamped_Jan31ToFeb(t *testing.T) { + sub := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) + next := addMonthsClamped(sub, 1) + require.Equal(t, time.Month(2), next.Month()) + require.Equal(t, 28, next.Day()) // Feb 28 (2026 is not a leap year) +} + +func TestAddMonthsClamped_Jan31ToFebLeapYear(t *testing.T) { + sub := time.Date(2028, 1, 31, 0, 0, 0, 0, time.UTC) + next := addMonthsClamped(sub, 1) + require.Equal(t, time.Month(2), next.Month()) + require.Equal(t, 29, next.Day()) // Feb 29 (2028 is a leap year) +} + +func TestAddMonthsClamped_Mar31ToApr(t *testing.T) { + sub := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC) + next := addMonthsClamped(sub, 1) + require.Equal(t, time.Month(4), next.Month()) + require.Equal(t, 30, next.Day()) // Apr has 30 days +} + +func TestAddMonthsClamped_NormalDay(t *testing.T) { + sub := time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC) + next := addMonthsClamped(sub, 1) + require.Equal(t, time.Month(2), next.Month()) + require.Equal(t, 15, next.Day()) // no clamping needed +} + +// --- Redis key --- + func TestQuotaRedisKey_Format(t *testing.T) { - key := quotaRedisKey("brave", QuotaRefreshDaily) - require.Contains(t, key, "websearch:quota:brave:") + key := quotaRedisKey("brave") + require.Equal(t, "websearch:quota:brave", key) } // --- isProviderAvailable --- @@ -173,9 +216,7 @@ func TestIsProviderAvailable_Valid(t *testing.T) { func TestResolveProxyID_AccountProxyOverrides(t *testing.T) { cfg := ProviderConfig{ProxyID: 42} - // account proxy present → return 0 (account proxy has no config-level ID) require.Equal(t, int64(0), resolveProxyID(cfg, "http://account-proxy:8080")) - // no account proxy → return provider's proxy ID require.Equal(t, int64(42), resolveProxyID(cfg, "")) } @@ -186,28 +227,23 @@ func TestIsProxyError_Nil(t *testing.T) { } func TestIsProxyError_ConnectionRefused(t *testing.T) { - err := fmt.Errorf("dial tcp: connection refused") - require.True(t, isProxyError(err)) + require.True(t, isProxyError(fmt.Errorf("dial tcp: connection refused"))) } func TestIsProxyError_Timeout(t *testing.T) { - err := fmt.Errorf("i/o timeout while connecting to proxy") - require.True(t, isProxyError(err)) + require.True(t, isProxyError(fmt.Errorf("i/o timeout while connecting to proxy"))) } func TestIsProxyError_SOCKS(t *testing.T) { - err := fmt.Errorf("socks connect failed") - require.True(t, isProxyError(err)) + require.True(t, isProxyError(fmt.Errorf("socks connect failed"))) } func TestIsProxyError_TLSHandshake(t *testing.T) { - err := fmt.Errorf("tls handshake timeout") - require.True(t, isProxyError(err)) + require.True(t, isProxyError(fmt.Errorf("tls handshake timeout"))) } func TestIsProxyError_APIError_NotProxy(t *testing.T) { - err := fmt.Errorf("API rate limit exceeded") - require.False(t, isProxyError(err)) + require.False(t, isProxyError(fmt.Errorf("API rate limit exceeded"))) } // --- isProxyAvailable (nil Redis) --- @@ -225,14 +261,13 @@ func TestIsProxyAvailable_ZeroID(t *testing.T) { // --- selectByQuotaWeight --- func TestSelectByQuotaWeight_NoQuotaLast(t *testing.T) { - m := NewManager(nil, nil) // nil Redis → GetUsage returns 0 + m := NewManager(nil, nil) candidates := []ProviderConfig{ - {Type: "brave", APIKey: "k1", QuotaLimit: 0}, // no limit → weight 0 - {Type: "tavily", APIKey: "k2", QuotaLimit: 100}, // remaining 100 + {Type: "brave", APIKey: "k1", QuotaLimit: 0}, + {Type: "tavily", APIKey: "k2", QuotaLimit: 100}, } result := m.selectByQuotaWeight(context.Background(), candidates) require.Len(t, result, 2) - // tavily (with quota) should come first require.Equal(t, "tavily", result[0].Type) require.Equal(t, "brave", result[1].Type) } @@ -245,7 +280,6 @@ func TestSelectByQuotaWeight_AllNoQuota(t *testing.T) { } result := m.selectByQuotaWeight(context.Background(), candidates) require.Len(t, result, 2) - // both have weight 0, original order preserved } func TestSelectByQuotaWeight_Empty(t *testing.T) { diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index a8034e98..ba45c31b 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -2,12 +2,14 @@ package server import ( + "context" "log" "net/http" "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" + "github.com/Wei-Shaw/sub2api/internal/pkg/websearch" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -56,6 +58,34 @@ 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) { + if cfg == nil || !cfg.Enabled || len(cfg.Providers) == 0 { + service.SetWebSearchManager(nil) + return + } + configs := make([]websearch.ProviderConfig, 0, len(cfg.Providers)) + for _, p := range cfg.Providers { + if p.APIKey == "" { + continue + } + pc := websearch.ProviderConfig{ + Type: p.Type, + APIKey: p.APIKey, + QuotaLimit: p.QuotaLimit, + ExpiresAt: p.ExpiresAt, + } + if p.SubscribedAt != nil { + pc.SubscribedAt = p.SubscribedAt + } + if p.ProxyID != nil { + pc.ProxyID = *p.ProxyID + } + configs = append(configs, pc) + } + service.SetWebSearchManager(websearch.NewManager(configs, redisClient)) + }) + return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 7c4e6cb7..0a7b7a8b 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -410,6 +410,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Web Search 模拟配置 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) } } diff --git a/backend/internal/service/websearch_config.go b/backend/internal/service/websearch_config.go index 15ec1f9d..bdfff3e4 100644 --- a/backend/internal/service/websearch_config.go +++ b/backend/internal/service/websearch_config.go @@ -22,15 +22,14 @@ type WebSearchEmulationConfig struct { // WebSearchProviderConfig describes a single search provider (Brave or Tavily). 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 - Priority int `json:"priority"` // lower = higher priority - QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited - QuotaRefreshInterval string `json:"quota_refresh_interval"` // websearch.QuotaRefresh* - QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current period usage - ProxyID *int64 `json:"proxy_id"` // optional proxy association - ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration timestamp + 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 + 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 + ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration timestamp } // --- Validation --- @@ -42,13 +41,6 @@ var validProviderTypes = map[string]bool{ websearch.ProviderTypeTavily: true, } -var validQuotaIntervals = map[string]bool{ - websearch.QuotaRefreshDaily: true, - websearch.QuotaRefreshWeekly: true, - websearch.QuotaRefreshMonthly: true, - "": true, // defaults to monthly -} - func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error { if cfg == nil { return nil @@ -61,9 +53,6 @@ func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error { if !validProviderTypes[p.Type] { return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type) } - if !validQuotaIntervals[p.QuotaRefreshInterval] { - return fmt.Errorf("provider[%d]: invalid quota_refresh_interval %q", i, p.QuotaRefreshInterval) - } if p.QuotaLimit < 0 { return fmt.Errorf("provider[%d]: quota_limit must be >= 0", i) } @@ -237,17 +226,55 @@ func (s *SettingService) RebuildWebSearchManager(ctx context.Context) { slog.Info("websearch: manager rebuilt", "provider_count", len(providerConfigs)) } -// SanitizeWebSearchConfig returns a copy with api_key fields masked for API responses. -func SanitizeWebSearchConfig(cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig { +// WebSearchTestResult holds the result of a search test. +type WebSearchTestResult struct { + Provider string `json:"provider"` + Results []websearch.SearchResult `json:"results"` + Query string `json:"query"` +} + +// TestWebSearch executes a test search using the currently configured Manager. +// Uses Manager.TestSearch which bypasses quota tracking. +func TestWebSearch(ctx context.Context, query string) (*WebSearchTestResult, error) { + mgr := getWebSearchManager() + if mgr == nil { + return nil, fmt.Errorf("web search: manager not initialized, save config first") + } + resp, providerName, err := mgr.TestSearch(ctx, websearch.SearchRequest{ + Query: query, + MaxResults: webSearchDefaultMaxResults, + }) + if err != nil { + return nil, err + } + return &WebSearchTestResult{ + Provider: providerName, + Results: resp.Results, + Query: resp.Query, + }, nil +} + +// SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated. +func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig { if cfg == nil { return nil } out := *cfg out.Providers = make([]WebSearchProviderConfig, len(cfg.Providers)) + + // Load usage from the global Manager (reads from Redis) + mgr := getWebSearchManager() + for i, p := range cfg.Providers { out.Providers[i] = p out.Providers[i].APIKeyConfigured = p.APIKey != "" out.Providers[i].APIKey = "" // never return the secret + + // Populate quota usage from Redis + if mgr != nil { + used, _ := mgr.GetUsage(ctx, p.Type) + out.Providers[i].QuotaUsed = used + } } return &out } diff --git a/backend/internal/service/websearch_config_test.go b/backend/internal/service/websearch_config_test.go index 1a19dd9d..4aea98b7 100644 --- a/backend/internal/service/websearch_config_test.go +++ b/backend/internal/service/websearch_config_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -16,8 +17,8 @@ func TestValidateWebSearchConfig_Valid(t *testing.T) { cfg := &WebSearchEmulationConfig{ Enabled: true, Providers: []WebSearchProviderConfig{ - {Type: "brave", Priority: 1, QuotaLimit: 1000, QuotaRefreshInterval: "monthly"}, - {Type: "tavily", Priority: 2, QuotaLimit: 500, QuotaRefreshInterval: "daily"}, + {Type: "brave", QuotaLimit: 1000}, + {Type: "tavily", QuotaLimit: 500}, }, } require.NoError(t, validateWebSearchConfig(cfg)) @@ -39,13 +40,6 @@ func TestValidateWebSearchConfig_InvalidType(t *testing.T) { require.ErrorContains(t, validateWebSearchConfig(cfg), "invalid type") } -func TestValidateWebSearchConfig_InvalidQuotaInterval(t *testing.T) { - cfg := &WebSearchEmulationConfig{ - Providers: []WebSearchProviderConfig{{Type: "brave", QuotaRefreshInterval: "hourly"}}, - } - require.ErrorContains(t, validateWebSearchConfig(cfg), "invalid quota_refresh_interval") -} - func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) { cfg := &WebSearchEmulationConfig{ Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: -1}}, @@ -56,20 +50,13 @@ func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) { func TestValidateWebSearchConfig_DuplicateType(t *testing.T) { cfg := &WebSearchEmulationConfig{ Providers: []WebSearchProviderConfig{ - {Type: "brave", Priority: 1}, - {Type: "brave", Priority: 2}, + {Type: "brave"}, + {Type: "brave"}, }, } require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type") } -func TestValidateWebSearchConfig_EmptyQuotaInterval(t *testing.T) { - cfg := &WebSearchEmulationConfig{ - Providers: []WebSearchProviderConfig{{Type: "brave", QuotaRefreshInterval: ""}}, - } - require.NoError(t, validateWebSearchConfig(cfg)) -} - func TestValidateWebSearchConfig_ZeroQuotaLimit(t *testing.T) { cfg := &WebSearchEmulationConfig{ Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: 0}}, @@ -99,6 +86,15 @@ func TestParseWebSearchConfigJSON_InvalidJSON(t *testing.T) { require.Empty(t, cfg.Providers) } +func TestParseWebSearchConfigJSON_BackwardCompatibility(t *testing.T) { + // Old config with priority and quota_refresh_interval should parse without error + raw := `{"enabled":true,"providers":[{"type":"brave","priority":1,"quota_refresh_interval":"monthly","quota_limit":1000}]}` + cfg := parseWebSearchConfigJSON(raw) + require.True(t, cfg.Enabled) + require.Len(t, cfg.Providers, 1) + require.Equal(t, int64(1000), cfg.Providers[0].QuotaLimit) +} + // --- SanitizeWebSearchConfig --- func TestSanitizeWebSearchConfig_MaskAPIKey(t *testing.T) { @@ -108,7 +104,7 @@ func TestSanitizeWebSearchConfig_MaskAPIKey(t *testing.T) { {Type: "brave", APIKey: "sk-secret-xxx"}, }, } - out := SanitizeWebSearchConfig(cfg) + out := SanitizeWebSearchConfig(context.Background(), cfg) require.Equal(t, "", out.Providers[0].APIKey) require.True(t, out.Providers[0].APIKeyConfigured) } @@ -117,25 +113,24 @@ func TestSanitizeWebSearchConfig_NoAPIKey(t *testing.T) { cfg := &WebSearchEmulationConfig{ Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: ""}}, } - out := SanitizeWebSearchConfig(cfg) + out := SanitizeWebSearchConfig(context.Background(), cfg) require.Equal(t, "", out.Providers[0].APIKey) require.False(t, out.Providers[0].APIKeyConfigured) } func TestSanitizeWebSearchConfig_Nil(t *testing.T) { - require.Nil(t, SanitizeWebSearchConfig(nil)) + require.Nil(t, SanitizeWebSearchConfig(context.Background(), nil)) } func TestSanitizeWebSearchConfig_PreservesOtherFields(t *testing.T) { cfg := &WebSearchEmulationConfig{ Enabled: true, Providers: []WebSearchProviderConfig{ - {Type: "brave", APIKey: "secret", Priority: 10, QuotaLimit: 1000}, + {Type: "brave", APIKey: "secret", QuotaLimit: 1000}, }, } - out := SanitizeWebSearchConfig(cfg) + out := SanitizeWebSearchConfig(context.Background(), cfg) require.True(t, out.Enabled) - require.Equal(t, 10, out.Providers[0].Priority) require.Equal(t, int64(1000), out.Providers[0].QuotaLimit) } @@ -143,6 +138,6 @@ func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) { cfg := &WebSearchEmulationConfig{ Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: "secret"}}, } - _ = SanitizeWebSearchConfig(cfg) + _ = SanitizeWebSearchConfig(context.Background(), cfg) require.Equal(t, "secret", cfg.Providers[0].APIKey) } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index c6323b00..31284289 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -497,9 +497,8 @@ export interface WebSearchProviderConfig { type: 'brave' | 'tavily' api_key: string api_key_configured: boolean - priority: number quota_limit: number - quota_refresh_interval: 'daily' | 'weekly' | 'monthly' + subscribed_at: number | null quota_used?: number proxy_id: number | null expires_at: number | null @@ -510,6 +509,12 @@ export interface WebSearchEmulationConfig { providers: WebSearchProviderConfig[] } +export interface WebSearchTestResult { + provider: string + results: { url: string; title: string; snippet: string; page_age?: string }[] + query: string +} + export async function getWebSearchEmulationConfig(): Promise { const { data } = await apiClient.get( '/admin/settings/web-search-emulation' @@ -527,6 +532,16 @@ export async function updateWebSearchEmulationConfig( return data } +export async function testWebSearchEmulation( + query: string +): Promise { + const { data } = await apiClient.post( + '/admin/settings/web-search-emulation/test', + { query } + ) + return data +} + export const settingsAPI = { getSettings, updateSettings, @@ -544,7 +559,8 @@ export const settingsAPI = { getBetaPolicySettings, updateBetaPolicySettings, getWebSearchEmulationConfig, - updateWebSearchEmulationConfig + updateWebSearchEmulationConfig, + testWebSearchEmulation } export default settingsAPI diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 7119fa36..8e10bf2a 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4417,19 +4417,24 @@ export default { apiKey: 'API Key', apiKeyPlaceholder: 'Enter API Key', apiKeyConfigured: 'Configured', - priority: 'Priority', - priorityHint: 'Lower number = higher priority', + showApiKey: 'Show', + hideApiKey: 'Hide', + copyApiKey: 'Copy', + copied: 'Copied', quotaLimit: 'Quota Limit', quotaLimitHint: '0 = unlimited', - quotaRefreshInterval: 'Refresh Interval', - quotaUsed: 'Used', + subscribedAt: 'Subscribed At', + subscribedAtHint: 'Quota resets monthly from this date', + quotaUsage: 'Usage', proxy: 'Proxy', - expiresAt: 'Expires At', removeProvider: 'Remove', - daily: 'Daily', - weekly: 'Weekly', - monthly: 'Monthly', noProviders: 'No search providers configured', + test: 'Test', + testDefaultQuery: 'Major world events this year', + testing: 'Searching...', + testResultTitle: 'Search Results', + testResultProvider: 'Provider', + testNoResults: 'No results found', }, site: { title: 'Site Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 6efaf657..1b82f419 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4579,19 +4579,24 @@ export default { apiKey: 'API Key', apiKeyPlaceholder: '输入 API Key', apiKeyConfigured: '已配置', - priority: '优先级', - priorityHint: '数值越小优先级越高', + showApiKey: '显示', + hideApiKey: '隐藏', + copyApiKey: '复制', + copied: '已复制', quotaLimit: '配额上限', quotaLimitHint: '0 表示无限制', - quotaRefreshInterval: '刷新周期', - quotaUsed: '已使用', + subscribedAt: '订阅时间', + subscribedAtHint: '配额从此日期起每月自动重置', + quotaUsage: '用量', proxy: '代理', - expiresAt: '过期时间', removeProvider: '删除', - daily: '每日', - weekly: '每周', - monthly: '每月', noProviders: '未配置搜索服务商', + test: '测试', + testDefaultQuery: '搜索今年世界大事件', + testing: '搜索中...', + testResultTitle: '搜索结果', + testResultProvider: '服务商', + testNoResults: '无搜索结果', }, site: { title: '站点设置', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index f57bfcf3..8ed77203 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1751,61 +1751,152 @@
-
- + + + {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }} + + + {{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }} + +
+
-
+ +
+
- +
+ + + +
-
- - -

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

-
-
- - -

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

-

- {{ t('admin.settings.webSearchEmulation.quotaUsed') }}: {{ provider.quota_used }} / {{ provider.quota_limit || '∞' }} -

-
-
- - +

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

+
+
+ + +

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

+
+
+ + +
+ {{ t('admin.settings.webSearchEmulation.quotaUsage') }}: +
+
+
+ {{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }} +
+ + +
+ + +
+ + +
+
+ + +
+ +
+

+ {{ t('admin.settings.webSearchEmulation.testResultProvider') }}: {{ wsTestResult.provider }} +

+
+ {{ t('admin.settings.webSearchEmulation.testNoResults') }} +
+
+ {{ r.title }} +

{{ r.snippet && r.snippet.length > 120 ? r.snippet.slice(0, 120) + '...' : r.snippet }}

+
+
+
@@ -2303,6 +2394,13 @@ ]" >{{ pt.label }} +

+ {{ t('admin.settings.payment.enabledPaymentTypesHint') }} + + {{ t('admin.settings.payment.findProvider') }} + + +

@@ -2562,60 +2660,6 @@
- -
-
-

- {{ t('admin.settings.balanceNotify.title') }} -

-

- {{ t('admin.settings.balanceNotify.description') }} -

-
-
-
- - -
-
- -
- $ - -
-

{{ t('admin.settings.balanceNotify.thresholdHint') }}

-
-
-
- - -
-
-

- {{ t('admin.settings.quotaNotify.title') }} -

-

- {{ t('admin.settings.quotaNotify.description') }} -

-
-
-
- -
-
- - -
- -
-

{{ t('admin.settings.quotaNotify.emailsHint') }}

-
-
-
@@ -2674,8 +2718,9 @@ import type { DefaultSubscriptionSetting, WebSearchEmulationConfig, WebSearchProviderConfig, + WebSearchTestResult, } from '@/api/admin/settings' -import type { AdminGroup } from '@/types' +import type { AdminGroup, Proxy } from '@/types' import type { ProviderInstance } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' @@ -2894,13 +2939,12 @@ const form = reactive({ // Gateway forwarding behavior enable_fingerprint_unification: true, enable_metadata_passthrough: false, - enable_cch_signing: false, - // Balance & quota notification - balance_low_notify_enabled: false, - balance_low_notify_threshold: 0, - account_quota_notify_emails: [] as string[] + enable_cch_signing: false }) +// Proxies for web search emulation ProxySelector +const webSearchProxies = ref([]) + // Web Search Emulation config (loaded/saved separately) const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000 @@ -2909,26 +2953,101 @@ const webSearchConfig = reactive({ providers: [], }) +const expandedProviders = reactive>({}) +const apiKeyVisible = reactive>({}) +const wsTestQuery = ref('') +const wsTestLoading = ref(false) +const wsTestResult = ref(null) + +function toggleProviderExpand(idx: number) { + expandedProviders[idx] = !expandedProviders[idx] +} + +function removeWebSearchProvider(idx: number) { + webSearchConfig.providers.splice(idx, 1) + // Re-index expandedProviders and apiKeyVisible after removal + const newExpanded: Record = {} + const newVisible: Record = {} + for (let i = 0; i < webSearchConfig.providers.length; i++) { + const oldIdx = i >= idx ? i + 1 : i + newExpanded[i] = expandedProviders[oldIdx] ?? false + newVisible[i] = apiKeyVisible[oldIdx] ?? false + } + Object.keys(expandedProviders).forEach((k) => delete expandedProviders[Number(k)]) + Object.keys(apiKeyVisible).forEach((k) => delete apiKeyVisible[Number(k)]) + Object.assign(expandedProviders, newExpanded) + Object.assign(apiKeyVisible, newVisible) +} + function addWebSearchProvider() { + const idx = webSearchConfig.providers.length webSearchConfig.providers.push({ type: 'brave', api_key: '', api_key_configured: false, - priority: webSearchConfig.providers.length + 1, quota_limit: DEFAULT_WEB_SEARCH_QUOTA_LIMIT, - quota_refresh_interval: 'monthly', + subscribed_at: null, proxy_id: null, expires_at: null, } as WebSearchProviderConfig) + expandedProviders[idx] = true +} + +function formatSubscribedAt(ts: number | null): string { + if (!ts) return '' + // Use UTC to avoid timezone drift on repeated edits + const d = new Date(ts * 1000) + const y = d.getUTCFullYear() + const m = String(d.getUTCMonth() + 1).padStart(2, '0') + const day = String(d.getUTCDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +function parseSubscribedAt(dateStr: string): number | null { + if (!dateStr) return null + // Parse as UTC to match formatSubscribedAt + return Math.floor(new Date(dateStr + 'T00:00:00Z').getTime() / 1000) +} + +function quotaPercentage(provider: WebSearchProviderConfig): number { + if (!provider.quota_limit || provider.quota_limit <= 0) return 0 + return ((provider.quota_used ?? 0) / provider.quota_limit) * 100 +} + +async function copyApiKey(idx: number) { + const key = webSearchConfig.providers[idx]?.api_key + if (!key) { + appStore.showError(t('admin.settings.webSearchEmulation.apiKeyPlaceholder')) + return + } + await navigator.clipboard.writeText(key) + appStore.showSuccess(t('admin.settings.webSearchEmulation.copied')) +} + +async function testWebSearchProvider() { + wsTestLoading.value = true + wsTestResult.value = null + try { + const query = wsTestQuery.value.trim() || t('admin.settings.webSearchEmulation.testDefaultQuery') + wsTestResult.value = await adminAPI.settings.testWebSearchEmulation(query) + } catch (err: unknown) { + appStore.showError(extractApiErrorMessage(err, t('common.error'))) + } finally { + wsTestLoading.value = false + } } async function loadWebSearchConfig() { try { - const resp = await adminAPI.settings.getWebSearchEmulationConfig() + const [resp, proxiesResp] = await Promise.all([ + adminAPI.settings.getWebSearchEmulationConfig(), + adminAPI.proxies.list().catch(() => ({ items: [] as Proxy[] })), + ]) if (resp) { webSearchConfig.enabled = resp.enabled || false webSearchConfig.providers = resp.providers || [] } + webSearchProxies.value = proxiesResp.items || [] } catch (err: unknown) { // 404 is expected when config hasn't been created yet; show error for other failures const status = (err as { status?: number })?.status @@ -3030,14 +3149,6 @@ 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 const linuxdoRedirectUrlSuggestion = computed(() => { if (typeof window === 'undefined') return '' @@ -3377,10 +3488,6 @@ async function saveSettings() { 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_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: 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)