2026-06-26 08:37:04 +00:00
|
|
|
package usecase
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-06-26 16:02:06 +00:00
|
|
|
"haixun-backend/internal/library/crypto"
|
2026-06-26 08:37:04 +00:00
|
|
|
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
|
2026-06-26 16:02:06 +00:00
|
|
|
cipher *crypto.Cipher
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 16:02:06 +00:00
|
|
|
func NewUseCase(settings settingdomain.UseCase, cipher *crypto.Cipher) UseCase {
|
|
|
|
|
return &placementUseCase{settings: settings, cipher: cipher}
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-06-26 16:02:06 +00:00
|
|
|
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
|
2026-06-26 08:37:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *placementUseCase) save(ctx context.Context, ownerUID string, value storedSettings) error {
|
2026-06-26 16:02:06 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-26 08:37:04 +00:00
|
|
|
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
|
|
|
|
|
Scope: settingScopeUser,
|
|
|
|
|
ScopeID: ownerUID,
|
|
|
|
|
Key: keyResearch,
|
2026-06-26 16:02:06 +00:00
|
|
|
Value: payload,
|
2026-06-26 08:37:04 +00:00
|
|
|
})
|
|
|
|
|
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, "••••")
|
|
|
|
|
}
|