feat: websearch quota enhancements and balance notify hint
- QuotaLimit changed to *int64 (null=unlimited, >0=limited) - Add reset-usage endpoint (POST /admin/settings/web-search-emulation/reset-usage) - Show quota usage in header always (collapsed and expanded) - Add reset quota button in expanded provider view - Quota input: empty=unlimited with ∞ placeholder, must be >0 if set - Add email verification hint on balance notify card
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.112.3
|
||||
0.1.112.4
|
||||
|
||||
@@ -1962,7 +1962,28 @@ func (h *SettingHandler) UpdateWebSearchEmulationConfig(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, service.SanitizeWebSearchConfig(c.Request.Context(), updated))
|
||||
response.Success(c, service.PopulateWebSearchUsage(c.Request.Context(), updated))
|
||||
}
|
||||
|
||||
// ResetWebSearchUsage 重置指定 provider 的配额用量
|
||||
// POST /api/v1/admin/settings/web-search-emulation/reset-usage
|
||||
func (h *SettingHandler) ResetWebSearchUsage(c *gin.Context) {
|
||||
var req struct {
|
||||
ProviderType string `json:"provider_type"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.ProviderType == "" {
|
||||
response.BadRequest(c, "provider_type is required")
|
||||
return
|
||||
}
|
||||
if err := service.ResetWebSearchUsage(c.Request.Context(), req.ProviderType); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, nil)
|
||||
}
|
||||
|
||||
// TestWebSearchEmulation 测试 Web Search 搜索
|
||||
|
||||
@@ -447,6 +447,15 @@ func (m *Manager) GetAllUsage(ctx context.Context) map[string]int64 {
|
||||
return result
|
||||
}
|
||||
|
||||
// ResetUsage deletes the Redis quota key for the given provider, resetting usage to 0.
|
||||
func (m *Manager) ResetUsage(ctx context.Context, providerType string) error {
|
||||
if m.redis == nil {
|
||||
return nil
|
||||
}
|
||||
key := quotaRedisKey(providerType)
|
||||
return m.redis.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// --- Provider factory ---
|
||||
|
||||
func (m *Manager) buildProvider(cfg ProviderConfig, client *http.Client) Provider {
|
||||
|
||||
@@ -73,7 +73,7 @@ func ProvideRouter(
|
||||
pc := websearch.ProviderConfig{
|
||||
Type: p.Type,
|
||||
APIKey: p.APIKey,
|
||||
QuotaLimit: p.QuotaLimit,
|
||||
QuotaLimit: derefInt64(p.QuotaLimit),
|
||||
ExpiresAt: p.ExpiresAt,
|
||||
}
|
||||
if p.SubscribedAt != nil {
|
||||
@@ -141,3 +141,10 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
|
||||
// 不设置 ReadTimeout,因为大请求体可能需要较长时间读取
|
||||
}
|
||||
}
|
||||
|
||||
func derefInt64(p *int64) int64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
@@ -411,6 +411,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
adminSettings.GET("/web-search-emulation", h.Admin.Setting.GetWebSearchEmulationConfig)
|
||||
adminSettings.PUT("/web-search-emulation", h.Admin.Setting.UpdateWebSearchEmulationConfig)
|
||||
adminSettings.POST("/web-search-emulation/test", h.Admin.Setting.TestWebSearchEmulation)
|
||||
adminSettings.POST("/web-search-emulation/reset-usage", h.Admin.Setting.ResetWebSearchUsage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ type WebSearchProviderConfig struct {
|
||||
Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily
|
||||
APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses
|
||||
APIKeyConfigured bool `json:"api_key_configured"` // read-only mask
|
||||
QuotaLimit int64 `json:"quota_limit"` // 0 = unlimited
|
||||
QuotaLimit *int64 `json:"quota_limit"` // nil = unlimited, >0 = limited
|
||||
SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly
|
||||
QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current usage from Redis
|
||||
ProxyID *int64 `json:"proxy_id"` // optional proxy association
|
||||
@@ -52,8 +52,8 @@ func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error {
|
||||
if !validProviderTypes[p.Type] {
|
||||
return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type)
|
||||
}
|
||||
if p.QuotaLimit < 0 {
|
||||
return fmt.Errorf("provider[%d]: quota_limit must be >= 0", i)
|
||||
if p.QuotaLimit != nil && *p.QuotaLimit < 0 {
|
||||
return fmt.Errorf("provider[%d]: quota_limit must be > 0 or null", i)
|
||||
}
|
||||
if seen[p.Type] {
|
||||
return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type)
|
||||
@@ -299,6 +299,15 @@ func PopulateWebSearchUsage(ctx context.Context, cfg *WebSearchEmulationConfig)
|
||||
return &out
|
||||
}
|
||||
|
||||
// ResetWebSearchUsage deletes the Redis quota key for the given provider type.
|
||||
func ResetWebSearchUsage(ctx context.Context, providerType string) error {
|
||||
mgr := getWebSearchManager()
|
||||
if mgr == nil {
|
||||
return fmt.Errorf("web search manager not initialized")
|
||||
}
|
||||
return mgr.ResetUsage(ctx, providerType)
|
||||
}
|
||||
|
||||
// SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated.
|
||||
func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
|
||||
if cfg == nil {
|
||||
|
||||
@@ -17,8 +17,8 @@ func TestValidateWebSearchConfig_Valid(t *testing.T) {
|
||||
cfg := &WebSearchEmulationConfig{
|
||||
Enabled: true,
|
||||
Providers: []WebSearchProviderConfig{
|
||||
{Type: "brave", QuotaLimit: 1000},
|
||||
{Type: "tavily", QuotaLimit: 500},
|
||||
{Type: "brave", QuotaLimit: int64Ptr(1000)},
|
||||
{Type: "tavily", QuotaLimit: int64Ptr(500)},
|
||||
},
|
||||
}
|
||||
require.NoError(t, validateWebSearchConfig(cfg))
|
||||
@@ -42,9 +42,9 @@ func TestValidateWebSearchConfig_InvalidType(t *testing.T) {
|
||||
|
||||
func TestValidateWebSearchConfig_NegativeQuotaLimit(t *testing.T) {
|
||||
cfg := &WebSearchEmulationConfig{
|
||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: -1}},
|
||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: int64Ptr(-1)}},
|
||||
}
|
||||
require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be >= 0")
|
||||
require.ErrorContains(t, validateWebSearchConfig(cfg), "quota_limit must be > 0 or null")
|
||||
}
|
||||
|
||||
func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
|
||||
@@ -57,9 +57,9 @@ func TestValidateWebSearchConfig_DuplicateType(t *testing.T) {
|
||||
require.ErrorContains(t, validateWebSearchConfig(cfg), "duplicate type")
|
||||
}
|
||||
|
||||
func TestValidateWebSearchConfig_ZeroQuotaLimit(t *testing.T) {
|
||||
func TestValidateWebSearchConfig_NilQuotaLimit(t *testing.T) {
|
||||
cfg := &WebSearchEmulationConfig{
|
||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: 0}},
|
||||
Providers: []WebSearchProviderConfig{{Type: "brave", QuotaLimit: nil}},
|
||||
}
|
||||
require.NoError(t, validateWebSearchConfig(cfg))
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestParseWebSearchConfigJSON_BackwardCompatibility(t *testing.T) {
|
||||
cfg := parseWebSearchConfigJSON(raw)
|
||||
require.True(t, cfg.Enabled)
|
||||
require.Len(t, cfg.Providers, 1)
|
||||
require.Equal(t, int64(1000), cfg.Providers[0].QuotaLimit)
|
||||
require.Equal(t, int64(1000), *cfg.Providers[0].QuotaLimit)
|
||||
}
|
||||
|
||||
// --- SanitizeWebSearchConfig ---
|
||||
@@ -126,12 +126,12 @@ func TestSanitizeWebSearchConfig_PreservesOtherFields(t *testing.T) {
|
||||
cfg := &WebSearchEmulationConfig{
|
||||
Enabled: true,
|
||||
Providers: []WebSearchProviderConfig{
|
||||
{Type: "brave", APIKey: "secret", QuotaLimit: 1000},
|
||||
{Type: "brave", APIKey: "secret", QuotaLimit: int64Ptr(1000)},
|
||||
},
|
||||
}
|
||||
out := SanitizeWebSearchConfig(context.Background(), cfg)
|
||||
require.True(t, out.Enabled)
|
||||
require.Equal(t, int64(1000), out.Providers[0].QuotaLimit)
|
||||
require.Equal(t, int64(1000), *out.Providers[0].QuotaLimit)
|
||||
}
|
||||
|
||||
func TestSanitizeWebSearchConfig_DoesNotMutateOriginal(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user