Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
91ca28b7
Unverified
Commit
91ca28b7
authored
Feb 09, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 09, 2026
Browse files
Merge pull request #525 from DaydreamCoding/feat/crs_sync_preview_with_select
feat(admin): 新增 CRS 同步预览和账号选择功能
parents
149e4267
04cedce9
Changes
14
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_handler.go
View file @
91ca28b7
...
...
@@ -424,10 +424,17 @@ type TestAccountRequest struct {
}
type
SyncFromCRSRequest
struct
{
BaseURL
string
`json:"base_url" binding:"required"`
Username
string
`json:"username" binding:"required"`
Password
string
`json:"password" binding:"required"`
SyncProxies
*
bool
`json:"sync_proxies"`
BaseURL
string
`json:"base_url" binding:"required"`
Username
string
`json:"username" binding:"required"`
Password
string
`json:"password" binding:"required"`
SyncProxies
*
bool
`json:"sync_proxies"`
SelectedAccountIDs
[]
string
`json:"selected_account_ids"`
}
type
PreviewFromCRSRequest
struct
{
BaseURL
string
`json:"base_url" binding:"required"`
Username
string
`json:"username" binding:"required"`
Password
string
`json:"password" binding:"required"`
}
// Test handles testing account connectivity with SSE streaming
...
...
@@ -466,10 +473,11 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
}
result
,
err
:=
h
.
crsSyncService
.
SyncFromCRS
(
c
.
Request
.
Context
(),
service
.
SyncFromCRSInput
{
BaseURL
:
req
.
BaseURL
,
Username
:
req
.
Username
,
Password
:
req
.
Password
,
SyncProxies
:
syncProxies
,
BaseURL
:
req
.
BaseURL
,
Username
:
req
.
Username
,
Password
:
req
.
Password
,
SyncProxies
:
syncProxies
,
SelectedAccountIDs
:
req
.
SelectedAccountIDs
,
})
if
err
!=
nil
{
// Provide detailed error message for CRS sync failures
...
...
@@ -480,6 +488,28 @@ func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
response
.
Success
(
c
,
result
)
}
// PreviewFromCRS handles previewing accounts from CRS before sync
// POST /api/v1/admin/accounts/sync/crs/preview
func
(
h
*
AccountHandler
)
PreviewFromCRS
(
c
*
gin
.
Context
)
{
var
req
PreviewFromCRSRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
result
,
err
:=
h
.
crsSyncService
.
PreviewFromCRS
(
c
.
Request
.
Context
(),
service
.
SyncFromCRSInput
{
BaseURL
:
req
.
BaseURL
,
Username
:
req
.
Username
,
Password
:
req
.
Password
,
})
if
err
!=
nil
{
response
.
InternalError
(
c
,
"CRS preview failed: "
+
err
.
Error
())
return
}
response
.
Success
(
c
,
result
)
}
// Refresh handles refreshing account credentials
// POST /api/v1/admin/accounts/:id/refresh
func
(
h
*
AccountHandler
)
Refresh
(
c
*
gin
.
Context
)
{
...
...
backend/internal/repository/account_repo.go
View file @
91ca28b7
...
...
@@ -282,6 +282,34 @@ func (r *accountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID
return
&
accounts
[
0
],
nil
}
func
(
r
*
accountRepository
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
rows
,
err
:=
r
.
sql
.
QueryContext
(
ctx
,
`
SELECT id, extra->>'crs_account_id'
FROM accounts
WHERE deleted_at IS NULL
AND extra->>'crs_account_id' IS NOT NULL
AND extra->>'crs_account_id' != ''
`
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
result
:=
make
(
map
[
string
]
int64
)
for
rows
.
Next
()
{
var
id
int64
var
crsID
string
if
err
:=
rows
.
Scan
(
&
id
,
&
crsID
);
err
!=
nil
{
return
nil
,
err
}
result
[
crsID
]
=
id
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
result
,
nil
}
func
(
r
*
accountRepository
)
Update
(
ctx
context
.
Context
,
account
*
service
.
Account
)
error
{
if
account
==
nil
{
return
nil
...
...
backend/internal/server/api_contract_test.go
View file @
91ca28b7
...
...
@@ -1049,6 +1049,10 @@ func (s *stubAccountRepo) BulkUpdate(ctx context.Context, ids []int64, updates s
return
int64
(
len
(
ids
)),
nil
}
func
(
s
*
stubAccountRepo
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
return
nil
,
errors
.
New
(
"not implemented"
)
}
type
stubProxyRepo
struct
{}
func
(
stubProxyRepo
)
Create
(
ctx
context
.
Context
,
proxy
*
service
.
Proxy
)
error
{
...
...
backend/internal/server/routes/admin.go
View file @
91ca28b7
...
...
@@ -209,6 +209,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts
.
GET
(
"/:id"
,
h
.
Admin
.
Account
.
GetByID
)
accounts
.
POST
(
""
,
h
.
Admin
.
Account
.
Create
)
accounts
.
POST
(
"/sync/crs"
,
h
.
Admin
.
Account
.
SyncFromCRS
)
accounts
.
POST
(
"/sync/crs/preview"
,
h
.
Admin
.
Account
.
PreviewFromCRS
)
accounts
.
PUT
(
"/:id"
,
h
.
Admin
.
Account
.
Update
)
accounts
.
DELETE
(
"/:id"
,
h
.
Admin
.
Account
.
Delete
)
accounts
.
POST
(
"/:id/test"
,
h
.
Admin
.
Account
.
Test
)
...
...
backend/internal/service/account_service.go
View file @
91ca28b7
...
...
@@ -25,6 +25,9 @@ type AccountRepository interface {
// GetByCRSAccountID finds an account previously synced from CRS.
// Returns (nil, nil) if not found.
GetByCRSAccountID
(
ctx
context
.
Context
,
crsAccountID
string
)
(
*
Account
,
error
)
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
// for all accounts that have been synced from CRS.
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
Update
(
ctx
context
.
Context
,
account
*
Account
)
error
Delete
(
ctx
context
.
Context
,
id
int64
)
error
...
...
backend/internal/service/account_service_delete_test.go
View file @
91ca28b7
...
...
@@ -54,6 +54,10 @@ func (s *accountRepoStub) GetByCRSAccountID(ctx context.Context, crsAccountID st
panic
(
"unexpected GetByCRSAccountID call"
)
}
func
(
s
*
accountRepoStub
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
panic
(
"unexpected ListCRSAccountIDs call"
)
}
func
(
s
*
accountRepoStub
)
Update
(
ctx
context
.
Context
,
account
*
Account
)
error
{
panic
(
"unexpected Update call"
)
}
...
...
backend/internal/service/crs_sync_helpers_test.go
0 → 100644
View file @
91ca28b7
package
service
import
(
"testing"
)
func
TestBuildSelectedSet
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
ids
[]
string
wantNil
bool
wantSize
int
}{
{
name
:
"nil input returns nil (backward compatible: create all)"
,
ids
:
nil
,
wantNil
:
true
,
},
{
name
:
"empty slice returns empty map (create none)"
,
ids
:
[]
string
{},
wantNil
:
false
,
wantSize
:
0
,
},
{
name
:
"single ID"
,
ids
:
[]
string
{
"abc-123"
},
wantNil
:
false
,
wantSize
:
1
,
},
{
name
:
"multiple IDs"
,
ids
:
[]
string
{
"a"
,
"b"
,
"c"
},
wantNil
:
false
,
wantSize
:
3
,
},
{
name
:
"duplicate IDs are deduplicated"
,
ids
:
[]
string
{
"a"
,
"a"
,
"b"
},
wantNil
:
false
,
wantSize
:
2
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
buildSelectedSet
(
tt
.
ids
)
if
tt
.
wantNil
{
if
got
!=
nil
{
t
.
Errorf
(
"buildSelectedSet(%v) = %v, want nil"
,
tt
.
ids
,
got
)
}
return
}
if
got
==
nil
{
t
.
Fatalf
(
"buildSelectedSet(%v) = nil, want non-nil map"
,
tt
.
ids
)
}
if
len
(
got
)
!=
tt
.
wantSize
{
t
.
Errorf
(
"buildSelectedSet(%v) has %d entries, want %d"
,
tt
.
ids
,
len
(
got
),
tt
.
wantSize
)
}
// Verify all unique IDs are present
for
_
,
id
:=
range
tt
.
ids
{
if
_
,
ok
:=
got
[
id
];
!
ok
{
t
.
Errorf
(
"buildSelectedSet(%v) missing key %q"
,
tt
.
ids
,
id
)
}
}
})
}
}
func
TestShouldCreateAccount
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
crsID
string
selectedSet
map
[
string
]
struct
{}
want
bool
}{
{
name
:
"nil set allows all (backward compatible)"
,
crsID
:
"any-id"
,
selectedSet
:
nil
,
want
:
true
,
},
{
name
:
"empty set blocks all"
,
crsID
:
"any-id"
,
selectedSet
:
map
[
string
]
struct
{}{},
want
:
false
,
},
{
name
:
"ID in set is allowed"
,
crsID
:
"abc-123"
,
selectedSet
:
map
[
string
]
struct
{}{
"abc-123"
:
{},
"def-456"
:
{}},
want
:
true
,
},
{
name
:
"ID not in set is blocked"
,
crsID
:
"xyz-789"
,
selectedSet
:
map
[
string
]
struct
{}{
"abc-123"
:
{},
"def-456"
:
{}},
want
:
false
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
got
:=
shouldCreateAccount
(
tt
.
crsID
,
tt
.
selectedSet
)
if
got
!=
tt
.
want
{
t
.
Errorf
(
"shouldCreateAccount(%q, %v) = %v, want %v"
,
tt
.
crsID
,
tt
.
selectedSet
,
got
,
tt
.
want
)
}
})
}
}
backend/internal/service/crs_sync_service.go
View file @
91ca28b7
...
...
@@ -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"
)
}
base
URL
:=
strings
.
TrimSpace
(
input
.
B
aseURL
)
normalized
URL
:=
strings
.
TrimSpace
(
b
aseURL
)
if
s
.
cfg
.
Security
.
URLAllowlist
.
Enabled
{
normalized
,
err
:=
normalizeBaseURL
(
base
URL
,
s
.
cfg
.
Security
.
URLAllowlist
.
CRSHosts
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowPrivateHosts
)
normalized
,
err
:=
normalizeBaseURL
(
normalized
URL
,
s
.
cfg
.
Security
.
URLAllowlist
.
CRSHosts
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowPrivateHosts
)
if
err
!=
nil
{
return
nil
,
err
}
base
URL
=
normalized
normalized
URL
=
normalized
}
else
{
normalized
,
err
:=
urlvalidator
.
ValidateURLFormat
(
base
URL
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowInsecureHTTP
)
normalized
,
err
:=
urlvalidator
.
ValidateURLFormat
(
normalized
URL
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowInsecureHTTP
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid base_url: %w"
,
err
)
}
base
URL
=
normalized
normalized
URL
=
normalized
}
if
strings
.
TrimSpace
(
input
.
U
sername
)
==
""
||
strings
.
TrimSpace
(
input
.
P
assword
)
==
""
{
if
strings
.
TrimSpace
(
u
sername
)
==
""
||
strings
.
TrimSpace
(
p
assword
)
==
""
{
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
.
U
sername
,
input
.
P
assword
)
adminToken
,
err
:=
crsLogin
(
ctx
,
client
,
normalizedURL
,
u
sername
,
p
assword
)
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
}
backend/internal/service/gateway_multiplatform_test.go
View file @
91ca28b7
...
...
@@ -77,6 +77,9 @@ func (m *mockAccountRepoForPlatform) Create(ctx context.Context, account *Accoun
func
(
m
*
mockAccountRepoForPlatform
)
GetByCRSAccountID
(
ctx
context
.
Context
,
crsAccountID
string
)
(
*
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForPlatform
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForPlatform
)
Update
(
ctx
context
.
Context
,
account
*
Account
)
error
{
return
nil
}
...
...
backend/internal/service/gemini_multiplatform_test.go
View file @
91ca28b7
...
...
@@ -66,6 +66,9 @@ func (m *mockAccountRepoForGemini) Create(ctx context.Context, account *Account)
func
(
m
*
mockAccountRepoForGemini
)
GetByCRSAccountID
(
ctx
context
.
Context
,
crsAccountID
string
)
(
*
Account
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
ListCRSAccountIDs
(
ctx
context
.
Context
)
(
map
[
string
]
int64
,
error
)
{
return
nil
,
nil
}
func
(
m
*
mockAccountRepoForGemini
)
Update
(
ctx
context
.
Context
,
account
*
Account
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
m
*
mockAccountRepoForGemini
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
Account
,
*
pagination
.
PaginationResult
,
error
)
{
...
...
frontend/src/api/admin/accounts.ts
View file @
91ca28b7
...
...
@@ -327,11 +327,34 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
return
data
}
export
interface
CRSPreviewAccount
{
crs_account_id
:
string
kind
:
string
name
:
string
platform
:
string
type
:
string
}
export
interface
PreviewFromCRSResult
{
new_accounts
:
CRSPreviewAccount
[]
existing_accounts
:
CRSPreviewAccount
[]
}
export
async
function
previewFromCrs
(
params
:
{
base_url
:
string
username
:
string
password
:
string
}):
Promise
<
PreviewFromCRSResult
>
{
const
{
data
}
=
await
apiClient
.
post
<
PreviewFromCRSResult
>
(
'
/admin/accounts/sync/crs/preview
'
,
params
)
return
data
}
export
async
function
syncFromCrs
(
params
:
{
base_url
:
string
username
:
string
password
:
string
sync_proxies
?:
boolean
selected_account_ids
?:
string
[]
}):
Promise
<
{
created
:
number
updated
:
number
...
...
@@ -345,7 +368,19 @@ export async function syncFromCrs(params: {
error
?:
string
}
>
}
>
{
const
{
data
}
=
await
apiClient
.
post
(
'
/admin/accounts/sync/crs
'
,
params
)
const
{
data
}
=
await
apiClient
.
post
<
{
created
:
number
updated
:
number
skipped
:
number
failed
:
number
items
:
Array
<
{
crs_account_id
:
string
kind
:
string
name
:
string
action
:
string
error
?:
string
}
>
}
>
(
'
/admin/accounts/sync/crs
'
,
params
)
return
data
}
...
...
@@ -442,6 +477,7 @@ export const accountsAPI = {
batchCreate
,
batchUpdateCredentials
,
bulkUpdate
,
previewFromCrs
,
syncFromCrs
,
exportData
,
importData
,
...
...
frontend/src/components/account/SyncFromCrsModal.vue
View file @
91ca28b7
...
...
@@ -6,15 +6,20 @@
close-on-click-outside
@
close=
"handleClose"
>
<form
id=
"sync-from-crs-form"
class=
"space-y-4"
@
submit.prevent=
"handleSync"
>
<!-- Step 1: Input credentials -->
<form
v-if=
"currentStep === 'input'"
id=
"sync-from-crs-form"
class=
"space-y-4"
@
submit.prevent=
"handlePreview"
>
<div
class=
"text-sm text-gray-600 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.syncFromCrsDesc
'
)
}}
</div>
<div
class=
"rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
{{
t
(
'
admin.accounts.crsUpdateBehaviorNote
'
)
}}
</div>
<div
class=
"rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
...
...
@@ -24,26 +29,30 @@
<div
class=
"grid grid-cols-1 gap-4"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsBaseUrl
'
)
}}
</label>
<label
for=
"crs-base-url"
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsBaseUrl
'
)
}}
</label>
<input
id=
"crs-base-url"
v-model=
"form.base_url"
type=
"text"
class=
"input"
required
:placeholder=
"t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div
class=
"grid grid-cols-1 gap-4 sm:grid-cols-2"
>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsUsername
'
)
}}
</label>
<input
v-model=
"form.username"
type=
"text"
class=
"input"
autocomplete=
"username"
/>
<label
for=
"crs-username"
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsUsername
'
)
}}
</label>
<input
id=
"crs-username"
v-model=
"form.username"
type=
"text"
class=
"input"
required
autocomplete=
"username"
/>
</div>
<div>
<label
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsPassword
'
)
}}
</label>
<label
for=
"crs-password"
class=
"input-label"
>
{{
t
(
'
admin.accounts.crsPassword
'
)
}}
</label>
<input
id=
"crs-password"
v-model=
"form.password"
type=
"password"
class=
"input"
required
autocomplete=
"current-password"
/>
</div>
...
...
@@ -58,9 +67,101 @@
{{
t
(
'
admin.accounts.syncProxies
'
)
}}
</label>
</div>
</form>
<!-- Step 2: Preview & select -->
<div
v-else-if=
"currentStep === 'preview' && previewResult"
class=
"space-y-4"
>
<!-- Existing accounts (read-only info) -->
<div
v-if=
"previewResult.existing_accounts.length"
class=
"rounded-lg bg-gray-50 p-3 dark:bg-dark-700/60"
>
<div
class=
"mb-2 text-sm font-medium text-gray-700 dark:text-dark-300"
>
{{
t
(
'
admin.accounts.crsExistingAccounts
'
)
}}
<span
class=
"ml-1 text-xs text-gray-400"
>
(
{{
previewResult
.
existing_accounts
.
length
}}
)
</span>
</div>
<div
class=
"max-h-32 overflow-auto text-xs text-gray-500 dark:text-dark-400"
>
<div
v-for=
"acc in previewResult.existing_accounts"
:key=
"acc.crs_account_id"
class=
"flex items-center gap-2 py-0.5"
>
<span
class=
"inline-block rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{
acc
.
platform
}}
/
{{
acc
.
type
}}
</span>
<span
class=
"truncate"
>
{{
acc
.
name
}}
</span>
</div>
</div>
</div>
<!-- New accounts (selectable) -->
<div
v-if=
"previewResult.new_accounts.length"
>
<div
class=
"mb-2 flex items-center justify-between"
>
<div
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{
t
(
'
admin.accounts.crsNewAccounts
'
)
}}
<span
class=
"ml-1 text-xs text-gray-400"
>
(
{{
previewResult
.
new_accounts
.
length
}}
)
</span>
</div>
<div
class=
"flex gap-2"
>
<button
type=
"button"
class=
"text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@
click=
"selectAll"
>
{{
t
(
'
admin.accounts.crsSelectAll
'
)
}}
</button>
<button
type=
"button"
class=
"text-xs text-gray-500 hover:text-gray-600 dark:text-gray-400"
@
click=
"selectNone"
>
{{
t
(
'
admin.accounts.crsSelectNone
'
)
}}
</button>
</div>
</div>
<div
class=
"max-h-48 overflow-auto rounded-lg border border-gray-200 p-2 dark:border-dark-600"
>
<label
v-for=
"acc in previewResult.new_accounts"
:key=
"acc.crs_account_id"
class=
"flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-gray-50 dark:hover:bg-dark-700/40"
>
<input
type=
"checkbox"
:checked=
"selectedIds.has(acc.crs_account_id)"
class=
"rounded border-gray-300 dark:border-dark-600"
@
change=
"toggleSelect(acc.crs_account_id)"
/>
<span
class=
"inline-block rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
>
{{
acc
.
platform
}}
/
{{
acc
.
type
}}
</span>
<span
class=
"truncate text-sm text-gray-700 dark:text-dark-300"
>
{{
acc
.
name
}}
</span>
</label>
</div>
<div
class=
"mt-1 text-xs text-gray-400"
>
{{
t
(
'
admin.accounts.crsSelectedCount
'
,
{
count
:
selectedIds
.
size
}
)
}}
<
/div
>
<
/div
>
<!--
Sync
options
summary
-->
<
div
class
=
"
flex items-center gap-2 text-xs text-gray-500 dark:text-dark-400
"
>
<
span
>
{{
t
(
'
admin.accounts.syncProxies
'
)
}}
:
<
/span
>
<
span
:
class
=
"
form.sync_proxies ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-dark-500'
"
>
{{
form
.
sync_proxies
?
t
(
'
common.yes
'
)
:
t
(
'
common.no
'
)
}}
<
/span
>
<
/div
>
<!--
No
new
accounts
-->
<
div
v
-
if
=
"
!previewResult.new_accounts.length
"
class
=
"
rounded-lg bg-gray-50 p-4 text-center text-sm text-gray-500 dark:bg-dark-700/60 dark:text-dark-400
"
>
{{
t
(
'
admin.accounts.crsNoNewAccounts
'
)
}}
<
span
v
-
if
=
"
previewResult.existing_accounts.length
"
>
{{
t
(
'
admin.accounts.crsWillUpdate
'
,
{
count
:
previewResult
.
existing_accounts
.
length
}
)
}}
<
/span
>
<
/div
>
<
/div
>
<!--
Step
3
:
Result
-->
<
div
v
-
else
-
if
=
"
currentStep === 'result' && result
"
class
=
"
space-y-4
"
>
<
div
v-if=
"result"
class
=
"
space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700
"
>
<
div
class
=
"
text-sm font-medium text-gray-900 dark:text-white
"
>
...
...
@@ -84,21 +185,56 @@
<
/div
>
<
/div
>
<
/div
>
<
/
form
>
<
/
div
>
<
template
#
footer
>
<
div
class
=
"
flex justify-end gap-3
"
>
<
button
class
=
"
btn btn-secondary
"
type
=
"
button
"
:
disabled
=
"
syncing
"
@
click
=
"
handleClose
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
class
=
"
btn btn-primary
"
type
=
"
submit
"
form
=
"
sync-from-crs-form
"
:
disabled
=
"
syncing
"
>
{{
syncing
?
t
(
'
admin.accounts.syncing
'
)
:
t
(
'
admin.accounts.syncNow
'
)
}}
<
/button
>
<!--
Step
1
:
Input
-->
<
template
v
-
if
=
"
currentStep === 'input'
"
>
<
button
class
=
"
btn btn-secondary
"
type
=
"
button
"
:
disabled
=
"
previewing
"
@
click
=
"
handleClose
"
>
{{
t
(
'
common.cancel
'
)
}}
<
/button
>
<
button
class
=
"
btn btn-primary
"
type
=
"
submit
"
form
=
"
sync-from-crs-form
"
:
disabled
=
"
previewing
"
>
{{
previewing
?
t
(
'
admin.accounts.crsPreviewing
'
)
:
t
(
'
admin.accounts.crsPreview
'
)
}}
<
/button
>
<
/template
>
<!--
Step
2
:
Preview
-->
<
template
v
-
else
-
if
=
"
currentStep === 'preview'
"
>
<
button
class
=
"
btn btn-secondary
"
type
=
"
button
"
:
disabled
=
"
syncing
"
@
click
=
"
handleBack
"
>
{{
t
(
'
admin.accounts.crsBack
'
)
}}
<
/button
>
<
button
class
=
"
btn btn-primary
"
type
=
"
button
"
:
disabled
=
"
syncing || hasNewButNoneSelected
"
@
click
=
"
handleSync
"
>
{{
syncing
?
t
(
'
admin.accounts.syncing
'
)
:
t
(
'
admin.accounts.syncNow
'
)
}}
<
/button
>
<
/template
>
<!--
Step
3
:
Result
-->
<
template
v
-
else
-
if
=
"
currentStep === 'result'
"
>
<
button
class
=
"
btn btn-secondary
"
type
=
"
button
"
@
click
=
"
handleClose
"
>
{{
t
(
'
common.close
'
)
}}
<
/button
>
<
/template
>
<
/div
>
<
/template
>
<
/BaseDialog
>
...
...
@@ -110,6 +246,7 @@ import { useI18n } from 'vue-i18n'
import
BaseDialog
from
'
@/components/common/BaseDialog.vue
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
adminAPI
}
from
'
@/api/admin
'
import
type
{
PreviewFromCRSResult
}
from
'
@/api/admin/accounts
'
interface
Props
{
show
:
boolean
...
...
@@ -126,7 +263,12 @@ const emit = defineEmits<Emits>()
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
type
Step
=
'
input
'
|
'
preview
'
|
'
result
'
const
currentStep
=
ref
<
Step
>
(
'
input
'
)
const
previewing
=
ref
(
false
)
const
syncing
=
ref
(
false
)
const
previewResult
=
ref
<
PreviewFromCRSResult
|
null
>
(
null
)
const
selectedIds
=
ref
(
new
Set
<
string
>
())
const
result
=
ref
<
Awaited
<
ReturnType
<
typeof
adminAPI
.
accounts
.
syncFromCrs
>>
|
null
>
(
null
)
const
form
=
reactive
({
...
...
@@ -136,28 +278,90 @@ const form = reactive({
sync_proxies
:
true
}
)
const
hasNewButNoneSelected
=
computed
(()
=>
{
if
(
!
previewResult
.
value
)
return
false
return
previewResult
.
value
.
new_accounts
.
length
>
0
&&
selectedIds
.
value
.
size
===
0
}
)
const
errorItems
=
computed
(()
=>
{
if
(
!
result
.
value
?.
items
)
return
[]
return
result
.
value
.
items
.
filter
((
i
)
=>
i
.
action
===
'
failed
'
||
i
.
action
===
'
skipped
'
)
return
result
.
value
.
items
.
filter
(
(
i
)
=>
i
.
action
===
'
failed
'
||
(
i
.
action
===
'
skipped
'
&&
i
.
error
!==
'
not selected
'
)
)
}
)
watch
(
()
=>
props
.
show
,
(
open
)
=>
{
if
(
open
)
{
currentStep
.
value
=
'
input
'
previewResult
.
value
=
null
selectedIds
.
value
=
new
Set
()
result
.
value
=
null
form
.
base_url
=
''
form
.
username
=
''
form
.
password
=
''
form
.
sync_proxies
=
true
}
}
)
const
handleClose
=
()
=>
{
// 防止在同步进行中关闭对话框
if
(
syncing
.
value
)
{
if
(
syncing
.
value
||
previewing
.
value
)
{
return
}
emit
(
'
close
'
)
}
const
handleBack
=
()
=>
{
currentStep
.
value
=
'
input
'
previewResult
.
value
=
null
selectedIds
.
value
=
new
Set
()
}
const
selectAll
=
()
=>
{
if
(
!
previewResult
.
value
)
return
selectedIds
.
value
=
new
Set
(
previewResult
.
value
.
new_accounts
.
map
((
a
)
=>
a
.
crs_account_id
))
}
const
selectNone
=
()
=>
{
selectedIds
.
value
=
new
Set
()
}
const
toggleSelect
=
(
id
:
string
)
=>
{
const
s
=
new
Set
(
selectedIds
.
value
)
if
(
s
.
has
(
id
))
{
s
.
delete
(
id
)
}
else
{
s
.
add
(
id
)
}
selectedIds
.
value
=
s
}
const
handlePreview
=
async
()
=>
{
if
(
!
form
.
base_url
.
trim
()
||
!
form
.
username
.
trim
()
||
!
form
.
password
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.syncMissingFields
'
))
return
}
previewing
.
value
=
true
try
{
const
res
=
await
adminAPI
.
accounts
.
previewFromCrs
({
base_url
:
form
.
base_url
.
trim
(),
username
:
form
.
username
.
trim
(),
password
:
form
.
password
}
)
previewResult
.
value
=
res
// Auto-select all new accounts
selectedIds
.
value
=
new
Set
(
res
.
new_accounts
.
map
((
a
)
=>
a
.
crs_account_id
))
currentStep
.
value
=
'
preview
'
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.accounts.crsPreviewFailed
'
))
}
finally
{
previewing
.
value
=
false
}
}
const
handleSync
=
async
()
=>
{
if
(
!
form
.
base_url
.
trim
()
||
!
form
.
username
.
trim
()
||
!
form
.
password
.
trim
())
{
appStore
.
showError
(
t
(
'
admin.accounts.syncMissingFields
'
))
...
...
@@ -170,16 +374,18 @@ const handleSync = async () => {
base_url
:
form
.
base_url
.
trim
(),
username
:
form
.
username
.
trim
(),
password
:
form
.
password
,
sync_proxies
:
form
.
sync_proxies
sync_proxies
:
form
.
sync_proxies
,
selected_account_ids
:
[...
selectedIds
.
value
]
}
)
result
.
value
=
res
currentStep
.
value
=
'
result
'
if
(
res
.
failed
>
0
)
{
appStore
.
showError
(
t
(
'
admin.accounts.syncCompletedWithErrors
'
,
res
))
}
else
{
appStore
.
showSuccess
(
t
(
'
admin.accounts.syncCompleted
'
,
res
))
emit
(
'
synced
'
)
}
emit
(
'
synced
'
)
}
catch
(
error
:
any
)
{
appStore
.
showError
(
error
?.
message
||
t
(
'
admin.accounts.syncFailed
'
))
}
finally
{
...
...
frontend/src/i18n/locales/en.ts
View file @
91ca28b7
...
...
@@ -1309,10 +1309,23 @@ export default {
syncResult
:
'
Sync Result
'
,
syncResultSummary
:
'
Created {created}, updated {updated}, skipped {skipped}, failed {failed}
'
,
syncErrors
:
'
Errors / Skipped Details
'
,
syncCompleted
:
'
Sync completed: created {created}, updated {updated}
'
,
syncCompleted
:
'
Sync completed: created {created}, updated {updated}
, skipped {skipped}
'
,
syncCompletedWithErrors
:
'
Sync completed with errors: failed {failed} (created {created}, updated {updated})
'
,
'
Sync completed with errors: failed {failed} (created {created}, updated {updated}
, skipped {skipped}
)
'
,
syncFailed
:
'
Sync failed
'
,
crsPreview
:
'
Preview
'
,
crsPreviewing
:
'
Previewing...
'
,
crsPreviewFailed
:
'
Preview failed
'
,
crsExistingAccounts
:
'
Existing accounts (will be updated)
'
,
crsNewAccounts
:
'
New accounts (select to sync)
'
,
crsSelectAll
:
'
Select all
'
,
crsSelectNone
:
'
Select none
'
,
crsNoNewAccounts
:
'
All CRS accounts are already synced.
'
,
crsWillUpdate
:
'
Will update {count} existing accounts.
'
,
crsSelectedCount
:
'
{count} new accounts selected
'
,
crsUpdateBehaviorNote
:
'
Existing accounts only sync fields returned by CRS; missing fields keep their current values. Credentials are merged by key — keys not returned by CRS are preserved. Proxies are kept when "Sync proxies" is unchecked.
'
,
crsBack
:
'
Back
'
,
editAccount
:
'
Edit Account
'
,
deleteAccount
:
'
Delete Account
'
,
searchAccounts
:
'
Search accounts...
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
91ca28b7
...
...
@@ -1397,9 +1397,22 @@ export default {
syncResult
:
'
同步结果
'
,
syncResultSummary
:
'
创建 {created},更新 {updated},跳过 {skipped},失败 {failed}
'
,
syncErrors
:
'
错误/跳过详情
'
,
syncCompleted
:
'
同步完成:创建 {created},更新 {updated}
'
,
syncCompletedWithErrors
:
'
同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})
'
,
syncCompleted
:
'
同步完成:创建 {created},更新 {updated}
,跳过 {skipped}
'
,
syncCompletedWithErrors
:
'
同步完成但有错误:失败 {failed}(创建 {created},更新 {updated}
,跳过 {skipped}
)
'
,
syncFailed
:
'
同步失败
'
,
crsPreview
:
'
预览
'
,
crsPreviewing
:
'
预览中...
'
,
crsPreviewFailed
:
'
预览失败
'
,
crsExistingAccounts
:
'
将自动更新的已有账号
'
,
crsNewAccounts
:
'
新账号(可选择)
'
,
crsSelectAll
:
'
全选
'
,
crsSelectNone
:
'
全不选
'
,
crsNoNewAccounts
:
'
所有 CRS 账号均已同步。
'
,
crsWillUpdate
:
'
将更新 {count} 个已有账号。
'
,
crsSelectedCount
:
'
已选择 {count} 个新账号
'
,
crsUpdateBehaviorNote
:
'
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
'
,
crsBack
:
'
返回
'
,
editAccount
:
'
编辑账号
'
,
deleteAccount
:
'
删除账号
'
,
deleteConfirmMessage
:
"
确定要删除账号 '{name}' 吗?
"
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment