From f7da6e2bc628b5c5eae9521b4ac6bce6859cabd5 Mon Sep 17 00:00:00 2001 From: yan9651688 <174289968+yan9651688@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:51:13 +0800 Subject: [PATCH] fix(accounts): prevent duplicate retries from crossing admins Ambiguous idempotency-store failures can occur after the account transaction commits. Scope durable recovery markers to the authenticated admin, retain the operation key across reloads, and recover only an already committed copy without rerunning active work. Constraint: Generic idempotent handlers may legitimately remain active while a recovery lookup is attempted Rejected: Reclaim or rerun an in-progress duplicate request | can execute account creation concurrently Rejected: Recover by source account and key alone | allows another admin to observe the committed copy Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep ambiguous-response recovery read-only and bind durable operation markers to the authenticated actor Tested: Full Go unit suite, go vet, server build, integration-test compilation; frontend lint, typecheck, 1,030 Vitest tests, and production build Not-tested: Docker-backed PostgreSQL integration runtime because Docker is unavailable Related: Wei-Shaw/sub2api#1379 Related: Wei-Shaw/sub2api#2928 --- .../internal/handler/admin/account_handler.go | 14 +- .../admin/account_handler_duplicate_test.go | 177 +++++++++++++++++- .../handler/admin/admin_service_stub_test.go | 6 +- .../handler/admin/idempotency_helper.go | 15 +- backend/internal/service/admin_account.go | 21 ++- backend/internal/service/admin_service.go | 5 +- .../admin_service_duplicate_account_test.go | 25 ++- .../admin.accounts.duplicate.spec.ts | 16 ++ frontend/src/api/admin/accounts.ts | 27 ++- 9 files changed, 280 insertions(+), 26 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index a8aeb46709..4e89b18bd2 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -868,6 +868,7 @@ func (h *AccountHandler) Duplicate(c *gin.Context) { response.BadRequest(c, "Invalid account ID") return } + actorScope := adminActorScope(c) result, err := executeAdminIdempotent( c, @@ -877,7 +878,7 @@ func (h *AccountHandler) Duplicate(c *gin.Context) { }{AccountID: accountID}, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { - account, execErr := h.adminService.DuplicateAccount(ctx, accountID, c.GetHeader("Idempotency-Key")) + account, execErr := h.adminService.DuplicateAccount(ctx, accountID, actorScope, c.GetHeader("Idempotency-Key")) if execErr != nil { return nil, execErr } @@ -885,6 +886,17 @@ func (h *AccountHandler) Duplicate(c *gin.Context) { }, ) 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 } diff --git a/backend/internal/handler/admin/account_handler_duplicate_test.go b/backend/internal/handler/admin/account_handler_duplicate_test.go index 2233acbe98..c99e882b1c 100644 --- a/backend/internal/handler/admin/account_handler_duplicate_test.go +++ b/backend/internal/handler/admin/account_handler_duplicate_test.go @@ -5,10 +5,14 @@ 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" @@ -18,18 +22,74 @@ 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 } -func (s *duplicateAccountAdminServiceStub) DuplicateAccount(_ context.Context, accountID int64, operationKey string) (*service.Account, error) { +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 setupDuplicateAccountRouter(t *testing.T, svc *duplicateAccountAdminServiceStub) *gin.Engine { +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) @@ -37,6 +97,10 @@ func setupDuplicateAccountRouter(t *testing.T, svc *duplicateAccountAdminService 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 @@ -117,6 +181,115 @@ func TestDuplicateAccountHandlerReplaysSameIdempotencyKey(t *testing.T) { 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") + } +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index ebd0c042c5..d93ee7111c 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -418,11 +418,15 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre return &account, nil } -func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, operationKey string) (*service.Account, error) { +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 { diff --git a/backend/internal/handler/admin/idempotency_helper.go b/backend/internal/handler/admin/idempotency_helper.go index aa8eeaaf79..1894faeaae 100644 --- a/backend/internal/handler/admin/idempotency_helper.go +++ b/backend/internal/handler/admin/idempotency_helper.go @@ -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, diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 388bd4f1ba..f4c3375650 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -184,12 +184,16 @@ func duplicateAccountGroups(source *Account) ([]AccountGroup, []int64) { return groups, groupIDs } -func duplicateAccountOperationID(sourceID int64, operationKey string) string { +func duplicateAccountOperationID(sourceID int64, actorScope, operationKey string) string { operationKey = strings.TrimSpace(operationKey) if operationKey == "" { return "" } - payload := "admin.accounts.duplicate\x00" + strconv.FormatInt(sourceID, 10) + "\x00" + operationKey + 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) } @@ -209,6 +213,13 @@ func (s *adminServiceImpl) findDuplicateByOperationID(ctx context.Context, opera 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 @@ -221,9 +232,9 @@ func cloneAccountValuePointer[T any](value *T) *T { // runtime state. Credentials and extra configuration are deep-copied so normalization of the new // account cannot mutate the in-memory source. Linked credential shadows are excluded because they // intentionally do not own credentials and must be created through CreateShadow. -func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, operationKey string) (*Account, error) { - operationID := duplicateAccountOperationID(id, operationKey) - existing, err := s.findDuplicateByOperationID(ctx, operationID) +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 } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 4d839fa901..964566a80c 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -69,7 +69,10 @@ type AdminService interface { CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) // DuplicateAccount creates an independent account from an existing account's configuration. // First-class runtime columns are intentionally reset by the normal account creation path. - DuplicateAccount(ctx context.Context, id int64, operationKey string) (*Account, error) + 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 等少量键,避免被全量快照覆盖。 diff --git a/backend/internal/service/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go index 41a8d3db7c..f89d887035 100644 --- a/backend/internal/service/admin_service_duplicate_account_test.go +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -142,7 +142,7 @@ func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T) } require.NoError(t, repo.Create(ctx, source)) - duplicate, err := svc.DuplicateAccount(ctx, source.ID, "") + duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.NoError(t, err) require.NotEqual(t, source.ID, duplicate.ID) @@ -210,7 +210,7 @@ func TestDuplicateAccountRejectsCredentialShadow(t *testing.T) { } require.NoError(t, repo.Create(ctx, shadow)) - _, err := svc.DuplicateAccount(ctx, shadow.ID, "") + _, err := svc.DuplicateAccount(ctx, shadow.ID, "admin:1", "") require.Error(t, err) require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) @@ -232,7 +232,7 @@ func TestDuplicateAccountRejectsRotatingOrUnknownCredentialTypes(t *testing.T) { } require.NoError(t, repo.Create(ctx, source)) - _, err := svc.DuplicateAccount(ctx, source.ID, "") + _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.Error(t, err) require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) @@ -255,7 +255,7 @@ func TestDuplicateAccountPreservesUngroupedState(t *testing.T) { } require.NoError(t, repo.Create(ctx, source)) - duplicate, err := svc.DuplicateAccount(ctx, source.ID, "") + duplicate, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.NoError(t, err) require.Empty(t, duplicate.GroupIDs) @@ -277,7 +277,7 @@ func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) { require.NoError(t, repo.Create(ctx, source)) repo.atomicCreateErr = errors.New("group binding failed") - _, err := svc.DuplicateAccount(ctx, source.ID, "") + _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") require.ErrorContains(t, err, "group binding failed") require.Len(t, repo.accounts, 1) @@ -302,12 +302,21 @@ func TestDuplicateAccountReturnsExistingCopyForSameOperationKey(t *testing.T) { } require.NoError(t, repo.Create(ctx, source)) - first, err := svc.DuplicateAccount(ctx, source.ID, "stable-operation-key") + first, err := svc.DuplicateAccount(ctx, source.ID, "admin:7", "stable-operation-key") require.NoError(t, err) - second, err := svc.DuplicateAccount(ctx, source.ID, "stable-operation-key") + 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.Len(t, repo.accounts, 2) + 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]) } diff --git a/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts b/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts index c68426de30..f6bc22c266 100644 --- a/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts +++ b/frontend/src/api/__tests__/admin.accounts.duplicate.spec.ts @@ -12,6 +12,7 @@ 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') @@ -40,4 +41,19 @@ describe('admin account duplicate API', () => { 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) + }) }) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index abc0a31d1a..64a47a50fb 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -145,17 +145,40 @@ export async function create(accountData: CreateAccountRequest): Promise() +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 { - let idempotencyKey = duplicateOperationKeys.get(id) + 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) } + duplicateOperationKeys.set(id, idempotencyKey) + storeDuplicateOperationKey(id, idempotencyKey) const { data } = await apiClient.post(`/admin/accounts/${id}/duplicate`, undefined, { headers: { 'Idempotency-Key': idempotencyKey } }) duplicateOperationKeys.delete(id) + storeDuplicateOperationKey(id, null) return data }