feat(websearch): settings UI overhaul and quota improvements

- 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
This commit is contained in:
erio
2026-04-12 13:11:46 +08:00
parent 30b926add4
commit d0674e0ff9
11 changed files with 627 additions and 328 deletions

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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<WebSearchEmulationConfig> {
const { data } = await apiClient.get<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation'
@@ -527,6 +532,16 @@ export async function updateWebSearchEmulationConfig(
return data
}
export async function testWebSearchEmulation(
query: string
): Promise<WebSearchTestResult> {
const { data } = await apiClient.post<WebSearchTestResult>(
'/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

View File

@@ -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',

View File

@@ -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: '站点设置',

View File

@@ -1751,61 +1751,152 @@
</div>
<div v-for="(provider, pIdx) in webSearchConfig.providers" :key="pIdx"
class="rounded-lg border border-gray-200 p-4 space-y-3 dark:border-dark-600">
<div class="flex items-center justify-between">
<Select
v-model="provider.type"
:options="[
{ value: 'brave', label: 'Brave Search' },
{ value: 'tavily', label: 'Tavily' },
]"
class="w-40"
/>
<button type="button" class="text-red-500 hover:text-red-700 text-xs" @click="webSearchConfig.providers.splice(pIdx, 1)">
class="rounded-lg border border-gray-200 dark:border-dark-600">
<!-- Collapsible header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3"
@click="toggleProviderExpand(pIdx)"
>
<div class="flex items-center gap-3">
<svg
class="h-4 w-4 text-gray-400 transition-transform"
:class="{ 'rotate-90': expandedProviders[pIdx] }"
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<Select
v-model="provider.type"
:options="[
{ value: 'brave', label: 'Brave Search' },
{ value: 'tavily', label: 'Tavily' },
]"
class="w-36"
@click.stop
/>
<!-- Quota summary in collapsed state -->
<span v-if="!expandedProviders[pIdx] && provider.quota_limit > 0" class="text-xs text-gray-400">
{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }}
</span>
<span v-if="!expandedProviders[pIdx] && provider.api_key_configured" class="text-xs text-green-500">
{{ t('admin.settings.webSearchEmulation.apiKeyConfigured') }}
</span>
</div>
<button type="button" class="text-red-500 hover:text-red-700 text-xs" @click.stop="removeWebSearchProvider(pIdx)">
{{ t('admin.settings.webSearchEmulation.removeProvider') }}
</button>
</div>
<div class="grid grid-cols-2 gap-3">
<!-- Expanded content -->
<div v-if="expandedProviders[pIdx]" class="space-y-3 border-t border-gray-100 px-4 pb-4 pt-3 dark:border-dark-700">
<!-- API Key with show/copy -->
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.apiKey') }}</label>
<input
v-model="provider.api_key"
type="password"
class="input text-sm"
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
/>
<div class="flex items-center gap-1">
<input
v-model="provider.api_key"
:type="apiKeyVisible[pIdx] ? 'text' : 'password'"
class="input flex-1 text-sm"
:placeholder="provider.api_key_configured ? '••••••••' : t('admin.settings.webSearchEmulation.apiKeyPlaceholder')"
/>
<button
type="button"
class="btn btn-secondary btn-sm px-2"
:title="apiKeyVisible[pIdx] ? t('admin.settings.webSearchEmulation.hideApiKey') : t('admin.settings.webSearchEmulation.showApiKey')"
@click="apiKeyVisible[pIdx] = !apiKeyVisible[pIdx]"
>
<svg v-if="!apiKeyVisible[pIdx]" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" />
</svg>
</button>
<button
type="button"
class="btn btn-secondary btn-sm px-2"
:title="t('admin.settings.webSearchEmulation.copyApiKey')"
@click="copyApiKey(pIdx)"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.priority') }}</label>
<input v-model.number="provider.priority" type="number" min="1" class="input text-sm" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.priorityHint') }}</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
<input v-model.number="provider.quota_limit" type="number" min="0" class="input text-sm" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
<p v-if="provider.quota_used != null" class="mt-0.5 text-xs text-gray-400">
{{ t('admin.settings.webSearchEmulation.quotaUsed') }}: {{ provider.quota_used }} / {{ provider.quota_limit || '' }}
</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaRefreshInterval') }}</label>
<Select
v-model="provider.quota_refresh_interval"
:options="[
{ value: 'daily', label: t('admin.settings.webSearchEmulation.daily') },
{ value: 'weekly', label: t('admin.settings.webSearchEmulation.weekly') },
{ value: 'monthly', label: t('admin.settings.webSearchEmulation.monthly') },
]"
class="w-full"
/>
</div>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
<ProxySelector v-model="provider.proxy_id" />
<!-- Quota + Subscription in compact row -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaLimit') }}</label>
<input v-model.number="provider.quota_limit" type="number" min="0" class="input text-sm" />
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.quotaLimitHint') }}</p>
</div>
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.subscribedAt') }}</label>
<input
:value="formatSubscribedAt(provider.subscribed_at)"
type="date"
class="input text-sm"
@input="provider.subscribed_at = parseSubscribedAt(($event.target as HTMLInputElement).value)"
/>
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.webSearchEmulation.subscribedAtHint') }}</p>
</div>
</div>
<!-- Usage display -->
<div v-if="provider.quota_limit > 0" class="flex items-center gap-2">
<span class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.quotaUsage') }}:</span>
<div class="flex-1 rounded-full bg-gray-200 dark:bg-dark-600" style="height: 6px">
<div
class="h-full rounded-full transition-all"
:class="quotaPercentage(provider) > 90 ? 'bg-red-500' : quotaPercentage(provider) > 70 ? 'bg-yellow-500' : 'bg-green-500'"
:style="{ width: Math.min(quotaPercentage(provider), 100) + '%' }"
/>
</div>
<span class="text-xs text-gray-500">{{ provider.quota_used ?? 0 }} / {{ provider.quota_limit }}</span>
</div>
<!-- Proxy -->
<div>
<label class="text-xs text-gray-500">{{ t('admin.settings.webSearchEmulation.proxy') }}</label>
<ProxySelector v-model="provider.proxy_id" :proxies="webSearchProxies" />
</div>
<!-- Test button -->
<div class="border-t border-gray-100 pt-3 dark:border-dark-700">
<div class="flex items-center gap-2">
<input
v-model="wsTestQuery"
type="text"
class="input flex-1 text-sm"
:placeholder="t('admin.settings.webSearchEmulation.testDefaultQuery')"
@keyup.enter="testWebSearchProvider()"
/>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="wsTestLoading"
@click="testWebSearchProvider()"
>
{{ wsTestLoading ? t('admin.settings.webSearchEmulation.testing') : t('admin.settings.webSearchEmulation.test') }}
</button>
</div>
<!-- Test results -->
<div v-if="wsTestResult" class="mt-2 rounded-lg bg-gray-50 p-3 text-xs dark:bg-dark-700">
<p class="mb-1 font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.webSearchEmulation.testResultProvider') }}: {{ wsTestResult.provider }}
</p>
<div v-if="wsTestResult.results.length === 0" class="text-gray-400">
{{ t('admin.settings.webSearchEmulation.testNoResults') }}
</div>
<div v-for="(r, rIdx) in wsTestResult.results.slice(0, 3)" :key="rIdx" class="mt-1">
<a :href="r.url" target="_blank" class="font-medium text-blue-600 hover:underline dark:text-blue-400">{{ r.title }}</a>
<p class="text-gray-500 dark:text-gray-400">{{ r.snippet && r.snippet.length > 120 ? r.snippet.slice(0, 120) + '...' : r.snippet }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -2303,6 +2394,13 @@
]"
>{{ pt.label }}</button>
</div>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.settings.payment.enabledPaymentTypesHint') }}
<a :href="locale === 'zh' ? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F' : 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'" target="_blank" rel="noopener noreferrer" class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300">
{{ t('admin.settings.payment.findProvider') }}
<svg class="mb-0.5 ml-0.5 inline h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</p>
</div>
<!-- Row 5: Help image + text -->
<div class="grid grid-cols-2 gap-3">
@@ -2562,60 +2660,6 @@
</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">
<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">$</span>
<input v-model.number="form.balance_low_notify_threshold" type="number" min="0" step="0.01" class="input pl-7" />
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.settings.balanceNotify.thresholdHint') }}</p>
</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 -->
<!-- Tab: Backup -->
@@ -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<SettingsForm>({
// 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<Proxy[]>([])
// Web Search Emulation config (loaded/saved separately)
const DEFAULT_WEB_SEARCH_QUOTA_LIMIT = 1000
@@ -2909,26 +2953,101 @@ const webSearchConfig = reactive<WebSearchEmulationConfig>({
providers: [],
})
const expandedProviders = reactive<Record<number, boolean>>({})
const apiKeyVisible = reactive<Record<number, boolean>>({})
const wsTestQuery = ref('')
const wsTestLoading = ref(false)
const wsTestResult = ref<WebSearchTestResult | null>(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<number, boolean> = {}
const newVisible: Record<number, boolean> = {}
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)