Merge pull request #4317 from yan9651688/feat/account-one-click-copy

feat(accounts): add safe one-click account duplication
This commit is contained in:
Wesley Liddick
2026-07-15 14:23:35 +08:00
committed by GitHub
22 changed files with 1402 additions and 49 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,53 @@ 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
}
actorScope := adminActorScope(c)
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, actorScope, c.GetHeader("Idempotency-Key"))
if execErr != nil {
return nil, execErr
}
return h.buildAccountResponseWithRuntime(ctx, account), nil
},
)
if err != nil {
reason := infraerrors.Reason(err)
if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
recovered, recoverErr := h.adminService.RecoverDuplicateAccount(c.Request.Context(), accountID, actorScope, c.GetHeader("Idempotency-Key"))
if recoverErr != nil {
slog.Warn("account_duplicate_recovery_failed", "account_id", accountID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
} else if recovered != nil {
c.Header("X-Idempotency-Recovered", "true")
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), recovered))
return
}
}
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,295 @@
//go:build unit
package admin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"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
recoverCalls int
accountID int64
actorScope string
operationKey string
recoverScope string
recoverKey string
recoverErr error
created bool
}
type blockingDuplicateAdminServiceStub struct {
service.AdminService
account *service.Account
started chan struct{}
release chan struct{}
calls atomic.Int32
recoverCalls atomic.Int32
recoverErr error
}
type failOnceMarkSucceededRepo struct {
*memoryIdempotencyRepoStub
failNext bool
}
func (r *failOnceMarkSucceededRepo) MarkSucceeded(ctx context.Context, id int64, responseStatus int, responseBody string, expiresAt time.Time) error {
if r.failNext {
r.failNext = false
return errors.New("mark succeeded failed")
}
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
}
func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, actorScope, operationKey string) (*service.Account, error) {
s.calls++
s.accountID = accountID
s.actorScope = actorScope
s.operationKey = operationKey
s.created = true
return s.account, nil
}
func (s *duplicateAccountAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, actorScope, operationKey string) (*service.Account, error) {
s.recoverCalls++
s.recoverScope = actorScope
s.recoverKey = operationKey
if s.recoverErr != nil {
return nil, s.recoverErr
}
if !s.created {
return nil, nil
}
return s.account, nil
}
func (s *blockingDuplicateAdminServiceStub) DuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
s.calls.Add(1)
close(s.started)
<-s.release
return s.account, nil
}
func (s *blockingDuplicateAdminServiceStub) RecoverDuplicateAccount(_ context.Context, _ int64, _, _ string) (*service.Account, error) {
s.recoverCalls.Add(1)
return nil, s.recoverErr
}
func setupDuplicateAccountRouter(t *testing.T, svc service.AdminService) *gin.Engine {
t.Helper()
previousCoordinator := service.DefaultIdempotencyCoordinator()
service.SetDefaultIdempotencyCoordinator(nil)
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(previousCoordinator) })
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 77})
c.Next()
})
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, "admin:77", svc.actorScope)
require.Equal(t, "duplicate-account-42", svc.operationKey)
require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
}
func TestDuplicateAccountHandlerRecoversAfterMarkSucceededFailure(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 := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
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-recovery")
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, "true", first.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, "true", second.Header().Get("X-Idempotency-Recovered"))
require.Equal(t, 1, svc.calls, "ambiguous retries must not repeat the create side effect")
require.Equal(t, 2, svc.recoverCalls)
require.Equal(t, "admin:77", svc.recoverScope)
require.Equal(t, "duplicate-account-42-recovery", svc.recoverKey)
require.Contains(t, second.Body.String(), `"id":43`)
}
func TestDuplicateAccountHandlerPreservesIdempotencyErrorWhenRecoveryLookupFails(t *testing.T) {
svc := &duplicateAccountAdminServiceStub{
account: &service.Account{
ID: 43,
Name: "primary (Copy)",
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: false,
},
recoverErr: errors.New("recovery database unavailable"),
}
router := setupDuplicateAccountRouter(t, svc)
repo := &failOnceMarkSucceededRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub(), failNext: true}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/duplicate", nil)
request.Header.Set("Idempotency-Key", "duplicate-account-42-recovery-error")
router.ServeHTTP(recorder, request)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Contains(t, recorder.Body.String(), "IDEMPOTENCY_STORE_UNAVAILABLE")
require.Equal(t, 1, svc.calls)
require.Equal(t, 1, svc.recoverCalls)
require.Equal(t, "admin:77", svc.recoverScope)
}
func TestDuplicateAccountHandlerDoesNotReexecuteWhileOriginalIsProcessing(t *testing.T) {
svc := &blockingDuplicateAdminServiceStub{
account: &service.Account{
ID: 43,
Name: "primary (Copy)",
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: false,
},
started: make(chan struct{}),
release: make(chan struct{}),
recoverErr: errors.New("recovery database unavailable"),
}
router := setupDuplicateAccountRouter(t, svc)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), 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-active")
router.ServeHTTP(recorder, request)
return recorder
}
firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() { firstDone <- call() }()
<-svc.started
second := call()
require.Equal(t, http.StatusConflict, second.Code)
require.Contains(t, second.Body.String(), "IDEMPOTENCY_IN_PROGRESS")
require.Equal(t, int32(1), svc.calls.Load())
require.Equal(t, int32(1), svc.recoverCalls.Load())
close(svc.release)
select {
case first := <-firstDone:
require.Equal(t, http.StatusOK, first.Code)
case <-time.After(time.Second):
t.Fatal("original duplicate request did not finish")
}
}
@@ -418,6 +418,15 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
return &account, nil
}
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
return &account, nil
}
func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
return nil, nil
}
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
s.updateAccountCalls++
if s.updateAccountErr != nil {
@@ -37,14 +37,9 @@ func executeAdminIdempotent(
return &service.IdempotencyExecuteResult{Data: data}, nil
}
actorScope := "admin:0"
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
}
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
Scope: scope,
ActorScope: actorScope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
@@ -54,6 +49,14 @@ func executeAdminIdempotent(
}, execute)
}
func adminActorScope(c *gin.Context) string {
actorScope := "admin:0"
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
}
return actorScope
}
func executeAdminIdempotentJSON(
c *gin.Context,
scope string,
+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 {
+313 -34
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -60,6 +62,275 @@ 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, actorScope, operationKey string) string {
operationKey = strings.TrimSpace(operationKey)
if operationKey == "" {
return ""
}
actorScope = strings.TrimSpace(actorScope)
if actorScope == "" {
actorScope = "admin:0"
}
payload := "admin.accounts.duplicate\x00" + actorScope + "\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
}
// RecoverDuplicateAccount performs a read-only lookup for an already committed duplicate.
// It is used when the idempotency coordinator cannot confirm whether response persistence
// succeeded, and deliberately never repeats the create side effect.
func (s *adminServiceImpl) RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) {
return s.findDuplicateByOperationID(ctx, duplicateAccountOperationID(id, actorScope, operationKey))
}
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, actorScope, operationKey string) (*Account, error) {
operationID := duplicateAccountOperationID(id, actorScope, operationKey)
existing, err := s.RecoverDuplicateAccount(ctx, id, actorScope, operationKey)
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 +393,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 +436,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
}
+9 -1
View File
@@ -67,6 +67,12 @@ 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, actorScope, operationKey string) (*Account, error)
// RecoverDuplicateAccount returns a previously committed duplicate for an ambiguous retry.
// It never creates an account.
RecoverDuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error)
UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error)
// UpdateAccountExtra 仅对 Extra 做 JSONB 增量合并(key 级覆盖),不会影响其它字段或运行态键。
// 用于刷新流程持久化 account_uuid / org_uuid 等少量键,避免被全量快照覆盖。
@@ -577,6 +583,7 @@ type adminServiceImpl struct {
userRepo UserRepository
groupRepo GroupRepository
accountRepo AccountRepository
accountDuplicateRepo AccountDuplicateRepository
proxyRepo ProxyRepository
apiKeyRepo APIKeyRepository
redeemCodeRepo RedeemCodeRepository
@@ -602,7 +609,7 @@ type userGroupRateBatchReader interface {
func NewAdminService(
userRepo UserRepository,
groupRepo GroupRepository,
accountRepo AccountRepository,
accountRepo AdminAccountRepository,
proxyRepo ProxyRepository,
apiKeyRepo APIKeyRepository,
redeemCodeRepo RedeemCodeRepository,
@@ -623,6 +630,7 @@ func NewAdminService(
userRepo: userRepo,
groupRepo: groupRepo,
accountRepo: accountRepo,
accountDuplicateRepo: accountRepo,
proxyRepo: proxyRepo,
apiKeyRepo: apiKeyRepo,
redeemCodeRepo: redeemCodeRepo,
@@ -0,0 +1,322 @@
//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, "admin:1", "")
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, "admin:1", "")
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, "admin:1", "")
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, "admin:1", "")
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, "admin:1", "")
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, "admin:7", "stable-operation-key")
require.NoError(t, err)
second, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key")
require.NoError(t, err)
recovered, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key")
require.NoError(t, err)
otherAdminRecovery, err := svc.RecoverDuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key")
require.NoError(t, err)
otherAdminCopy, err := svc.DuplicateAccount(ctx, source.ID, "admin:8", "stable-operation-key")
require.NoError(t, err)
require.Equal(t, first.ID, second.ID)
require.Equal(t, first.ID, recovered.ID)
require.Nil(t, otherAdminRecovery, "durable recovery identity must remain scoped to the initiating admin")
require.NotEqual(t, first.ID, otherAdminCopy.ID)
require.Len(t, repo.accounts, 3)
require.NotEmpty(t, first.Extra[duplicateAccountOperationIDExtraKey])
}
@@ -0,0 +1,59 @@
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(() => {
sessionStorage.clear()
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)
})
it('reuses the operation key after a page reload', async () => {
post.mockRejectedValueOnce(new Error('network timeout'))
await expect(duplicate(77)).rejects.toThrow('network timeout')
const firstHeaders = post.mock.calls[0][2].headers
vi.resetModules()
post.mockResolvedValueOnce({ data: { id: 78, name: 'reload (Copy)' } })
const { duplicate: duplicateAfterReload } = await import('@/api/admin/accounts')
await duplicateAfterReload(77)
expect(post).toHaveBeenCalledTimes(2)
expect(post.mock.calls[1][2].headers).toEqual(firstHeaders)
expect(sessionStorage.length).toBe(0)
})
})
+45
View File
@@ -138,6 +138,50 @@ 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>()
function duplicateOperationStorageKey(id: number): string {
return `sub2api:admin:account-duplicate:${id}`
}
function getStoredDuplicateOperationKey(id: number): string | null {
try {
return globalThis.sessionStorage?.getItem(duplicateOperationStorageKey(id)) ?? null
} catch {
return null
}
}
function storeDuplicateOperationKey(id: number, key: string | null): void {
try {
if (key) globalThis.sessionStorage?.setItem(duplicateOperationStorageKey(id), key)
else globalThis.sessionStorage?.removeItem(duplicateOperationStorageKey(id))
} catch {
// In-memory retry protection still works when browser storage is unavailable.
}
}
export async function duplicate(id: number): Promise<Account> {
let idempotencyKey = duplicateOperationKeys.get(id) ?? getStoredDuplicateOperationKey(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)
storeDuplicateOperationKey(id, idempotencyKey)
const { data } = await apiClient.post<Account>(`/admin/accounts/${id}/duplicate`, undefined, {
headers: { 'Idempotency-Key': idempotencyKey }
})
duplicateOperationKeys.delete(id)
storeDuplicateOperationKey(id, null)
return data
}
/**
* Update account
* @param id - Account ID
@@ -809,6 +853,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 })