mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #4317 from yan9651688/feat/account-one-click-copy
feat(accounts): add safe one-click account duplication
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: ¬es,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
ProxyID: &proxyID,
|
||||
ProxyFallbackOriginID: &originalProxyID,
|
||||
Concurrency: 6,
|
||||
Priority: 40,
|
||||
RateMultiplier: &rateMultiplier,
|
||||
LoadFactor: &loadFactor,
|
||||
Status: StatusError,
|
||||
Schedulable: true,
|
||||
ErrorMessage: "upstream unavailable",
|
||||
ExpiresAt: &expiresAt,
|
||||
AutoPauseOnExpired: false,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "secret",
|
||||
"nested": map[string]any{"token": "source-token"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"config": map[string]any{"region": "us-east-1"},
|
||||
"items": []any{map[string]any{"enabled": true}},
|
||||
"quota_limit": 1000,
|
||||
"quota_used": 450,
|
||||
"quota_daily_used": 25,
|
||||
"quota_daily_start": "2026-07-15T00:00:00Z",
|
||||
"model_rate_limits": map[string]any{"gpt-5": "2099-01-01T00:00:00Z"},
|
||||
"codex_5h_used_percent": 80,
|
||||
"codex_cli_only": true,
|
||||
"grok_usage_snapshot": map[string]any{"status_code": 429},
|
||||
"openai_responses_supported": false,
|
||||
"openai_compact_checked_at": "2026-07-15T00:00:00Z",
|
||||
"session_window_utilization": 0.8,
|
||||
"passive_usage_sampled_at": "2026-07-15T00:00:00Z",
|
||||
"antigravity_force_token_refresh": true,
|
||||
"antigravity_credits_overages": map[string]any{"enabled": true},
|
||||
"crs_account_id": "remote-42",
|
||||
"crs_kind": "openai-api-key",
|
||||
"crs_synced_at": "2026-07-15T00:00:00Z",
|
||||
},
|
||||
GroupIDs: []int64{7, 3},
|
||||
AccountGroups: []AccountGroup{{GroupID: 7, Priority: 50}, {GroupID: 3, Priority: 7}},
|
||||
RateLimitedAt: &rateLimitedAt,
|
||||
RateLimitResetAt: &rateLimitResetAt,
|
||||
OverloadUntil: &overloadUntil,
|
||||
TempUnschedulableUntil: &tempUnschedulableUntil,
|
||||
TempUnschedulableReason: "maintenance",
|
||||
SessionWindowStart: &sessionWindowStart,
|
||||
SessionWindowEnd: &sessionWindowEnd,
|
||||
SessionWindowStatus: "active",
|
||||
}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
duplicate, err := svc.DuplicateAccount(ctx, source.ID, "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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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: '重置账号状态失败',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user