From 60f6ed6bf63b6227a764ea04259ae9bcc821ead6 Mon Sep 17 00:00:00 2001 From: IanShaw <131567472+IanShaw027@users.noreply.github.com> Date: Thu, 25 Dec 2025 14:45:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CRS=20=E5=90=8C=E6=AD=A5=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20-=20=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=20OAuth=20?= =?UTF-8?q?token=20=E5=92=8C=E4=BF=AE=E5=A4=8D=E6=B5=8B=E8=AF=95=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(service): 修复 OpenAI Responses API 测试负载配置 - 所有账号类型统一添加 instructions 字段(不再仅限 OAuth) - Responses API 要求所有请求必须包含 instructions 参数 * feat(crs-sync): CRS 同步时自动刷新 OAuth token 并保留完整 extra 字段 **核心功能**: - CRSSyncService 注入 OAuth 服务依赖(Anthropic + OpenAI) - 账号创建/更新后自动刷新 OAuth token,确保可用性 - 完整保留 CRS extra 字段,避免数据丢失 **Extra 字段增强**: - 保留 CRS 所有原始 extra 字段 - 新增同步元数据: crs_account_id, crs_kind, crs_synced_at - Claude 账号: 从 credentials 提取 org_uuid/account_uuid 到 extra - OpenAI 账号: 映射 crs_email -> email **Token 刷新逻辑**: - 新增 refreshOAuthToken() 方法处理 Anthropic/OpenAI 平台 - 保留原有 credentials 字段,仅更新 token 相关字段 - 刷新失败静默处理,不中断同步流程 **依赖注入**: - wire_gen.go: CRSSyncService 新增 oAuthService/openaiOAuthService * style(crs-sync): 使用 switch 替代 if-else 修复 golangci-lint 警告 - 将 refreshOAuthToken 中的 if-else 改为 switch 语句 - 符合 staticcheck 规范 - 添加 default 分支处理未知平台 --- backend/cmd/server/wire_gen.go | 2 +- .../internal/service/account_test_service.go | 6 +- backend/internal/service/crs_sync_service.go | 144 ++++++++++++++++-- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index d4e3eb03..8a887c1b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -86,7 +86,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) concurrencyCache := repository.NewConcurrencyCache(client) concurrencyService := service.NewConcurrencyService(concurrencyCache) - crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository) + crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService) oAuthHandler := admin.NewOAuthHandler(oAuthService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 594db597..d85adc98 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -388,12 +388,14 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any { "stream": true, } - // OAuth accounts using ChatGPT internal API require store: false and instructions + // OAuth accounts using ChatGPT internal API require store: false if isOAuth { payload["store"] = false - payload["instructions"] = openai.DefaultInstructions } + // All accounts require instructions for Responses API + payload["instructions"] = openai.DefaultInstructions + return payload } diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index 8d1b7e68..09bf2660 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -17,14 +17,23 @@ import ( ) type CRSSyncService struct { - accountRepo ports.AccountRepository - proxyRepo ports.ProxyRepository + accountRepo ports.AccountRepository + proxyRepo ports.ProxyRepository + oauthService *OAuthService + openaiOAuthService *OpenAIOAuthService } -func NewCRSSyncService(accountRepo ports.AccountRepository, proxyRepo ports.ProxyRepository) *CRSSyncService { +func NewCRSSyncService( + accountRepo ports.AccountRepository, + proxyRepo ports.ProxyRepository, + oauthService *OAuthService, + openaiOAuthService *OpenAIOAuthService, +) *CRSSyncService { return &CRSSyncService{ - accountRepo: accountRepo, - proxyRepo: proxyRepo, + accountRepo: accountRepo, + proxyRepo: proxyRepo, + oauthService: oauthService, + openaiOAuthService: openaiOAuthService, } } @@ -232,12 +241,23 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput concurrency := 3 status := mapCRSStatus(src.IsActive, src.Status) - // 🔧 Use CRS extra data directly, add sync metadata - extra := src.Extra - if extra == nil { - extra = make(map[string]any) + // 🔧 Preserve all CRS extra fields and add sync metadata + extra := make(map[string]any) + if src.Extra != nil { + for k, v := range src.Extra { + extra[k] = v + } } + extra["crs_account_id"] = src.ID + extra["crs_kind"] = src.Kind extra["crs_synced_at"] = now + // Extract org_uuid and account_uuid from CRS credentials to extra + if orgUUID, ok := src.Credentials["org_uuid"]; ok { + extra["org_uuid"] = orgUUID + } + if accountUUID, ok := src.Credentials["account_uuid"]; ok { + extra["account_uuid"] = accountUUID + } existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) if err != nil { @@ -268,6 +288,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput result.Items = append(result.Items, item) continue } + // 🔄 Refresh OAuth token after creation + if targetType == model.AccountTypeOAuth { + if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil { + account.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, account) + } + } item.Action = "created" result.Created++ result.Items = append(result.Items, item) @@ -296,6 +323,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 🔄 Refresh OAuth token after update + if targetType == model.AccountTypeOAuth { + if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil { + existing.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, existing) + } + } + item.Action = "updated" result.Updated++ result.Items = append(result.Items, item) @@ -449,12 +484,20 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput concurrency := 3 status := mapCRSStatus(src.IsActive, src.Status) - // 🔧 Use CRS extra data directly, add sync metadata - extra := src.Extra - if extra == nil { - extra = make(map[string]any) + // 🔧 Preserve all CRS extra fields and add sync metadata + extra := make(map[string]any) + if src.Extra != nil { + for k, v := range src.Extra { + extra[k] = v + } } + extra["crs_account_id"] = src.ID + extra["crs_kind"] = src.Kind extra["crs_synced_at"] = now + // Extract email from CRS extra (crs_email -> email) + if crsEmail, ok := src.Extra["crs_email"]; ok { + extra["email"] = crsEmail + } existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID) if err != nil { @@ -485,6 +528,11 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput result.Items = append(result.Items, item) continue } + // 🔄 Refresh OAuth token after creation + if refreshedCreds := s.refreshOAuthToken(ctx, account); refreshedCreds != nil { + account.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, account) + } item.Action = "created" result.Created++ result.Items = append(result.Items, item) @@ -512,6 +560,12 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput continue } + // 🔄 Refresh OAuth token after update + if refreshedCreds := s.refreshOAuthToken(ctx, existing); refreshedCreds != nil { + existing.Credentials = refreshedCreds + _ = s.accountRepo.Update(ctx, existing) + } + item.Action = "updated" result.Updated++ result.Items = append(result.Items, item) @@ -841,3 +895,67 @@ func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminT } return &parsed, nil } + +// refreshOAuthToken attempts to refresh OAuth token for a synced account +// Returns updated credentials or nil if refresh failed/not applicable +func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *model.Account) model.JSONB { + if account.Type != model.AccountTypeOAuth { + return nil + } + + var newCredentials map[string]any + var err error + + switch account.Platform { + case model.PlatformAnthropic: + if s.oauthService == nil { + return nil + } + tokenInfo, refreshErr := s.oauthService.RefreshAccountToken(ctx, account) + if refreshErr != nil { + err = refreshErr + } else { + // Preserve existing credentials + newCredentials = make(map[string]any) + for k, v := range account.Credentials { + newCredentials[k] = v + } + // Update token fields + newCredentials["access_token"] = tokenInfo.AccessToken + newCredentials["token_type"] = tokenInfo.TokenType + newCredentials["expires_in"] = tokenInfo.ExpiresIn + newCredentials["expires_at"] = tokenInfo.ExpiresAt + if tokenInfo.RefreshToken != "" { + newCredentials["refresh_token"] = tokenInfo.RefreshToken + } + if tokenInfo.Scope != "" { + newCredentials["scope"] = tokenInfo.Scope + } + } + case model.PlatformOpenAI: + if s.openaiOAuthService == nil { + return nil + } + tokenInfo, refreshErr := s.openaiOAuthService.RefreshAccountToken(ctx, account) + if refreshErr != nil { + err = refreshErr + } else { + newCredentials = s.openaiOAuthService.BuildAccountCredentials(tokenInfo) + // Preserve non-token settings from existing credentials + for k, v := range account.Credentials { + if _, exists := newCredentials[k]; !exists { + newCredentials[k] = v + } + } + } + default: + return nil + } + + if err != nil { + // Log but don't fail the sync - token might still be valid or refreshable later + return nil + } + + return model.JSONB(newCredentials) +}