package usecase import ( "context" "strings" app "haixun-backend/internal/library/errors" "haixun-backend/internal/library/errors/code" settingdomain "haixun-backend/internal/model/setting/domain/usecase" domusecase "haixun-backend/internal/model/threads_account/domain/usecase" ) const keyAiCredentials = "ai.credentials" var knownAiProviders = []string{ "opencode-go", "xai", "openai", "anthropic", "google", } func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*domusecase.AiSettings, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID) if err != nil { return nil, err } return toPublicAiSettings(account.ID, stored), nil } func (u *threadsAccountUseCase) ResolveMemberAiCredential( ctx context.Context, tenantID, ownerUID string, ) (*domusecase.WorkerAiCredential, error) { if err := requireActor(tenantID, ownerUID); err != nil { return nil, err } stored, err := u.loadAiCredentials(ctx, ownerUID, "") if err != nil { return nil, err } provider := strings.TrimSpace(stored.Provider) model := strings.TrimSpace(stored.Model) apiKey := strings.TrimSpace(stored.ApiKeys[provider]) if provider == "" || apiKey == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 AI API key") } return &domusecase.WorkerAiCredential{ Provider: provider, Model: model, APIKey: apiKey, }, nil } func (u *threadsAccountUseCase) ResolveWorkerAiCredential( ctx context.Context, tenantID, ownerUID, accountID string, ) (*domusecase.WorkerAiCredential, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } stored, err := u.loadAiCredentials(ctx, ownerUID, account.ID) if err != nil { return nil, err } provider := strings.TrimSpace(stored.ResearchProvider) model := strings.TrimSpace(stored.ResearchModel) if provider == "" { provider = strings.TrimSpace(stored.Provider) } if model == "" { model = strings.TrimSpace(stored.Model) } apiKey := strings.TrimSpace(stored.ApiKeys[provider]) if apiKey == "" { return nil, app.For(code.ThreadsAccount).InputMissingRequired("AI API key is not configured for provider " + provider) } return &domusecase.WorkerAiCredential{ Provider: provider, Model: model, APIKey: apiKey, }, nil } func (u *threadsAccountUseCase) UpdateAiSettings( ctx context.Context, tenantID, ownerUID, accountID string, patch domusecase.AiSettingsPatch, ) (*domusecase.AiSettings, error) { account, err := u.assertOwned(ctx, tenantID, ownerUID, accountID) if err != nil { return nil, err } current, err := u.loadAiCredentials(ctx, ownerUID, account.ID) if err != nil { return nil, err } next := applyAiSettingsPatch(current, patch) if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil { return nil, err } return toPublicAiSettings(account.ID, next), nil } type aiCredentials struct { Provider string Model string ResearchProvider string ResearchModel string ApiKeys map[string]string } func defaultAiCredentials() aiCredentials { return aiCredentials{ Provider: "opencode-go", Model: "deepseek-v4-pro", ResearchProvider: "opencode-go", ResearchModel: "deepseek-v4-flash", ApiKeys: map[string]string{}, } } func (u *threadsAccountUseCase) loadAiCredentials(ctx context.Context, ownerUID, accountID string) (aiCredentials, error) { defaults := defaultAiCredentials() setting, err := u.settings.Get(ctx, settingScopeUser, ownerUID, keyAiCredentials) if err == nil { return u.mergeAiCredentials(defaults, setting.Value) } if !isSettingNotFound(err) { return defaults, err } // Legacy: per-account storage migrates to user scope on first read. legacy, err := u.settings.Get(ctx, settingScopeAccount, accountID, keyAiCredentials) if err != nil { if isSettingNotFound(err) { return defaults, nil } return defaults, err } creds, err := u.mergeAiCredentials(defaults, legacy.Value) if err != nil { return defaults, err } if saveErr := u.saveAiCredentials(ctx, ownerUID, creds); saveErr != nil { return creds, saveErr } return creds, nil } func (u *threadsAccountUseCase) saveAiCredentials(ctx context.Context, ownerUID string, creds aiCredentials) error { value, err := u.aiCredentialsToMap(creds) if err != nil { return err } _, err = u.settings.Upsert(ctx, settingdomain.UpsertRequest{ Scope: settingScopeUser, ScopeID: ownerUID, Key: keyAiCredentials, Value: value, }) return err } // mergeAiCredentials merges stored setting values onto defaults, decrypting any // at-rest encrypted API keys. func (u *threadsAccountUseCase) mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) (aiCredentials, error) { if value == nil { return defaults, nil } if v, ok := value["provider"].(string); ok && strings.TrimSpace(v) != "" { defaults.Provider = v } if v, ok := value["model"].(string); ok && strings.TrimSpace(v) != "" { defaults.Model = v } if v, ok := value["research_provider"].(string); ok && strings.TrimSpace(v) != "" { defaults.ResearchProvider = v } if v, ok := value["research_model"].(string); ok && strings.TrimSpace(v) != "" { defaults.ResearchModel = v } if raw, ok := value["api_keys"].(map[string]interface{}); ok { keys := map[string]string{} for provider, item := range raw { s, ok := item.(string) if !ok || strings.TrimSpace(s) == "" { continue } plain := strings.TrimSpace(s) if u.cipher != nil { decrypted, err := u.cipher.Decrypt(plain) if err != nil { return defaults, err } plain = strings.TrimSpace(decrypted) } keys[provider] = plain } defaults.ApiKeys = keys } return defaults, nil } func applyAiSettingsPatch(current aiCredentials, patch domusecase.AiSettingsPatch) aiCredentials { if patch.Provider != nil && strings.TrimSpace(*patch.Provider) != "" { current.Provider = strings.TrimSpace(*patch.Provider) } if patch.Model != nil && strings.TrimSpace(*patch.Model) != "" { current.Model = strings.TrimSpace(*patch.Model) } if patch.ResearchProvider != nil && strings.TrimSpace(*patch.ResearchProvider) != "" { current.ResearchProvider = strings.TrimSpace(*patch.ResearchProvider) } if patch.ResearchModel != nil && strings.TrimSpace(*patch.ResearchModel) != "" { current.ResearchModel = strings.TrimSpace(*patch.ResearchModel) } if len(patch.ApiKeys) > 0 { if current.ApiKeys == nil { current.ApiKeys = map[string]string{} } for provider, value := range patch.ApiKeys { trimmed := strings.TrimSpace(value) if trimmed == "" || isMaskedAPIKey(trimmed) { continue } current.ApiKeys[provider] = trimmed } } return current } func (u *threadsAccountUseCase) aiCredentialsToMap(creds aiCredentials) (map[string]interface{}, error) { keys := map[string]interface{}{} for provider, value := range creds.ApiKeys { stored := value if u.cipher != nil { enc, err := u.cipher.Encrypt(value) if err != nil { return nil, err } stored = enc } keys[provider] = stored } return map[string]interface{}{ "provider": creds.Provider, "model": creds.Model, "research_provider": creds.ResearchProvider, "research_model": creds.ResearchModel, "api_keys": keys, }, nil } func toPublicAiSettings(accountID string, creds aiCredentials) *domusecase.AiSettings { masked := map[string]string{} configured := map[string]bool{} for _, provider := range knownAiProviders { raw := strings.TrimSpace(creds.ApiKeys[provider]) configured[provider] = raw != "" if maskedValue := maskAPIKey(raw); maskedValue != "" { masked[provider] = maskedValue } } return &domusecase.AiSettings{ AccountID: accountID, Provider: creds.Provider, Model: creds.Model, ResearchProvider: creds.ResearchProvider, ResearchModel: creds.ResearchModel, ApiKeys: masked, ApiKeysConfigured: configured, } } 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, "••••") }