package usecase import ( "context" "strings" "haixun-backend/internal/library/crypto" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" libkg "haixun-backend/internal/library/knowledge" "haixun-backend/internal/library/placement" "haixun-backend/internal/library/websearch" settingdomain "haixun-backend/internal/model/setting/domain/usecase" ) const ( settingScopeUser = "user" keyResearch = "placement.research" ) type Settings struct { WebSearchProvider string BraveAPIKey string BraveAPIKeyConfigured bool ExaAPIKey string ExaAPIKeyConfigured bool BraveCountry string BraveSearchLang string ExaUserLocation string ExpandStrategy string } type SettingsPatch struct { WebSearchProvider *string BraveAPIKey *string ExaAPIKey *string BraveCountry *string BraveSearchLang *string ExaUserLocation *string ExpandStrategy *string } type UseCase interface { Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) Update(ctx context.Context, tenantID, ownerUID string, patch SettingsPatch) (*Settings, error) ResearchSettings(ctx context.Context, tenantID, ownerUID string) (placement.ResearchSettings, error) } type placementUseCase struct { settings settingdomain.UseCase cipher *crypto.Cipher } func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase { return &placementUseCase{settings: settings, cipher: cipher} } func (u *placementUseCase) Get(ctx context.Context, tenantID, ownerUID string) (*Settings, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } stored, err := u.load(ctx, ownerUID) if err != nil { return nil, err } return toPublic(stored), nil } func (u *placementUseCase) Update(ctx context.Context, tenantID, ownerUID string, patch SettingsPatch) (*Settings, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } current, err := u.load(ctx, ownerUID) if err != nil { return nil, err } next := applyPatch(current, patch) if err := u.save(ctx, ownerUID, next); err != nil { return nil, err } return toPublic(next), nil } func (u *placementUseCase) ResearchSettings(ctx context.Context, tenantID, ownerUID string) (placement.ResearchSettings, error) { if err := requireActor(tenantID, ownerUID); err != nil { return placement.ResearchSettings{}, err } stored, err := u.load(ctx, ownerUID) if err != nil { return placement.ResearchSettings{}, err } return placement.ResearchSettings{ WebSearchProvider: stored.WebSearchProvider, BraveAPIKey: stored.BraveAPIKey, ExaAPIKey: stored.ExaAPIKey, BraveCountry: stored.BraveCountry, BraveSearchLang: stored.BraveSearchLang, ExaUserLocation: stored.ExaUserLocation, ExpandStrategy: stored.ExpandStrategy, }, nil } func (u *placementUseCase) load(ctx context.Context, ownerUID string) (storedSettings, error) { defaults := defaultSettings() setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyResearch) if err != nil { if isSettingNotFound(err) { return defaults, nil } return defaults, err } merged := mergeSettings(defaults, setting.Value) if u.cipher != nil { if merged.BraveAPIKey, err = u.decryptKey(merged.BraveAPIKey); err != nil { return defaults, err } if merged.ExaAPIKey, err = u.decryptKey(merged.ExaAPIKey); err != nil { return defaults, err } } return merged, nil } func (u *placementUseCase) decryptKey(value string) (string, error) { if value == "" { return "", nil } plain, err := u.cipher.Decrypt(value) if err != nil { return "", err } return strings.TrimSpace(plain), nil } func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error { payload := value.toMap() if u.cipher != nil { for _, key := range []string{"brave_api_key", "exa_api_key"} { s, ok := payload[key].(string) if !ok || s == "" { continue } enc, err := u.cipher.Encrypt(s) if err != nil { return err } payload[key] = enc } } _, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyResearch, Value: payload, }) return err } type storedSettings struct { WebSearchProvider string BraveAPIKey string ExaAPIKey string BraveCountry string BraveSearchLang string ExaUserLocation string ExpandStrategy string } func defaultSettings() storedSettings { return storedSettings{ WebSearchProvider: string(websearch.ProviderBrave), BraveCountry: "tw", BraveSearchLang: "zh-hant", ExaUserLocation: "TW", ExpandStrategy: string(libkg.ExpandStrategyHybrid), } } func mergeSettings(defaults storedSettings, raw map[string]interface{}) storedSettings { if raw == nil { return defaults } if v, ok := raw["web_search_provider"].(string); ok && strings.TrimSpace(v) != "" { defaults.WebSearchProvider = string(websearch.ParseProvider(v)) } if v, ok := raw["brave_api_key"].(string); ok { defaults.BraveAPIKey = strings.TrimSpace(v) } if v, ok := raw["exa_api_key"].(string); ok { defaults.ExaAPIKey = strings.TrimSpace(v) } if v, ok := raw["brave_country"].(string); ok && strings.TrimSpace(v) != "" { defaults.BraveCountry = strings.TrimSpace(v) } if v, ok := raw["brave_search_lang"].(string); ok && strings.TrimSpace(v) != "" { defaults.BraveSearchLang = strings.TrimSpace(v) } if v, ok := raw["exa_user_location"].(string); ok && strings.TrimSpace(v) != "" { defaults.ExaUserLocation = strings.TrimSpace(v) } if v, ok := raw["expand_strategy"].(string); ok && strings.TrimSpace(v) != "" { defaults.ExpandStrategy = string(libkg.ParseExpandStrategy(v)) } return defaults } func applyPatch(current storedSettings, patch SettingsPatch) storedSettings { if patch.WebSearchProvider != nil && strings.TrimSpace(*patch.WebSearchProvider) != "" { current.WebSearchProvider = string(websearch.ParseProvider(*patch.WebSearchProvider)) } if patch.BraveAPIKey != nil { value := strings.TrimSpace(*patch.BraveAPIKey) if value != "" && !isMaskedAPIKey(value) { current.BraveAPIKey = value } } if patch.ExaAPIKey != nil { value := strings.TrimSpace(*patch.ExaAPIKey) if value != "" && !isMaskedAPIKey(value) { current.ExaAPIKey = value } } if patch.BraveCountry != nil && strings.TrimSpace(*patch.BraveCountry) != "" { current.BraveCountry = strings.TrimSpace(*patch.BraveCountry) } if patch.BraveSearchLang != nil && strings.TrimSpace(*patch.BraveSearchLang) != "" { current.BraveSearchLang = strings.TrimSpace(*patch.BraveSearchLang) } if patch.ExaUserLocation != nil && strings.TrimSpace(*patch.ExaUserLocation) != "" { current.ExaUserLocation = strings.TrimSpace(*patch.ExaUserLocation) } if patch.ExpandStrategy != nil && strings.TrimSpace(*patch.ExpandStrategy) != "" { current.ExpandStrategy = string(libkg.ParseExpandStrategy(*patch.ExpandStrategy)) } return current } func (s storedSettings) toMap() map[string]interface{} { return map[string]interface{}{ "web_search_provider": s.WebSearchProvider, "brave_api_key": s.BraveAPIKey, "exa_api_key": s.ExaAPIKey, "brave_country": s.BraveCountry, "brave_search_lang": s.BraveSearchLang, "exa_user_location": s.ExaUserLocation, "expand_strategy": s.ExpandStrategy, } } func toPublic(stored storedSettings) *Settings { braveMasked := "" if stored.BraveAPIKey != "" { braveMasked = maskAPIKey(stored.BraveAPIKey) } exaMasked := "" if stored.ExaAPIKey != "" { exaMasked = maskAPIKey(stored.ExaAPIKey) } strategy := string(libkg.ParseExpandStrategy(stored.ExpandStrategy)) return &Settings{ WebSearchProvider: string(websearch.ParseProvider(stored.WebSearchProvider)), BraveAPIKey: braveMasked, BraveAPIKeyConfigured: strings.TrimSpace(stored.BraveAPIKey) != "", ExaAPIKey: exaMasked, ExaAPIKeyConfigured: strings.TrimSpace(stored.ExaAPIKey) != "", BraveCountry: stored.BraveCountry, BraveSearchLang: stored.BraveSearchLang, ExaUserLocation: stored.ExaUserLocation, ExpandStrategy: strategy, } } func requireActor(tenantID, ownerUID string) error { if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" { return app.For(code.Member).InputMissingRequired("tenant_id and uid are required") } return nil } func isSettingNotFound(err error) bool { if err == nil { return false } appErr := app.FromError(err) return appErr != nil && strings.Contains(strings.ToLower(appErr.Error()), "not found") } func maskAPIKey(key string) string { trimmed := strings.TrimSpace(key) if trimmed == "" { return "" } if len(trimmed) <= 4 { return "••••" } return "••••" + trimmed[len(trimmed)-4:] } func isMaskedAPIKey(value string) bool { return strings.HasPrefix(value, "••••") }