From 60ff61132da6d4c8838d2d9604b702f97fc45d68 Mon Sep 17 00:00:00 2001 From: yan9651688 <174289968+yan9651688@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:51:13 +0800 Subject: [PATCH 1/2] feat(accounts): make repeated static account setup safer Admins often need another account with the same provider and routing configuration. Duplicate on the server so credentials never return to the browser, preserve exact group priorities atomically, start the copy paused, and recover the same copy after ambiguous idempotency-store failures. Constraint: Admin account responses redact credentials, so duplication must remain server-side Constraint: OAuth and setup-token credentials rotate and must not be shared across account rows Rejected: Copy raw account JSON to the clipboard | exposes credentials outside the server Rejected: Duplicate rotating credentials | account-scoped refresh locks can race token rotation Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep copies paused, avoid automatic upstream probes, and exclude rotating credential types unless token ownership is redesigned Tested: Targeted Go tests, Go vet, server build; frontend lint, typecheck, Vitest suite, production build; integration test compiled Not-tested: Docker-backed PostgreSQL execution because Docker is unavailable Related: Wei-Shaw/sub2api#1379 Related: Wei-Shaw/sub2api#2928 --- backend/cmd/server/wire_gen.go | 3 +- .../internal/handler/admin/account_handler.go | 35 ++ .../admin/account_handler_duplicate_test.go | 122 +++++++ .../handler/admin/admin_service_stub_test.go | 5 + backend/internal/repository/account_repo.go | 72 +++- ...account_repo_duplicate_integration_test.go | 73 ++++ backend/internal/repository/wire.go | 1 + backend/internal/server/api_contract_test.go | 4 + backend/internal/server/routes/admin.go | 1 + backend/internal/service/account_service.go | 13 + backend/internal/service/admin_account.go | 336 ++++++++++++++++-- backend/internal/service/admin_service.go | 7 +- .../admin_service_duplicate_account_test.go | 313 ++++++++++++++++ .../admin.accounts.duplicate.spec.ts | 43 +++ frontend/src/api/admin/accounts.ts | 22 ++ .../admin/account/AccountActionMenu.vue | 10 +- .../AccountActionMenu.spark_shadow.spec.ts | 49 +++ .../src/i18n/locales/en/admin/accounts.ts | 3 + .../src/i18n/locales/zh/admin/accounts.ts | 3 + frontend/src/views/admin/AccountsView.vue | 17 +- .../AccountsView.sparkShadow.spec.ts | 53 ++- 21 files changed, 1142 insertions(+), 43 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_duplicate_test.go create mode 100644 backend/internal/repository/account_repo_duplicate_integration_test.go create mode 100644 backend/internal/service/admin_service_duplicate_account_test.go create mode 100644 frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 2b339f3d96..4012e5d228 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -70,6 +70,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) + adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService, concurrencyService) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) @@ -175,7 +176,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService) + adminService := service.NewAdminService(userRepository, groupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index e4ed5b46b0..a8aeb46709 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -860,6 +860,41 @@ func (h *AccountHandler) Create(c *gin.Context) { response.Success(c, result.Data) } +// Duplicate handles creating an independent account from an existing account's configuration. +// POST /api/v1/admin/accounts/:id/duplicate +func (h *AccountHandler) Duplicate(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + result, err := executeAdminIdempotent( + c, + "admin.accounts.duplicate", + struct { + AccountID int64 `json:"account_id"` + }{AccountID: accountID}, + service.DefaultWriteIdempotencyTTL(), + func(ctx context.Context) (any, error) { + account, execErr := h.adminService.DuplicateAccount(ctx, accountID, c.GetHeader("Idempotency-Key")) + if execErr != nil { + return nil, execErr + } + return h.buildAccountResponseWithRuntime(ctx, account), nil + }, + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + + if result != nil && result.Replayed { + c.Header("X-Idempotency-Replayed", "true") + } + response.Success(c, result.Data) +} + // Update handles updating an account // PUT /api/v1/admin/accounts/:id func (h *AccountHandler) Update(c *gin.Context) { diff --git a/backend/internal/handler/admin/account_handler_duplicate_test.go b/backend/internal/handler/admin/account_handler_duplicate_test.go new file mode 100644 index 0000000000..2233acbe98 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_duplicate_test.go @@ -0,0 +1,122 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type duplicateAccountAdminServiceStub struct { + service.AdminService + account *service.Account + calls int + accountID int64 + operationKey string +} + +func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, operationKey string) (*service.Account, error) { + s.calls++ + s.accountID = accountID + s.operationKey = operationKey + return s.account, nil +} + +func setupDuplicateAccountRouter(t *testing.T, svc *duplicateAccountAdminServiceStub) *gin.Engine { + t.Helper() + previousCoordinator := service.DefaultIdempotencyCoordinator() + service.SetDefaultIdempotencyCoordinator(nil) + t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) }) + + gin.SetMode(gin.TestMode) + router := gin.New() + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router.POST("/api/v1/admin/accounts/:id/duplicate", handler.Duplicate) + return router +} + +func TestDuplicateAccountHandlerRedactsCredentials(t *testing.T) { + svc := &duplicateAccountAdminServiceStub{ + account: &service.Account{ + ID: 43, + Name: "primary (Copy)", + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: false, + Credentials: map[string]any{"api_key": "top-secret-key"}, + }, + } + router := setupDuplicateAccountRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, svc.calls) + require.Contains(t, recorder.Body.String(), `"name":"primary (Copy)"`) + require.NotContains(t, recorder.Body.String(), "top-secret-key") + var responseBody struct { + Data struct { + Credentials map[string]any `json:"credentials"` + Schedulable bool `json:"schedulable"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &responseBody)) + require.Empty(t, responseBody.Data.Credentials) + require.False(t, responseBody.Data.Schedulable) +} + +func TestDuplicateAccountHandlerRejectsInvalidID(t *testing.T) { + svc := &duplicateAccountAdminServiceStub{} + router := setupDuplicateAccountRouter(t, svc) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/not-a-number/duplicate", nil) + + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Zero(t, svc.calls) +} + +func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) { + svc := &duplicateAccountAdminServiceStub{ + account: &service.Account{ + ID: 43, + Name: "primary (Copy)", + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: false, + }, + } + router := setupDuplicateAccountRouter(t, svc) + repo := newMemoryIdempotencyRepoStub() + service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())) + + call := func() *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil) + request.Header.Set("Idempotency-Key", "duplicate-account-42") + router.ServeHTTP(recorder, request) + return recorder + } + + first := call() + second := call() + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusOK, second.Code) + require.Equal(t, 1, svc.calls) + require.Equal(t, int64(42), svc.accountID) + require.Equal(t, "duplicate-account-42", svc.operationKey) + require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed")) +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 5e9c4d517e..ebd0c042c5 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -418,6 +418,11 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre return &account, nil } +func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, operationKey string) (*service.Account, error) { + account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false} + return &account, nil +} + func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) { s.updateAccountCalls++ if s.updateAccountErr != nil { diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index e85bf46fc1..9af99223f7 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -73,6 +73,12 @@ func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache se return newAccountRepositoryWithSQL(client, sqlDB, schedulerCache) } +// NewAdminAccountRepository exposes the account repository's atomic duplication capability +// as an explicit dependency of the admin service. +func NewAdminAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AdminAccountRepository { + return newAccountRepositoryWithSQL(client, sqlDB, schedulerCache) +} + // newAccountRepositoryWithSQL 是内部构造函数,支持依赖注入 SQL 执行器。 // 这种设计便于单元测试时注入 mock 对象。 func newAccountRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, schedulerCache service.SchedulerCache) *accountRepository { @@ -80,11 +86,21 @@ func newAccountRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, schedul } func (r *accountRepository) Create(ctx context.Context, account *service.Account) error { + if err := createAccountRecord(ctx, r.client, account); err != nil { + return err + } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &account.ID, nil, buildSchedulerGroupPayload(account.GroupIDs)); err != nil { + logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue account create failed: account=%d err=%v", account.ID, err) + } + return nil +} + +func createAccountRecord(ctx context.Context, client *dbent.Client, account *service.Account) error { if account == nil { return service.ErrAccountNilInput } - builder := r.client.Account.Create(). + builder := client.Account.Create(). SetName(account.Name). SetNillableNotes(account.Notes). SetPlatform(account.Platform). @@ -146,8 +162,58 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account account.ID = created.ID account.CreatedAt = created.CreatedAt account.UpdatedAt = created.UpdatedAt - if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &account.ID, nil, buildSchedulerGroupPayload(account.GroupIDs)); err != nil { - logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue account create failed: account=%d err=%v", account.ID, err) + return nil +} + +// CreateWithAccountGroups atomically persists an account, its exact per-group priorities, +// and the scheduler outbox event used to publish the new routing snapshot. +func (r *accountRepository) CreateWithAccountGroups(ctx context.Context, account *service.Account, groups []service.AccountGroup) error { + if account == nil { + return service.ErrAccountNilInput + } + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + + var txClient *dbent.Client + if err == nil { + defer func() { _ = tx.Rollback() }() + txClient = tx.Client() + } else { + // Reuse a caller-owned transaction when this repository is already transactional. + txClient = r.client + } + + if err := createAccountRecord(ctx, txClient, account); err != nil { + return err + } + groupIDs := make([]int64, 0, len(groups)) + if len(groups) > 0 { + builders := make([]*dbent.AccountGroupCreate, 0, len(groups)) + for i := range groups { + groups[i].AccountID = account.ID + groupIDs = append(groupIDs, groups[i].GroupID) + builders = append(builders, txClient.AccountGroup.Create(). + SetAccountID(account.ID). + SetGroupID(groups[i].GroupID). + SetPriority(groups[i].Priority), + ) + } + if _, err := txClient.AccountGroup.CreateBulk(builders...).Save(ctx); err != nil { + return err + } + } + account.GroupIDs = groupIDs + account.AccountGroups = append([]service.AccountGroup(nil), groups...) + if err := enqueueSchedulerOutbox(ctx, txClient, service.SchedulerOutboxEventAccountChanged, &account.ID, nil, buildSchedulerGroupPayload(groupIDs)); err != nil { + return err + } + + if tx != nil { + if err := tx.Commit(); err != nil { + return err + } } return nil } diff --git a/backend/internal/repository/account_repo_duplicate_integration_test.go b/backend/internal/repository/account_repo_duplicate_integration_test.go new file mode 100644 index 0000000000..f36ee1d45e --- /dev/null +++ b/backend/internal/repository/account_repo_duplicate_integration_test.go @@ -0,0 +1,73 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestCreateWithAccountGroupsPersistsPausedCopyAtomically(t *testing.T) { + ctx := context.Background() + client := testEntClient(t) + repo := newAccountRepositoryWithSQL(client, integrationDB, nil) + suffix := time.Now().UnixNano() + + group, err := client.Group.Create(). + SetName(fmt.Sprintf("duplicate-atomic-%d", suffix)). + SetPlatform(service.PlatformAnthropic). + Save(ctx) + require.NoError(t, err) + + success := &service.Account{ + Name: fmt.Sprintf("duplicate-success-%d", suffix), + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: false, + Credentials: map[string]any{"api_key": "secret"}, + Extra: map[string]any{}, + } + require.NoError(t, repo.CreateWithAccountGroups(ctx, success, []service.AccountGroup{{GroupID: group.ID, Priority: 37}})) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM scheduler_outbox WHERE account_id = $1", success.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE account_id = $1", success.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", success.ID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", group.ID) + }) + + var schedulable bool + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT schedulable FROM accounts WHERE id = $1", success.ID).Scan(&schedulable)) + require.False(t, schedulable) + var priority int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT priority FROM account_groups WHERE account_id = $1 AND group_id = $2", success.ID, group.ID).Scan(&priority)) + require.Equal(t, 37, priority) + var outboxCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM scheduler_outbox WHERE account_id = $1", success.ID).Scan(&outboxCount)) + require.Equal(t, 1, outboxCount) + + failure := &service.Account{ + Name: fmt.Sprintf("duplicate-failure-%d", suffix), + Platform: service.PlatformAnthropic, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: false, + Credentials: map[string]any{"api_key": "secret"}, + Extra: map[string]any{}, + } + err = repo.CreateWithAccountGroups(ctx, failure, []service.AccountGroup{{GroupID: int64(^uint64(0) >> 1), Priority: 1}}) + require.Error(t, err) + + var accountCount, groupCount, failedOutboxCount int + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM accounts WHERE name = $1", failure.Name).Scan(&accountCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM account_groups WHERE account_id = $1", failure.ID).Scan(&groupCount)) + require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM scheduler_outbox WHERE account_id = $1", failure.ID).Scan(&failedOutboxCount)) + require.Zero(t, accountCount) + require.Zero(t, groupCount) + require.Zero(t, failedOutboxCount) +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index ec5078eac8..0ee91d146b 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet( NewAPIKeyRepository, NewGroupRepository, NewAccountRepository, + NewAdminAccountRepository, NewScheduledTestPlanRepository, // 定时测试计划仓储 NewScheduledTestResultRepository, // 定时测试结果仓储 NewProxyRepository, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index a5e3fde155..d42c5c934f 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1721,6 +1721,10 @@ func (s *stubAccountRepo) Create(ctx context.Context, account *service.Account) return errors.New("not implemented") } +func (s *stubAccountRepo) CreateWithAccountGroups(ctx context.Context, account *service.Account, groups []service.AccountGroup) error { + return errors.New("not implemented") +} + func (s *stubAccountRepo) GetByID(ctx context.Context, id int64) (*service.Account, error) { return nil, service.ErrAccountNotFound } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5132022d4a..6bf67bcbb1 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -297,6 +297,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("", h.Admin.Account.List) accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) + accounts.POST("/:id/duplicate", h.Admin.Account.Duplicate) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 5956684f98..109ef95680 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -90,6 +90,19 @@ type AccountRepository interface { ListShadowsByParent(ctx context.Context, parentID int64) ([]*Account, error) } +type AccountDuplicateRepository interface { + // CreateWithAccountGroups atomically persists an account, its exact group priorities, + // and the scheduler outbox event for the new routing snapshot. + CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error +} + +// AdminAccountRepository makes the account-duplication write capability an explicit +// construction dependency without forcing read-only gateway test doubles to implement it. +type AdminAccountRepository interface { + AccountRepository + AccountDuplicateRepository +} + // AccountBulkUpdate describes the fields that can be updated in a bulk operation. // Nil pointers mean "do not change". type AccountBulkUpdate struct { diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 8cb6d8e63b..388bd4f1ba 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -2,6 +2,8 @@ package service import ( "context" + "crypto/sha256" + "encoding/json" "errors" "fmt" "log/slog" @@ -60,6 +62,264 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([ return accounts, nil } +const maxAccountNameRunes = 100 +const duplicateAccountOperationIDExtraKey = "duplicate_operation_id" + +func duplicateAccountName(sourceName string) string { + const suffix = " (Copy)" + nameRunes := []rune(strings.TrimSpace(sourceName)) + maxBaseRunes := maxAccountNameRunes - len([]rune(suffix)) + if len(nameRunes) > maxBaseRunes { + nameRunes = nameRunes[:maxBaseRunes] + } + return string(nameRunes) + suffix +} + +func cloneAccountJSONMap(value map[string]any) (map[string]any, error) { + if value == nil { + return nil, nil + } + payload, err := json.Marshal(value) + if err != nil { + return nil, err + } + cloned := make(map[string]any, len(value)) + if err := json.Unmarshal(payload, &cloned); err != nil { + return nil, err + } + return cloned, nil +} + +var duplicateAccountDiscardedExtraKeys = map[string]struct{}{ + // A retry identity belongs to the operation that created one copy, not to later copies. + duplicateAccountOperationIDExtraKey: {}, + // External sync identity belongs to one local account only. + "crs_account_id": {}, + "crs_kind": {}, + "crs_synced_at": {}, + // Local quota usage and derived window timestamps must start fresh. + "quota_used": {}, + "quota_daily_used": {}, + "quota_weekly_used": {}, + "quota_daily_start": {}, + "quota_weekly_start": {}, + "quota_daily_reset_at": {}, + "quota_weekly_reset_at": {}, + // Provider observations, capability probes, and transient scheduling state. + "model_rate_limits": {}, + "session_window_utilization": {}, + "passive_usage_7d_utilization": {}, + "passive_usage_7d_reset": {}, + "passive_usage_7d_oi_utilization": {}, + "passive_usage_7d_oi_reset": {}, + "passive_usage_sampled_at": {}, + "grok_usage_snapshot": {}, + "grok_billing_snapshot": {}, + "openai_responses_supported": {}, + "openai_compact_supported": {}, + "openai_compact_checked_at": {}, + "openai_compact_last_status": {}, + "openai_compact_last_error": {}, + "antigravity_credits_overages": {}, + "antigravity_force_token_refresh": {}, + "antigravity_force_token_refresh_at": {}, + "antigravity_force_token_refresh_reason": {}, + "drive_storage_limit": {}, + "drive_storage_usage": {}, + "drive_tier_updated_at": {}, + "codex_primary_used_percent": {}, + "codex_primary_reset_after_seconds": {}, + "codex_primary_window_minutes": {}, + "codex_secondary_used_percent": {}, + "codex_secondary_reset_after_seconds": {}, + "codex_secondary_window_minutes": {}, + "codex_primary_over_secondary_percent": {}, + "codex_usage_updated_at": {}, + "codex_5h_used_percent": {}, + "codex_5h_reset_after_seconds": {}, + "codex_5h_window_minutes": {}, + "codex_5h_reset_at": {}, + "codex_7d_used_percent": {}, + "codex_7d_reset_after_seconds": {}, + "codex_7d_window_minutes": {}, + "codex_7d_reset_at": {}, +} + +func duplicateAccountExtra(value map[string]any) (map[string]any, error) { + cloned, err := cloneAccountJSONMap(value) + if err != nil { + return nil, err + } + for key := range duplicateAccountDiscardedExtraKeys { + delete(cloned, key) + } + return cloned, nil +} + +func canDuplicateAccountType(accountType string) bool { + switch accountType { + case AccountTypeAPIKey, AccountTypeUpstream, AccountTypeBedrock, AccountTypeServiceAccount: + return true + default: + return false + } +} + +func duplicateAccountGroups(source *Account) ([]AccountGroup, []int64) { + if len(source.AccountGroups) > 0 { + groups := make([]AccountGroup, 0, len(source.AccountGroups)) + groupIDs := make([]int64, 0, len(source.AccountGroups)) + for _, sourceGroup := range source.AccountGroups { + groups = append(groups, AccountGroup{GroupID: sourceGroup.GroupID, Priority: sourceGroup.Priority}) + groupIDs = append(groupIDs, sourceGroup.GroupID) + } + return groups, groupIDs + } + + groups := make([]AccountGroup, 0, len(source.GroupIDs)) + groupIDs := append([]int64(nil), source.GroupIDs...) + for i, groupID := range groupIDs { + groups = append(groups, AccountGroup{GroupID: groupID, Priority: i + 1}) + } + return groups, groupIDs +} + +func duplicateAccountOperationID(sourceID int64, operationKey string) string { + operationKey = strings.TrimSpace(operationKey) + if operationKey == "" { + return "" + } + payload := "admin.accounts.duplicate\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey + digest := sha256.Sum256([]byte(payload)) + return fmt.Sprintf("%x", digest) +} + +func (s *adminServiceImpl) findDuplicateByOperationID(ctx context.Context, operationID string) (*Account, error) { + if operationID == "" { + return nil, nil + } + accounts, err := s.accountRepo.FindByExtraField(ctx, duplicateAccountOperationIDExtraKey, operationID) + if err != nil { + return nil, fmt.Errorf("find duplicate account operation: %w", err) + } + if len(accounts) == 0 { + return nil, nil + } + account := accounts[0] + return &account, nil +} + +func cloneAccountValuePointer[T any](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +// DuplicateAccount creates a paused account from source configuration without carrying first-class +// runtime state. Credentials and extra configuration are deep-copied so normalization of the new +// account cannot mutate the in-memory source. Linked credential shadows are excluded because they +// intentionally do not own credentials and must be created through CreateShadow. +func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, operationKey string) (*Account, error) { + operationID := duplicateAccountOperationID(id, operationKey) + existing, err := s.findDuplicateByOperationID(ctx, operationID) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + + source, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if source.IsCredentialShadow() { + return nil, infraerrors.BadRequest( + "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED", + "linked credential shadow accounts cannot be duplicated; duplicate the parent account instead", + ) + } + if !canDuplicateAccountType(source.Type) { + return nil, infraerrors.BadRequest( + "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED", + "accounts with rotating or unsupported credential types cannot be duplicated", + ) + } + + credentials, err := cloneAccountJSONMap(source.Credentials) + if err != nil { + return nil, fmt.Errorf("clone account credentials: %w", err) + } + extra, err := duplicateAccountExtra(source.Extra) + if err != nil { + return nil, fmt.Errorf("clone account extra configuration: %w", err) + } + if operationID != "" { + if extra == nil { + extra = make(map[string]any, 1) + } + extra[duplicateAccountOperationIDExtraKey] = operationID + } + + var expiresAt *int64 + if source.ExpiresAt != nil { + unix := source.ExpiresAt.Unix() + expiresAt = &unix + } + autoPauseOnExpired := source.AutoPauseOnExpired + groups, groupIDs := duplicateAccountGroups(source) + proxyID := source.ProxyID + if source.ProxyFallbackOriginID != nil { + // Proxy fallback is transient runtime state; duplicate the configured origin. + proxyID = source.ProxyFallbackOriginID + } + input := &CreateAccountInput{ + Name: duplicateAccountName(source.Name), + Notes: cloneAccountValuePointer(source.Notes), + Platform: source.Platform, + Type: source.Type, + Credentials: credentials, + Extra: extra, + ProxyID: cloneAccountValuePointer(proxyID), + Concurrency: source.Concurrency, + Priority: source.Priority, + RateMultiplier: cloneAccountValuePointer(source.RateMultiplier), + LoadFactor: cloneAccountValuePointer(source.LoadFactor), + GroupIDs: groupIDs, + ExpiresAt: expiresAt, + AutoPauseOnExpired: &autoPauseOnExpired, + SkipDefaultGroupBind: true, + SkipMixedChannelCheck: true, + } + accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra) + if err != nil { + return nil, fmt.Errorf("normalize duplicate account extra: %w", err) + } + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + duplicate, err := buildAccountForCreate(input, accountExtra) + if err != nil { + return nil, err + } + // A copied credential must be reviewed before it can share live traffic with its source. + duplicate.Schedulable = false + if s.accountDuplicateRepo == nil { + return nil, errors.New("account duplicate repository is not configured") + } + if err := s.accountDuplicateRepo.CreateWithAccountGroups(ctx, duplicate, groups); err != nil { + return nil, fmt.Errorf("create duplicate account: %w", err) + } + for i := range groups { + groups[i].AccountID = duplicate.ID + } + duplicate.AccountGroups = groups + duplicate.GroupIDs = groupIDs + return duplicate, nil +} + func normalizeAccountConcurrency(platform, accountType string, concurrency int) int { if platform == PlatformGrok && accountType == AccountTypeOAuth { if concurrency <= 0 { @@ -122,40 +382,7 @@ func normalizeOpenAILongContextBillingUpdateExtra(account *Account, input *Updat return normalized, nil } -func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { - accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra) - if err != nil { - return nil, err - } - - // 绑定分组 - groupIDs := input.GroupIDs - // 如果没有指定分组,自动绑定对应平台的默认分组 - if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { - defaultGroupName := input.Platform + "-default" - groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) - if err == nil { - for _, g := range groups { - if g.Name == defaultGroupName { - groupIDs = []int64{g.ID} - break - } - } - } - } - - // 检查混合渠道风险(除非用户已确认) - if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { - if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { - return nil, err - } - } - - // 校验并规范化请求头覆写配置(header 名小写化、格式检查) - if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { - return nil, err - } - +func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) { account := &Account{ Name: input.Name, Notes: normalizeAccountNotes(input.Notes), @@ -198,6 +425,47 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou } account.LoadFactor = input.LoadFactor } + return account, nil +} + +func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + accountExtra, err := normalizeOpenAILongContextBillingExtra(input.Platform, input.Extra) + if err != nil { + return nil, err + } + + // 绑定分组 + groupIDs := input.GroupIDs + // 如果没有指定分组,自动绑定对应平台的默认分组 + if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { + defaultGroupName := input.Platform + "-default" + groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) + if err == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 检查混合渠道风险(除非用户已确认) + if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { + if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { + return nil, err + } + } + + // 校验并规范化请求头覆写配置(header 名小写化、格式检查) + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + + account, err := buildAccountForCreate(input, accountExtra) + if err != nil { + return nil, err + } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 2a7125f51f..4d839fa901 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -67,6 +67,9 @@ type AdminService interface { GetAccount(ctx context.Context, id int64) (*Account, error) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) + // DuplicateAccount creates an independent account from an existing account's configuration. + // First-class runtime columns are intentionally reset by the normal account creation path. + DuplicateAccount(ctx context.Context, id int64, operationKey string) (*Account, error) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) // UpdateAccountExtra 仅对 Extra 做 JSONB 增量合并(key 级覆盖),不会影响其它字段或运行态键。 // 用于刷新流程持久化 account_uuid / org_uuid 等少量键,避免被全量快照覆盖。 @@ -577,6 +580,7 @@ type adminServiceImpl struct { userRepo UserRepository groupRepo GroupRepository accountRepo AccountRepository + accountDuplicateRepo AccountDuplicateRepository proxyRepo ProxyRepository apiKeyRepo APIKeyRepository redeemCodeRepo RedeemCodeRepository @@ -602,7 +606,7 @@ type userGroupRateBatchReader interface { func NewAdminService( userRepo UserRepository, groupRepo GroupRepository, - accountRepo AccountRepository, + accountRepo AdminAccountRepository, proxyRepo ProxyRepository, apiKeyRepo APIKeyRepository, redeemCodeRepo RedeemCodeRepository, @@ -623,6 +627,7 @@ func NewAdminService( userRepo: userRepo, groupRepo: groupRepo, accountRepo: accountRepo, + accountDuplicateRepo: accountRepo, proxyRepo: proxyRepo, apiKeyRepo: apiKeyRepo, redeemCodeRepo: redeemCodeRepo, diff --git a/backend/internal/service/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go new file mode 100644 index 0000000000..41a8d3db7c --- /dev/null +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -0,0 +1,313 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + "unicode/utf8" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +type duplicateAccountRepoStub struct { + *sparkShadowRepoStub + atomicCreateErr error + accountGroupsOf map[int64][]AccountGroup +} + +func newDuplicateAccountRepoStub() *duplicateAccountRepoStub { + return &duplicateAccountRepoStub{ + sparkShadowRepoStub: newSparkShadowRepoStub(), + accountGroupsOf: make(map[int64][]AccountGroup), + } +} + +func (s *duplicateAccountRepoStub) CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error { + if s.atomicCreateErr != nil { + return s.atomicCreateErr + } + groupIDs := make([]int64, 0, len(groups)) + for _, group := range groups { + groupIDs = append(groupIDs, group.GroupID) + } + account.GroupIDs = groupIDs + if err := s.Create(ctx, account); err != nil { + return err + } + clonedGroups := make([]AccountGroup, len(groups)) + copy(clonedGroups, groups) + for i := range clonedGroups { + clonedGroups[i].AccountID = account.ID + } + account.AccountGroups = clonedGroups + s.accountGroupsOf[account.ID] = clonedGroups + if len(groupIDs) > 0 { + s.groupsOf[account.ID] = append([]int64(nil), groupIDs...) + } + stored := *account + s.accounts[account.ID] = &stored + s.mockAccountRepoForGemini.accountsByID[account.ID] = &stored + return nil +} + +func (s *duplicateAccountRepoStub) FindByExtraField(_ context.Context, key string, value any) ([]Account, error) { + wanted, ok := value.(string) + if !ok { + return nil, nil + } + var matches []Account + for _, account := range s.accounts { + if actual, ok := account.Extra[key].(string); ok && actual == wanted { + matches = append(matches, *account) + } + } + return matches, nil +} + +func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + + notes := "keep this note" + proxyID := int64(17) + originalProxyID := int64(11) + rateMultiplier := 1.25 + loadFactor := 9 + expiresAt := time.Date(2027, time.March, 4, 5, 6, 7, 0, time.UTC) + rateLimitedAt := time.Now().Add(-time.Minute) + rateLimitResetAt := time.Now().Add(time.Hour) + overloadUntil := time.Now().Add(2 * time.Hour) + tempUnschedulableUntil := time.Now().Add(3 * time.Hour) + sessionWindowStart := time.Now().Add(-2 * time.Hour) + sessionWindowEnd := time.Now().Add(2 * time.Hour) + + source := &Account{ + Name: "primary", + Notes: ¬es, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + ProxyID: &proxyID, + ProxyFallbackOriginID: &originalProxyID, + Concurrency: 6, + Priority: 40, + RateMultiplier: &rateMultiplier, + LoadFactor: &loadFactor, + Status: StatusError, + Schedulable: true, + ErrorMessage: "upstream unavailable", + ExpiresAt: &expiresAt, + AutoPauseOnExpired: false, + Credentials: map[string]any{ + "api_key": "secret", + "nested": map[string]any{"token": "source-token"}, + }, + Extra: map[string]any{ + "config": map[string]any{"region": "us-east-1"}, + "items": []any{map[string]any{"enabled": true}}, + "quota_limit": 1000, + "quota_used": 450, + "quota_daily_used": 25, + "quota_daily_start": "2026-07-15T00:00:00Z", + "model_rate_limits": map[string]any{"gpt-5": "2099-01-01T00:00:00Z"}, + "codex_5h_used_percent": 80, + "codex_cli_only": true, + "grok_usage_snapshot": map[string]any{"status_code": 429}, + "openai_responses_supported": false, + "openai_compact_checked_at": "2026-07-15T00:00:00Z", + "session_window_utilization": 0.8, + "passive_usage_sampled_at": "2026-07-15T00:00:00Z", + "antigravity_force_token_refresh": true, + "antigravity_credits_overages": map[string]any{"enabled": true}, + "crs_account_id": "remote-42", + "crs_kind": "openai-api-key", + "crs_synced_at": "2026-07-15T00:00:00Z", + }, + GroupIDs: []int64{7, 3}, + AccountGroups: []AccountGroup{{GroupID: 7, Priority: 50}, {GroupID: 3, Priority: 7}}, + RateLimitedAt: &rateLimitedAt, + RateLimitResetAt: &rateLimitResetAt, + OverloadUntil: &overloadUntil, + TempUnschedulableUntil: &tempUnschedulableUntil, + TempUnschedulableReason: "maintenance", + SessionWindowStart: &sessionWindowStart, + SessionWindowEnd: &sessionWindowEnd, + SessionWindowStatus: "active", + } + require.NoError(t, repo.Create(ctx, source)) + + duplicate, err := svc.DuplicateAccount(ctx, source.ID, "") + + require.NoError(t, err) + require.NotEqual(t, source.ID, duplicate.ID) + require.Equal(t, "primary (Copy)", duplicate.Name) + require.Equal(t, source.Platform, duplicate.Platform) + require.Equal(t, source.Type, duplicate.Type) + require.Equal(t, source.Concurrency, duplicate.Concurrency) + require.Equal(t, source.Priority, duplicate.Priority) + require.Equal(t, source.AutoPauseOnExpired, duplicate.AutoPauseOnExpired) + require.Equal(t, source.GroupIDs, duplicate.GroupIDs) + require.Equal(t, source.Credentials, duplicate.Credentials) + require.Equal(t, map[string]any{ + "config": map[string]any{"region": "us-east-1"}, + "items": []any{map[string]any{"enabled": true}}, + "quota_limit": float64(1000), + "codex_cli_only": true, + }, duplicate.Extra) + require.NotNil(t, duplicate.ExpiresAt) + require.True(t, source.ExpiresAt.Equal(*duplicate.ExpiresAt)) + require.Equal(t, source.Notes, duplicate.Notes) + require.Equal(t, source.ProxyFallbackOriginID, duplicate.ProxyID) + require.Equal(t, source.RateMultiplier, duplicate.RateMultiplier) + require.Equal(t, source.LoadFactor, duplicate.LoadFactor) + require.Equal(t, source.GroupIDs, repo.groupsOf[duplicate.ID]) + require.Equal(t, []AccountGroup{ + {AccountID: duplicate.ID, GroupID: 7, Priority: 50}, + {AccountID: duplicate.ID, GroupID: 3, Priority: 7}, + }, repo.accountGroupsOf[duplicate.ID]) + + require.Equal(t, StatusActive, duplicate.Status) + require.False(t, duplicate.Schedulable) + require.Empty(t, duplicate.ErrorMessage) + require.Nil(t, duplicate.LastUsedAt) + require.Nil(t, duplicate.RateLimitedAt) + require.Nil(t, duplicate.RateLimitResetAt) + require.Nil(t, duplicate.OverloadUntil) + require.Nil(t, duplicate.TempUnschedulableUntil) + require.Empty(t, duplicate.TempUnschedulableReason) + require.Nil(t, duplicate.SessionWindowStart) + require.Nil(t, duplicate.SessionWindowEnd) + require.Empty(t, duplicate.SessionWindowStatus) + + duplicate.Credentials["nested"].(map[string]any)["token"] = "changed" + duplicate.Extra["config"].(map[string]any)["region"] = "changed" + duplicate.Extra["items"].([]any)[0].(map[string]any)["enabled"] = false + storedSource, getErr := repo.GetByID(ctx, source.ID) + require.NoError(t, getErr) + require.Equal(t, "source-token", storedSource.Credentials["nested"].(map[string]any)["token"]) + require.Equal(t, "us-east-1", storedSource.Extra["config"].(map[string]any)["region"]) + require.Equal(t, true, storedSource.Extra["items"].([]any)[0].(map[string]any)["enabled"]) + require.Equal(t, "remote-42", storedSource.Extra["crs_account_id"]) +} + +func TestDuplicateAccountRejectsCredentialShadow(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + parentID := int64(99) + shadow := &Account{ + Name: "shadow", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + } + require.NoError(t, repo.Create(ctx, shadow)) + + _, err := svc.DuplicateAccount(ctx, shadow.ID, "") + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "ACCOUNT_DUPLICATE_SHADOW_UNSUPPORTED", infraerrors.Reason(err)) + require.Len(t, repo.accounts, 1) +} + +func TestDuplicateAccountRejectsRotatingOrUnknownCredentialTypes(t *testing.T) { + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken, "legacy-cookie"} { + t.Run(accountType, func(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{ + Name: "rotating-credential-account", + Platform: PlatformOpenAI, + Type: accountType, + Credentials: map[string]any{"refresh_token": "shared-token"}, + } + require.NoError(t, repo.Create(ctx, source)) + + _, err := svc.DuplicateAccount(ctx, source.ID, "") + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "ACCOUNT_DUPLICATE_CREDENTIAL_TYPE_UNSUPPORTED", infraerrors.Reason(err)) + require.Len(t, repo.accounts, 1) + }) + } +} + +func TestDuplicateAccountPreservesUngroupedState(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{ + Name: "ungrouped", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "secret"}, + GroupIDs: nil, + } + require.NoError(t, repo.Create(ctx, source)) + + duplicate, err := svc.DuplicateAccount(ctx, source.ID, "") + + require.NoError(t, err) + require.Empty(t, duplicate.GroupIDs) + require.NotContains(t, repo.groupsOf, duplicate.ID) +} + +func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{ + Name: "source", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "secret"}, + GroupIDs: []int64{7}, + AccountGroups: []AccountGroup{{GroupID: 7, Priority: 25}}, + } + require.NoError(t, repo.Create(ctx, source)) + repo.atomicCreateErr = errors.New("group binding failed") + + _, err := svc.DuplicateAccount(ctx, source.ID, "") + + require.ErrorContains(t, err, "group binding failed") + require.Len(t, repo.accounts, 1) +} + +func TestDuplicateAccountNamePreservesSuffixWithinSchemaLimit(t *testing.T) { + name := duplicateAccountName(strings.Repeat("界", 100)) + + require.Equal(t, 100, utf8.RuneCountInString(name)) + require.True(t, strings.HasSuffix(name, " (Copy)")) +} + +func TestDuplicateAccountReturnsExistingCopyForSameOperationKey(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + svc := &adminServiceImpl{accountRepo: repo, accountDuplicateRepo: repo} + source := &Account{ + Name: "source", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "secret"}, + } + require.NoError(t, repo.Create(ctx, source)) + + first, err := svc.DuplicateAccount(ctx, source.ID, "stable-operation-key") + require.NoError(t, err) + second, err := svc.DuplicateAccount(ctx, source.ID, "stable-operation-key") + require.NoError(t, err) + + require.Equal(t, first.ID, second.ID) + require.Len(t, repo.accounts, 2) + require.NotEmpty(t, first.Extra[duplicateAccountOperationIDExtraKey]) +} diff --git a/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts b/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts new file mode 100644 index 0000000000..c68426de30 --- /dev/null +++ b/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { post } = vi.hoisted(() => ({ + post: vi.fn() +})) + +vi.mock('@/api/client', () => ({ + apiClient: { post } +})) + +import { duplicate } from '@/api/admin/accounts' + +describe('admin account duplicate API', () => { + beforeEach(() => { + post.mockReset() + post.mockResolvedValue({ data: { id: 43, name: 'primary (Copy)' } }) + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111') + }) + + it('sends a stable idempotency key with the duplicate request', async () => { + const account = await duplicate(42) + + expect(post).toHaveBeenCalledWith('/admin/accounts/42/duplicate', undefined, { + headers: { + 'Idempotency-Key': 'account-duplicate-42-11111111-1111-4111-8111-111111111111' + } + }) + expect(account).toEqual({ id: 43, name: 'primary (Copy)' }) + }) + + it('reuses the operation key after an ambiguous failed request', async () => { + post.mockRejectedValueOnce(new Error('network timeout')) + await expect(duplicate(99)).rejects.toThrow('network timeout') + + post.mockResolvedValueOnce({ data: { id: 100, name: 'retry (Copy)' } }) + await duplicate(99) + + expect(post).toHaveBeenCalledTimes(2) + const firstHeaders = post.mock.calls[0][2].headers + const secondHeaders = post.mock.calls[1][2].headers + expect(secondHeaders).toEqual(firstHeaders) + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 2f9625b430..abc0a31d1a 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -138,6 +138,27 @@ export async function create(accountData: CreateAccountRequest): Promise() + +export async function duplicate(id: number): Promise { + let idempotencyKey = duplicateOperationKeys.get(id) + if (!idempotencyKey) { + const requestID = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` + idempotencyKey = `account-duplicate-${id}-${requestID}` + duplicateOperationKeys.set(id, idempotencyKey) + } + const { data } = await apiClient.post(`/admin/accounts/${id}/duplicate`, undefined, { + headers: { 'Idempotency-Key': idempotencyKey } + }) + duplicateOperationKeys.delete(id) + return data +} + /** * Update account * @param id - Account ID @@ -809,6 +830,7 @@ export const accountsAPI = { listWithEtag, getById, create, + duplicate, update, checkMixedChannelRisk, delete: deleteAccount, diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 9bb5e8891e..14922a37b7 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -22,6 +22,10 @@ {{ t('admin.scheduledTests.schedule') }} +