websearch_config.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "sync/atomic"
  9. "time"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
  12. "golang.org/x/sync/singleflight"
  13. )
  14. // WebSearchEmulationConfig holds the global web search emulation configuration.
  15. type WebSearchEmulationConfig struct {
  16. Enabled bool `json:"enabled"`
  17. Providers []WebSearchProviderConfig `json:"providers"`
  18. }
  19. // WebSearchProviderConfig describes a single search provider (Brave or Tavily).
  20. type WebSearchProviderConfig struct {
  21. Type string `json:"type"` // websearch.ProviderTypeBrave | Tavily
  22. APIKey string `json:"api_key,omitempty"` // secret — omitted in API responses
  23. APIKeyConfigured bool `json:"api_key_configured"` // read-only mask
  24. QuotaLimit *int64 `json:"quota_limit"` // nil = unlimited, >0 = limited
  25. SubscribedAt *int64 `json:"subscribed_at,omitempty"` // subscription start (unix seconds); quota resets monthly
  26. QuotaUsed int64 `json:"quota_used,omitempty"` // read-only: current usage from Redis
  27. ProxyID *int64 `json:"proxy_id"` // optional proxy association
  28. ExpiresAt *int64 `json:"expires_at,omitempty"` // optional expiration timestamp
  29. }
  30. // --- Validation ---
  31. const maxWebSearchProviders = 10
  32. var validProviderTypes = map[string]bool{
  33. websearch.ProviderTypeBrave: true,
  34. websearch.ProviderTypeTavily: true,
  35. }
  36. func validateWebSearchConfig(cfg *WebSearchEmulationConfig) error {
  37. if cfg == nil {
  38. return nil
  39. }
  40. if len(cfg.Providers) > maxWebSearchProviders {
  41. return fmt.Errorf("too many providers (max %d)", maxWebSearchProviders)
  42. }
  43. seen := make(map[string]bool, len(cfg.Providers))
  44. for i, p := range cfg.Providers {
  45. if !validProviderTypes[p.Type] {
  46. return fmt.Errorf("provider[%d]: invalid type %q", i, p.Type)
  47. }
  48. if p.QuotaLimit != nil && *p.QuotaLimit < 0 {
  49. return fmt.Errorf("provider[%d]: quota_limit must be > 0 or null", i)
  50. }
  51. if seen[p.Type] {
  52. return fmt.Errorf("provider[%d]: duplicate type %q", i, p.Type)
  53. }
  54. seen[p.Type] = true
  55. }
  56. return nil
  57. }
  58. // --- In-process cache (same pattern as gateway forwarding settings) ---
  59. const sfKeyWebSearchConfig = "web_search_emulation_config"
  60. type cachedWebSearchEmulationConfig struct {
  61. config *WebSearchEmulationConfig
  62. expiresAt int64 // unix nano
  63. }
  64. var webSearchEmulationCache atomic.Value // *cachedWebSearchEmulationConfig
  65. var webSearchEmulationSF singleflight.Group
  66. const (
  67. webSearchEmulationCacheTTL = 60 * time.Second
  68. webSearchEmulationErrorTTL = 5 * time.Second
  69. webSearchEmulationDBTimeout = 5 * time.Second
  70. )
  71. // GetWebSearchEmulationConfig returns the configuration with in-process cache + singleflight.
  72. func (s *SettingService) GetWebSearchEmulationConfig(ctx context.Context) (*WebSearchEmulationConfig, error) {
  73. if cached := webSearchEmulationCache.Load(); cached != nil {
  74. if c, ok := cached.(*cachedWebSearchEmulationConfig); ok && time.Now().UnixNano() < c.expiresAt {
  75. return c.config, nil
  76. }
  77. }
  78. result, err, _ := webSearchEmulationSF.Do(sfKeyWebSearchConfig, func() (any, error) {
  79. return s.loadWebSearchConfigFromDB()
  80. })
  81. if err != nil {
  82. return &WebSearchEmulationConfig{}, err
  83. }
  84. if cfg, ok := result.(*WebSearchEmulationConfig); ok {
  85. return cfg, nil
  86. }
  87. return &WebSearchEmulationConfig{}, nil
  88. }
  89. func (s *SettingService) loadWebSearchConfigFromDB() (*WebSearchEmulationConfig, error) {
  90. dbCtx, cancel := context.WithTimeout(context.Background(), webSearchEmulationDBTimeout)
  91. defer cancel()
  92. raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyWebSearchEmulationConfig)
  93. if err != nil {
  94. // Missing key is the normal first-boot state: return empty disabled config.
  95. if errors.Is(err, ErrSettingNotFound) {
  96. cfg := &WebSearchEmulationConfig{}
  97. webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
  98. config: cfg,
  99. expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(),
  100. })
  101. return cfg, nil
  102. }
  103. webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
  104. config: &WebSearchEmulationConfig{},
  105. expiresAt: time.Now().Add(webSearchEmulationErrorTTL).UnixNano(),
  106. })
  107. return &WebSearchEmulationConfig{}, err
  108. }
  109. cfg := parseWebSearchConfigJSON(raw)
  110. webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
  111. config: cfg,
  112. expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(),
  113. })
  114. return cfg, nil
  115. }
  116. func parseWebSearchConfigJSON(raw string) *WebSearchEmulationConfig {
  117. cfg := &WebSearchEmulationConfig{}
  118. if raw == "" {
  119. return cfg
  120. }
  121. if err := json.Unmarshal([]byte(raw), cfg); err != nil {
  122. slog.Warn("websearch: failed to parse config JSON", "error", err)
  123. return &WebSearchEmulationConfig{}
  124. }
  125. return cfg
  126. }
  127. // SaveWebSearchEmulationConfig validates and persists the configuration.
  128. // Empty API keys in the input are preserved from the existing config.
  129. func (s *SettingService) SaveWebSearchEmulationConfig(ctx context.Context, cfg *WebSearchEmulationConfig) error {
  130. if err := validateWebSearchConfig(cfg); err != nil {
  131. return infraerrors.BadRequest("INVALID_WEB_SEARCH_CONFIG", err.Error())
  132. }
  133. s.mergeExistingAPIKeys(ctx, cfg)
  134. // After merge, validate all enabled providers have API keys
  135. if cfg.Enabled {
  136. for _, p := range cfg.Providers {
  137. if p.APIKey == "" {
  138. return infraerrors.BadRequest("MISSING_API_KEY",
  139. fmt.Sprintf("provider %s has no API key configured", p.Type))
  140. }
  141. }
  142. }
  143. data, err := json.Marshal(cfg)
  144. if err != nil {
  145. return fmt.Errorf("websearch: marshal config: %w", err)
  146. }
  147. if err := s.settingRepo.Set(ctx, SettingKeyWebSearchEmulationConfig, string(data)); err != nil {
  148. return fmt.Errorf("websearch: save config: %w", err)
  149. }
  150. // Invalidate: forget singleflight first, then store new value
  151. webSearchEmulationSF.Forget(sfKeyWebSearchConfig)
  152. webSearchEmulationCache.Store(&cachedWebSearchEmulationConfig{
  153. config: cfg,
  154. expiresAt: time.Now().Add(webSearchEmulationCacheTTL).UnixNano(),
  155. })
  156. // Hot-reload: rebuild the global Manager with new config
  157. s.rebuildWebSearchManager(ctx)
  158. return nil
  159. }
  160. // mergeExistingAPIKeys preserves API keys from the current config when incoming value is empty.
  161. func (s *SettingService) mergeExistingAPIKeys(ctx context.Context, cfg *WebSearchEmulationConfig) {
  162. existing, _ := s.getWebSearchEmulationConfigRaw(ctx)
  163. if existing == nil || cfg == nil {
  164. return
  165. }
  166. existingByType := make(map[string]string, len(existing.Providers))
  167. for _, p := range existing.Providers {
  168. if p.APIKey != "" {
  169. existingByType[p.Type] = p.APIKey
  170. }
  171. }
  172. for i := range cfg.Providers {
  173. if cfg.Providers[i].APIKey == "" {
  174. if key, ok := existingByType[cfg.Providers[i].Type]; ok {
  175. cfg.Providers[i].APIKey = key
  176. }
  177. }
  178. }
  179. }
  180. func (s *SettingService) getWebSearchEmulationConfigRaw(ctx context.Context) (*WebSearchEmulationConfig, error) {
  181. raw, err := s.settingRepo.GetValue(ctx, SettingKeyWebSearchEmulationConfig)
  182. if err != nil {
  183. return nil, err
  184. }
  185. return parseWebSearchConfigJSON(raw), nil
  186. }
  187. // IsWebSearchEmulationEnabled is a quick check for whether the global switch is on.
  188. func (s *SettingService) IsWebSearchEmulationEnabled(ctx context.Context) bool {
  189. cfg, err := s.GetWebSearchEmulationConfig(ctx)
  190. if err != nil {
  191. return false
  192. }
  193. return cfg.Enabled && len(cfg.Providers) > 0
  194. }
  195. // SetWebSearchManagerBuilder injects a callback that creates and wires a websearch.Manager.
  196. // The infra layer (main/wire) provides this builder, keeping redis out of the service layer.
  197. // Triggers initial build.
  198. func (s *SettingService) SetWebSearchManagerBuilder(ctx context.Context, builder WebSearchManagerBuilder) {
  199. s.webSearchManagerBuilder = builder
  200. s.rebuildWebSearchManager(ctx)
  201. }
  202. // rebuildWebSearchManager reads the current config, resolves proxy URLs, and invokes the builder.
  203. func (s *SettingService) rebuildWebSearchManager(ctx context.Context) {
  204. if s.webSearchManagerBuilder == nil {
  205. return
  206. }
  207. cfg, err := s.GetWebSearchEmulationConfig(ctx)
  208. if err != nil {
  209. SetWebSearchManager(nil)
  210. return
  211. }
  212. proxyURLs := s.resolveProviderProxyURLs(ctx, cfg)
  213. s.webSearchManagerBuilder(cfg, proxyURLs)
  214. }
  215. // resolveProviderProxyURLs collects proxy IDs from providers and resolves them to URLs.
  216. func (s *SettingService) resolveProviderProxyURLs(ctx context.Context, cfg *WebSearchEmulationConfig) map[int64]string {
  217. if cfg == nil || s.proxyRepo == nil {
  218. return nil
  219. }
  220. var ids []int64
  221. for _, p := range cfg.Providers {
  222. if p.ProxyID != nil && *p.ProxyID > 0 {
  223. ids = append(ids, *p.ProxyID)
  224. }
  225. }
  226. if len(ids) == 0 {
  227. return nil
  228. }
  229. proxies, err := s.proxyRepo.ListByIDs(ctx, ids)
  230. if err != nil {
  231. slog.Warn("websearch: failed to resolve proxy URLs", "error", err)
  232. return nil
  233. }
  234. result := make(map[int64]string, len(proxies))
  235. for _, px := range proxies {
  236. result[px.ID] = px.URL()
  237. }
  238. return result
  239. }
  240. // WebSearchTestResult holds the result of a search test.
  241. type WebSearchTestResult struct {
  242. Provider string `json:"provider"`
  243. Results []websearch.SearchResult `json:"results"`
  244. Query string `json:"query"`
  245. }
  246. // TestWebSearch executes a test search using the currently configured Manager.
  247. // Uses Manager.TestSearch which bypasses quota tracking.
  248. const testSearchTimeout = 15 * time.Second
  249. func TestWebSearch(ctx context.Context, query string) (*WebSearchTestResult, error) {
  250. mgr := getWebSearchManager()
  251. if mgr == nil {
  252. return nil, fmt.Errorf("web search: manager not initialized, save config first")
  253. }
  254. testCtx, cancel := context.WithTimeout(ctx, testSearchTimeout)
  255. defer cancel()
  256. resp, providerName, err := mgr.TestSearch(testCtx, websearch.SearchRequest{
  257. Query: query,
  258. MaxResults: webSearchDefaultMaxResults,
  259. })
  260. if err != nil {
  261. return nil, err
  262. }
  263. return &WebSearchTestResult{
  264. Provider: providerName,
  265. Results: resp.Results,
  266. Query: resp.Query,
  267. }, nil
  268. }
  269. // PopulateWebSearchUsage returns a copy with quota usage populated from Redis (api_key kept as-is).
  270. func PopulateWebSearchUsage(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
  271. if cfg == nil {
  272. return nil
  273. }
  274. out := *cfg
  275. out.Providers = make([]WebSearchProviderConfig, len(cfg.Providers))
  276. mgr := getWebSearchManager()
  277. for i, p := range cfg.Providers {
  278. out.Providers[i] = p
  279. out.Providers[i].APIKeyConfigured = p.APIKey != ""
  280. if mgr != nil {
  281. used, _ := mgr.GetUsage(ctx, p.Type)
  282. out.Providers[i].QuotaUsed = used
  283. }
  284. }
  285. return &out
  286. }
  287. // ResetWebSearchUsage deletes the Redis quota key for the given provider type.
  288. func ResetWebSearchUsage(ctx context.Context, providerType string) error {
  289. mgr := getWebSearchManager()
  290. if mgr == nil {
  291. return fmt.Errorf("web search manager not initialized")
  292. }
  293. return mgr.ResetUsage(ctx, providerType)
  294. }
  295. // SanitizeWebSearchConfig returns a copy with api_key fields masked and quota usage populated.
  296. func SanitizeWebSearchConfig(ctx context.Context, cfg *WebSearchEmulationConfig) *WebSearchEmulationConfig {
  297. if cfg == nil {
  298. return nil
  299. }
  300. out := *cfg
  301. out.Providers = make([]WebSearchProviderConfig, len(cfg.Providers))
  302. // Load usage from the global Manager (reads from Redis)
  303. mgr := getWebSearchManager()
  304. for i, p := range cfg.Providers {
  305. out.Providers[i] = p
  306. out.Providers[i].APIKeyConfigured = p.APIKey != ""
  307. out.Providers[i].APIKey = "" // never return the secret
  308. // Populate quota usage from Redis
  309. if mgr != nil {
  310. used, _ := mgr.GetUsage(ctx, p.Type)
  311. out.Providers[i].QuotaUsed = used
  312. }
  313. }
  314. return &out
  315. }