thread-master/backend/internal/model/threads_account/usecase/ai_credentials.go

267 lines
7.7 KiB
Go
Raw Normal View History

2026-06-26 08:37:04 +00:00
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 mergeAiCredentials(defaults, setting.Value), nil
}
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 := mergeAiCredentials(defaults, legacy.Value)
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 {
_, err := u.settings.Upsert(ctx, settingdomain.UpsertRequest{
Scope: settingScopeUser,
ScopeID: ownerUID,
Key: keyAiCredentials,
Value: aiCredentialsToMap(creds),
})
return err
}
func mergeAiCredentials(defaults aiCredentials, value map[string]interface{}) aiCredentials {
if value == nil {
return defaults
}
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 {
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
keys[provider] = strings.TrimSpace(s)
}
}
defaults.ApiKeys = keys
}
return defaults
}
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 aiCredentialsToMap(creds aiCredentials) map[string]interface{} {
keys := map[string]interface{}{}
for provider, value := range creds.ApiKeys {
keys[provider] = value
}
return map[string]interface{}{
"provider": creds.Provider,
"model": creds.Model,
"research_provider": creds.ResearchProvider,
"research_model": creds.ResearchModel,
"api_keys": keys,
}
}
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, "••••")
}