feat: CRS 同步增强 - 自动刷新 OAuth token 和修复测试配置 (#27)

* 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 分支处理未知平台
This commit is contained in:
IanShaw
2025-12-25 14:45:17 +08:00
committed by GitHub
parent 4a2f7d4a99
commit 60f6ed6bf6
3 changed files with 136 additions and 16 deletions

View File

@@ -86,7 +86,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
concurrencyCache := repository.NewConcurrencyCache(client) concurrencyCache := repository.NewConcurrencyCache(client)
concurrencyService := service.NewConcurrencyService(concurrencyCache) 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) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService) oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)

View File

@@ -388,12 +388,14 @@ func createOpenAITestPayload(modelID string, isOAuth bool) map[string]any {
"stream": true, "stream": true,
} }
// OAuth accounts using ChatGPT internal API require store: false and instructions // OAuth accounts using ChatGPT internal API require store: false
if isOAuth { if isOAuth {
payload["store"] = false payload["store"] = false
payload["instructions"] = openai.DefaultInstructions
} }
// All accounts require instructions for Responses API
payload["instructions"] = openai.DefaultInstructions
return payload return payload
} }

View File

@@ -17,14 +17,23 @@ import (
) )
type CRSSyncService struct { type CRSSyncService struct {
accountRepo ports.AccountRepository accountRepo ports.AccountRepository
proxyRepo ports.ProxyRepository 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{ return &CRSSyncService{
accountRepo: accountRepo, accountRepo: accountRepo,
proxyRepo: proxyRepo, proxyRepo: proxyRepo,
oauthService: oauthService,
openaiOAuthService: openaiOAuthService,
} }
} }
@@ -232,12 +241,23 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
concurrency := 3 concurrency := 3
status := mapCRSStatus(src.IsActive, src.Status) status := mapCRSStatus(src.IsActive, src.Status)
// 🔧 Use CRS extra data directly, add sync metadata // 🔧 Preserve all CRS extra fields and add sync metadata
extra := src.Extra extra := make(map[string]any)
if extra == nil { if src.Extra != nil {
extra = make(map[string]any) 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 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) existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
if err != nil { if err != nil {
@@ -268,6 +288,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
continue 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" item.Action = "created"
result.Created++ result.Created++
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
@@ -296,6 +323,14 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
continue 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" item.Action = "updated"
result.Updated++ result.Updated++
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
@@ -449,12 +484,20 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
concurrency := 3 concurrency := 3
status := mapCRSStatus(src.IsActive, src.Status) status := mapCRSStatus(src.IsActive, src.Status)
// 🔧 Use CRS extra data directly, add sync metadata // 🔧 Preserve all CRS extra fields and add sync metadata
extra := src.Extra extra := make(map[string]any)
if extra == nil { if src.Extra != nil {
extra = make(map[string]any) 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 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) existing, err := s.accountRepo.GetByCRSAccountID(ctx, src.ID)
if err != nil { if err != nil {
@@ -485,6 +528,11 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
continue 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" item.Action = "created"
result.Created++ result.Created++
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
@@ -512,6 +560,12 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
continue 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" item.Action = "updated"
result.Updated++ result.Updated++
result.Items = append(result.Items, item) result.Items = append(result.Items, item)
@@ -841,3 +895,67 @@ func crsExportAccounts(ctx context.Context, client *http.Client, baseURL, adminT
} }
return &parsed, nil 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)
}