feat(admin): 新增 CRS 同步预览和账号选择功能
- 后端新增 PreviewFromCRS 接口,允许用户先预览 CRS 中的账号 - 后端支持在同步时选择特定账号,不选中的账号将被跳过 - 前端重构 SyncFromCrsModal 为三步向导:输入凭据 → 预览账号 → 执行同步 - 改进表单无障碍性:添加 for/id 关联和 required 属性 - 修复 Back 按钮返回时的状态清理 - 新增 buildSelectedSet 和 shouldCreateAccount 的单元测试 - 完整的向后兼容性:旧客户端不发送 selected_account_ids 时行为不变
This commit is contained in:
@@ -45,10 +45,11 @@ func NewCRSSyncService(
|
||||
}
|
||||
|
||||
type SyncFromCRSInput struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
SyncProxies bool
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
SyncProxies bool
|
||||
SelectedAccountIDs []string // if non-empty, only create new accounts with these CRS IDs
|
||||
}
|
||||
|
||||
type SyncFromCRSItemResult struct {
|
||||
@@ -190,25 +191,27 @@ type crsGeminiAPIKeyAccount struct {
|
||||
Extra map[string]any `json:"extra"`
|
||||
}
|
||||
|
||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||
// fetchCRSExport validates the connection parameters, authenticates with CRS,
|
||||
// and returns the exported accounts. Shared by SyncFromCRS and PreviewFromCRS.
|
||||
func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username, password string) (*crsExportResponse, error) {
|
||||
if s.cfg == nil {
|
||||
return nil, errors.New("config is not available")
|
||||
}
|
||||
baseURL := strings.TrimSpace(input.BaseURL)
|
||||
normalizedURL := strings.TrimSpace(baseURL)
|
||||
if s.cfg.Security.URLAllowlist.Enabled {
|
||||
normalized, err := normalizeBaseURL(baseURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
||||
normalized, err := normalizeBaseURL(normalizedURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL = normalized
|
||||
normalizedURL = normalized
|
||||
} else {
|
||||
normalized, err := urlvalidator.ValidateURLFormat(baseURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
normalized, err := urlvalidator.ValidateURLFormat(normalizedURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base_url: %w", err)
|
||||
}
|
||||
baseURL = normalized
|
||||
normalizedURL = normalized
|
||||
}
|
||||
if strings.TrimSpace(input.Username) == "" || strings.TrimSpace(input.Password) == "" {
|
||||
if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" {
|
||||
return nil, errors.New("username and password are required")
|
||||
}
|
||||
|
||||
@@ -221,12 +224,16 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
|
||||
adminToken, err := crsLogin(ctx, client, baseURL, input.Username, input.Password)
|
||||
adminToken, err := crsLogin(ctx, client, normalizedURL, username, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
exported, err := crsExportAccounts(ctx, client, baseURL, adminToken)
|
||||
return crsExportAccounts(ctx, client, normalizedURL, adminToken)
|
||||
}
|
||||
|
||||
func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput) (*SyncFromCRSResult, error) {
|
||||
exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -241,6 +248,8 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
),
|
||||
}
|
||||
|
||||
selectedSet := buildSelectedSet(input.SelectedAccountIDs)
|
||||
|
||||
var proxies []Proxy
|
||||
if input.SyncProxies {
|
||||
proxies, _ = s.proxyRepo.ListActive(ctx)
|
||||
@@ -329,6 +338,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformAnthropic,
|
||||
@@ -446,6 +462,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformAnthropic,
|
||||
@@ -569,6 +592,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformOpenAI,
|
||||
@@ -690,6 +720,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformOpenAI,
|
||||
@@ -798,6 +835,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformGemini,
|
||||
@@ -909,6 +953,13 @@ func (s *CRSSyncService) SyncFromCRS(ctx context.Context, input SyncFromCRSInput
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
if !shouldCreateAccount(src.ID, selectedSet) {
|
||||
item.Action = "skipped"
|
||||
item.Error = "not selected"
|
||||
result.Skipped++
|
||||
result.Items = append(result.Items, item)
|
||||
continue
|
||||
}
|
||||
account := &Account{
|
||||
Name: defaultName(src.Name, src.ID),
|
||||
Platform: PlatformGemini,
|
||||
@@ -1253,3 +1304,102 @@ func (s *CRSSyncService) refreshOAuthToken(ctx context.Context, account *Account
|
||||
|
||||
return newCredentials
|
||||
}
|
||||
|
||||
// buildSelectedSet converts a slice of selected CRS account IDs to a set for O(1) lookup.
|
||||
// Returns nil if ids is nil (field not sent → backward compatible: create all).
|
||||
// Returns an empty map if ids is non-nil but empty (user selected none → create none).
|
||||
func buildSelectedSet(ids []string) map[string]struct{} {
|
||||
if ids == nil {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// shouldCreateAccount checks if a new CRS account should be created based on user selection.
|
||||
// Returns true if selectedSet is nil (backward compatible: create all) or if crsID is in the set.
|
||||
func shouldCreateAccount(crsID string, selectedSet map[string]struct{}) bool {
|
||||
if selectedSet == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := selectedSet[crsID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// PreviewFromCRSResult contains the preview of accounts from CRS before sync.
|
||||
type PreviewFromCRSResult struct {
|
||||
NewAccounts []CRSPreviewAccount `json:"new_accounts"`
|
||||
ExistingAccounts []CRSPreviewAccount `json:"existing_accounts"`
|
||||
}
|
||||
|
||||
// CRSPreviewAccount represents a single account in the preview result.
|
||||
type CRSPreviewAccount struct {
|
||||
CRSAccountID string `json:"crs_account_id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// PreviewFromCRS connects to CRS, fetches all accounts, and classifies them
|
||||
// as new or existing by batch-querying local crs_account_id mappings.
|
||||
func (s *CRSSyncService) PreviewFromCRS(ctx context.Context, input SyncFromCRSInput) (*PreviewFromCRSResult, error) {
|
||||
exported, err := s.fetchCRSExport(ctx, input.BaseURL, input.Username, input.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Batch query all existing CRS account IDs
|
||||
existingCRSIDs, err := s.accountRepo.ListCRSAccountIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list existing CRS accounts: %w", err)
|
||||
}
|
||||
|
||||
result := &PreviewFromCRSResult{
|
||||
NewAccounts: make([]CRSPreviewAccount, 0),
|
||||
ExistingAccounts: make([]CRSPreviewAccount, 0),
|
||||
}
|
||||
|
||||
classify := func(crsID, kind, name, platform, accountType string) {
|
||||
preview := CRSPreviewAccount{
|
||||
CRSAccountID: crsID,
|
||||
Kind: kind,
|
||||
Name: defaultName(name, crsID),
|
||||
Platform: platform,
|
||||
Type: accountType,
|
||||
}
|
||||
if _, exists := existingCRSIDs[crsID]; exists {
|
||||
result.ExistingAccounts = append(result.ExistingAccounts, preview)
|
||||
} else {
|
||||
result.NewAccounts = append(result.NewAccounts, preview)
|
||||
}
|
||||
}
|
||||
|
||||
for _, src := range exported.Data.ClaudeAccounts {
|
||||
authType := strings.TrimSpace(src.AuthType)
|
||||
if authType == "" {
|
||||
authType = AccountTypeOAuth
|
||||
}
|
||||
classify(src.ID, src.Kind, src.Name, PlatformAnthropic, authType)
|
||||
}
|
||||
for _, src := range exported.Data.ClaudeConsoleAccounts {
|
||||
classify(src.ID, src.Kind, src.Name, PlatformAnthropic, AccountTypeAPIKey)
|
||||
}
|
||||
for _, src := range exported.Data.OpenAIOAuthAccounts {
|
||||
classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeOAuth)
|
||||
}
|
||||
for _, src := range exported.Data.OpenAIResponsesAccounts {
|
||||
classify(src.ID, src.Kind, src.Name, PlatformOpenAI, AccountTypeAPIKey)
|
||||
}
|
||||
for _, src := range exported.Data.GeminiOAuthAccounts {
|
||||
classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeOAuth)
|
||||
}
|
||||
for _, src := range exported.Data.GeminiAPIKeyAccounts {
|
||||
classify(src.ID, src.Kind, src.Name, PlatformGemini, AccountTypeAPIKey)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user