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
This commit is contained in:
yan9651688
2026-07-15 10:51:13 +08:00
parent cf6aa1a5c3
commit 60ff61132d
21 changed files with 1142 additions and 43 deletions
+2 -1
View File
@@ -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)
@@ -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) {
@@ -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"))
}
@@ -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 {
+69 -3
View File
@@ -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
}
@@ -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)
}
+1
View File
@@ -68,6 +68,7 @@ var ProviderSet = wire.NewSet(
NewAPIKeyRepository,
NewGroupRepository,
NewAccountRepository,
NewAdminAccountRepository,
NewScheduledTestPlanRepository, // 定时测试计划仓储
NewScheduledTestResultRepository, // 定时测试结果仓储
NewProxyRepository,
@@ -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
}
+1
View File
@@ -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)
@@ -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 {
+302 -34
View File
@@ -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
}
+6 -1
View File
@@ -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,
@@ -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: &notes,
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])
}
@@ -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)
})
})
+22
View File
@@ -138,6 +138,27 @@ export async function create(accountData: CreateAccountRequest): Promise<Account
return data
}
/**
* Duplicate an account while keeping credentials on the server.
* @param id - Source account ID
* @returns Newly created account
*/
const duplicateOperationKeys = new Map<number, string>()
export async function duplicate(id: number): Promise<Account> {
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<Account>(`/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,
@@ -22,6 +22,10 @@
<Icon name="clock" size="sm" class="text-orange-500" />
{{ t('admin.scheduledTests.schedule') }}
</button>
<button v-if="canDuplicate" @click="$emit('duplicate', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="copy" size="sm" class="text-sky-500" />
{{ t('admin.accounts.duplicateAccount') }}
</button>
<!-- 影子账号不持凭据:重授权/刷新 token 对其无效(后端拒绝),故隐藏(外审 G4)。 -->
<template v-if="(account.type === 'oauth' || account.type === 'setup-token') && !isShadow">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
@@ -64,8 +68,12 @@ import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const emit = defineEmits(['close', 'test', 'stats', 'schedule', 'duplicate', 'reauth', 'refresh-token', 'recover-state', 'reset-quota', 'set-privacy', 'create-spark-shadow'])
const { t } = useI18n()
const canDuplicate = computed(() => {
if (!props.account || props.account.parent_account_id != null) return false
return ['apikey', 'upstream', 'bedrock', 'service_account'].includes(props.account.type)
})
const isRateLimited = computed(() => {
if (props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date()) {
return true
@@ -49,6 +49,55 @@ const getBodyText = () => document.body.textContent ?? ''
const getBodyButtons = () => Array.from(document.body.querySelectorAll('button'))
describe('AccountActionMenu — spark shadow 按钮可见性', () => {
it('普通账号显示「复制账号」按钮', () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it('影子账号隐藏「复制账号」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: 42 })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it.each(['oauth', 'setup-token'] as const)('%s 账号隐藏「复制账号」按钮,避免共享可轮换令牌', (type) => {
const account = makeAccount({ platform: 'openai', type, parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
expect(getBodyText()).not.toContain('admin.accounts.duplicateAccount')
wrapper.unmount()
})
it('点击「复制账号」触发 duplicate 事件并携带 account', async () => {
const account = makeAccount({ platform: 'anthropic', type: 'apikey', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
props: { show: true, account, position },
attachTo: document.body,
})
const duplicateBtn = getBodyButtons().find(b => b.textContent?.includes('admin.accounts.duplicateAccount'))
expect(duplicateBtn).toBeDefined()
duplicateBtn!.click()
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('duplicate')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toMatchObject({ id: account.id, name: account.name })
wrapper.unmount()
})
it('OpenAI OAuth 母账号(无 parent_account_id)显示「创建 spark 影子」按钮', () => {
const account = makeAccount({ platform: 'openai', type: 'oauth', parent_account_id: null })
const wrapper = mount(AccountActionMenu, {
@@ -350,6 +350,9 @@ export default {
createSparkShadowConfirm: 'Create a spark shadow account linked to "{name}"? It shares the parent\'s credentials and serves only spark models.',
createSparkShadowSuccess: 'Spark shadow account created',
createSparkShadowFailed: 'Failed to create spark shadow account',
duplicateAccount: 'Duplicate Account',
duplicateSuccess: 'Account duplicated as "{name}" and paused. Review its credentials before enabling it.',
duplicateFailed: 'Failed to duplicate account',
resetStatus: 'Reset Status',
statusReset: 'Account status reset successfully',
failedToResetStatus: 'Failed to reset account status',
@@ -453,6 +453,9 @@ export default {
createSparkShadowConfirm: '为「{name}」创建链接型 Spark 影子账号?影子共享母账号凭据、仅服务 spark 模型。',
createSparkShadowSuccess: 'Spark 影子账号已创建',
createSparkShadowFailed: '创建 Spark 影子账号失败',
duplicateAccount: '复制账号',
duplicateSuccess: '账号已复制为「{name}」,已暂停调度,请确认凭据后再启用',
duplicateFailed: '复制账号失败',
resetStatus: '重置状态',
statusReset: '账号状态已重置',
failedToResetStatus: '重置账号状态失败',
+16 -1
View File
@@ -399,7 +399,7 @@
<AccountTestModal :show="showTest" :account="testingAcc" @close="closeTestModal" />
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<ScheduledTestsPanel :show="showSchedulePanel" :account-id="scheduleAcc?.id ?? null" :model-options="scheduleModelOptions" @close="closeSchedulePanel" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @schedule="handleSchedule" @duplicate="handleDuplicateAccount" @reauth="handleReAuth" @refresh-token="handleRefresh" @recover-state="handleRecoverState" @reset-quota="handleResetQuota" @set-privacy="handleSetPrivacy" @create-spark-shadow="handleCreateSparkShadow" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal
@@ -1707,6 +1707,21 @@ const handleSchedule = async (a: Account) => {
}
const closeSchedulePanel = () => { showSchedulePanel.value = false; scheduleAcc.value = null; scheduleModelOptions.value = [] }
const handleReAuth = (a: Account) => { reAuthAcc.value = a; showReAuth.value = true }
const duplicatingAccountIDs = new Set<number>()
const handleDuplicateAccount = async (a: Account) => {
if (duplicatingAccountIDs.has(a.id)) return
duplicatingAccountIDs.add(a.id)
try {
const duplicate = await adminAPI.accounts.duplicate(a.id)
appStore.showSuccess(t('admin.accounts.duplicateSuccess', { name: duplicate.name }))
reload()
} catch (error: any) {
console.error('Failed to duplicate account:', error)
appStore.showError(error?.message || t('admin.accounts.duplicateFailed'))
} finally {
duplicatingAccountIDs.delete(a.id)
}
}
const handleRefresh = async (a: Account) => {
try {
const updated = await adminAPI.accounts.refreshCredentials(a.id)
@@ -14,6 +14,7 @@ const {
getBatchTodayStats,
getAllProxies,
getAllGroups,
duplicateAccount,
createSparkShadow,
showSuccess,
showError
@@ -23,6 +24,7 @@ const {
getBatchTodayStats: vi.fn(),
getAllProxies: vi.fn(),
getAllGroups: vi.fn(),
duplicateAccount: vi.fn(),
createSparkShadow: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
@@ -34,6 +36,7 @@ vi.mock('@/api/admin', () => ({
list: listAccounts,
listWithEtag,
getBatchTodayStats,
duplicate: duplicateAccount,
createSparkShadow,
delete: vi.fn(),
batchClearError: vi.fn(),
@@ -102,7 +105,7 @@ const mountView = () =>
describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listAccounts.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 20, pages: 0 })
@@ -110,6 +113,7 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
getBatchTodayStats.mockResolvedValue({ stats: {} })
getAllProxies.mockResolvedValue([])
getAllGroups.mockResolvedValue([])
duplicateAccount.mockResolvedValue({ id: 998, name: 'parent-acc (Copy)' })
createSparkShadow.mockResolvedValue({ id: 999, name: 'parent-acc (Spark)' })
})
@@ -117,6 +121,51 @@ describe('admin AccountsView — 外审 F2:spark 影子创建接线', () => {
vi.unstubAllGlobals()
})
it('AccountActionMenu 的 duplicate 事件一键复制账号并刷新列表', async () => {
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(duplicateAccount).toHaveBeenCalledTimes(1)
expect(duplicateAccount).toHaveBeenCalledWith(42)
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.duplicateSuccess')
expect(listAccounts.mock.calls.length).toBeGreaterThan(1)
wrapper.unmount()
})
it('同一账号复制请求未完成时忽略重复点击', async () => {
let resolveDuplicate!: (account: { id: number; name: string }) => void
duplicateAccount.mockImplementationOnce(() => new Promise(resolve => { resolveDuplicate = resolve }))
const wrapper = mountView()
await flushPromises()
const menu = wrapper.findComponent(AccountActionMenu)
menu.vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
menu.vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(duplicateAccount).toHaveBeenCalledTimes(1)
resolveDuplicate({ id: 998, name: 'parent-acc (Copy)' })
await flushPromises()
wrapper.unmount()
})
it('复制失败时显示后端错误', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
duplicateAccount.mockRejectedValueOnce(new Error('duplicate failed'))
const wrapper = mountView()
await flushPromises()
wrapper.findComponent(AccountActionMenu).vm.$emit('duplicate', { id: 42, name: 'parent-acc' })
await flushPromises()
expect(showError).toHaveBeenCalledWith('duplicate failed')
consoleError.mockRestore()
wrapper.unmount()
})
it('AccountActionMenu 的 create-spark-shadow 事件触发 createSparkShadow API + 成功提示', async () => {
const wrapper = mountView()
await flushPromises()
@@ -208,7 +257,7 @@ const mountViewWithRow = () =>
describe('admin AccountsView — 影子行 parent_* OR 兜底展示', () => {
beforeEach(() => {
localStorage.clear()
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, createSparkShadow, showSuccess, showError]) {
for (const fn of [listAccounts, listWithEtag, getBatchTodayStats, getAllProxies, getAllGroups, duplicateAccount, createSparkShadow, showSuccess, showError]) {
fn.mockReset()
}
listWithEtag.mockResolvedValue({ notModified: true, etag: null, data: null })