From 10b25e222ea505656723b6b3e4a1639312826d74 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 21 Dec 2025 18:55:51 -0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=B8=AD=E7=8A=B6=E6=80=81=E4=B8=8B=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=A7=A6=E5=8F=91MPC=20keygen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 前端在钱包状态为"generating"时仍然调用retryWalletGeneration - 后端identity-service没有检查生成中状态 - mpc-service没有幂等保护,可能导致同一用户多次keygen 修复: 1. 前端 wallet_status_provider.dart: - 只在"failed"状态下才触发重试 - "generating"状态只更新UI,继续轮询等待 2. 后端 identity-service user-application.service.ts: - retryWalletGeneration添加Redis状态检查 - pending/generating/deriving状态下跳过重试 - 只有failed或无状态时才触发重试 3. 后端 mpc-service keygen-requested.handler.ts: - 使用分布式锁防止同一用户重复keygen - 锁TTL为5分钟,覆盖整个keygen过程 - 无法获取锁时跳过请求 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../services/user-application.service.ts | 41 +++++++++++++++++-- .../keygen-requested.handler.ts | 32 +++++++++++++++ .../providers/wallet_status_provider.dart | 14 +++++-- 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/backend/services/identity-service/src/application/services/user-application.service.ts b/backend/services/identity-service/src/application/services/user-application.service.ts index dade703c..207d8c8f 100644 --- a/backend/services/identity-service/src/application/services/user-application.service.ts +++ b/backend/services/identity-service/src/application/services/user-application.service.ts @@ -1078,6 +1078,11 @@ export class UserApplicationService { * * 用户可以通过此方法手动触发钱包生成重试 * 幂等操作:重新发布 UserAccountCreatedEvent + * + * 保护机制: + * - 钱包已完成时,直接返回不重试 + * - 钱包正在生成中(generating/deriving/pending),直接返回不重试 + * - 只有失败或无状态时才触发重试 */ async retryWalletGeneration(userId: string): Promise { this.logger.log( @@ -1106,7 +1111,37 @@ export class UserApplicationService { return; // 钱包已完成,无需重试 } - // 3. 重新触发钱包生成流程 + // 3. 检查 Redis 中的 keygen 状态,防止重复触发 + const redisKey = `keygen:status:${userId}`; + const currentStatusData = await this.redisService.get(redisKey); + + if (currentStatusData) { + try { + const parsed = JSON.parse(currentStatusData); + const status = parsed.status; + + // 如果状态是 pending/generating/deriving,说明正在生成中,不触发重试 + if (status === 'pending' || status === 'generating' || status === 'deriving') { + this.logger.log( + `[WALLET-RETRY] Wallet generation in progress (status=${status}) for user: ${userId}, skip retry`, + ); + return; // 正在生成中,无需重试 + } + + // 如果状态是 completed,但数据库没有完整地址,说明是过期状态,继续重试 + // 如果状态是 failed,允许重试 + this.logger.log( + `[WALLET-RETRY] Current status: ${status}, will trigger retry`, + ); + } catch (e) { + // JSON 解析失败,继续执行重试 + this.logger.warn( + `[WALLET-RETRY] Failed to parse status data for user: ${userId}`, + ); + } + } + + // 4. 重新触发钱包生成流程 const event = account.createWalletGenerationEvent(); await this.eventPublisher.publish(event); @@ -1114,7 +1149,7 @@ export class UserApplicationService { `[WALLET-RETRY] Wallet generation retry triggered for user: ${userId}`, ); - // 4. 更新 Redis 状态为 pending(等待重新生成) + // 5. 更新 Redis 状态为 pending(等待重新生成) const statusData = { status: 'pending', userId, @@ -1122,7 +1157,7 @@ export class UserApplicationService { }; await this.redisService.set( - `keygen:status:${userId}`, + redisKey, JSON.stringify(statusData), 60 * 60 * 24, // 24 小时 ); diff --git a/backend/services/mpc-service/src/application/event-handlers/keygen-requested.handler.ts b/backend/services/mpc-service/src/application/event-handlers/keygen-requested.handler.ts index 851850f8..440f041a 100644 --- a/backend/services/mpc-service/src/application/event-handlers/keygen-requested.handler.ts +++ b/backend/services/mpc-service/src/application/event-handlers/keygen-requested.handler.ts @@ -3,6 +3,11 @@ * * Handles keygen requests from identity-service via Kafka. * Processes the keygen and publishes completion/failure events. + * + * Idempotency Protection: + * - Uses distributed lock to prevent duplicate keygen for the same user + * - Lock is held for the entire keygen process (up to 5 minutes) + * - If lock cannot be acquired, the request is skipped (another instance is processing) */ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; @@ -19,6 +24,10 @@ import { KeygenStartedEvent } from '../../domain/events/keygen-started.event'; import { KeygenCompletedEvent } from '../../domain/events/keygen-completed.event'; import { SessionFailedEvent } from '../../domain/events/session-failed.event'; import { SessionType } from '../../domain/enums'; +import { DistributedLockService } from '../../infrastructure/redis/lock/distributed-lock.service'; + +// Lock TTL for keygen process: 5 minutes (keygen may take a while) +const KEYGEN_LOCK_TTL_MS = 5 * 60 * 1000; @Injectable() export class KeygenRequestedHandler implements OnModuleInit { @@ -30,6 +39,7 @@ export class KeygenRequestedHandler implements OnModuleInit { private readonly mpcCoordinator: MPCCoordinatorService, private readonly backupClient: BackupClientService, private readonly blockchainClient: BlockchainClientService, + private readonly distributedLock: DistributedLockService, ) {} async onModuleInit() { @@ -52,6 +62,24 @@ export class KeygenRequestedHandler implements OnModuleInit { this.logger.log(`[HANDLE] userId=${userId}, username=${username}`); this.logger.log(`[HANDLE] threshold=${threshold}, totalParties=${totalParties}, requireDelegate=${requireDelegate}`); + // Idempotency check: Try to acquire lock for this user's keygen + // If lock is already held, another keygen is in progress for this user + const lockKey = `keygen:user:${userId}`; + const lock = await this.distributedLock.acquire(lockKey, { + ttl: KEYGEN_LOCK_TTL_MS, + retryCount: 1, // Only try once, don't wait + retryDelay: 0, + }); + + if (!lock) { + this.logger.warn( + `[HANDLE] Keygen already in progress for user: ${userId}, skipping duplicate request`, + ); + return; // Skip this request, another instance is handling it + } + + this.logger.log(`[HANDLE] Acquired keygen lock for user: ${userId}`); + try { // Step 1: Create keygen session via mpc-system const createResult = await this.mpcCoordinator.createKeygenSession({ @@ -170,6 +198,10 @@ export class KeygenRequestedHandler implements OnModuleInit { } catch (publishError) { this.logger.error('Failed to publish failure event', publishError); } + } finally { + // Always release the lock when done (success or failure) + await lock.release(); + this.logger.log(`[HANDLE] Released keygen lock for user: ${userId}`); } } diff --git a/frontend/mobile-app/lib/features/auth/presentation/providers/wallet_status_provider.dart b/frontend/mobile-app/lib/features/auth/presentation/providers/wallet_status_provider.dart index a60f3093..320fbd2b 100644 --- a/frontend/mobile-app/lib/features/auth/presentation/providers/wallet_status_provider.dart +++ b/frontend/mobile-app/lib/features/auth/presentation/providers/wallet_status_provider.dart @@ -132,9 +132,9 @@ class WalletStatusNotifier extends StateNotifier { ); stopPolling(); - } else { - // 非 ready 状态,自动触发重试生成(API 是幂等的) - debugPrint('[WalletStatusProvider] Wallet not ready, triggering retry...'); + } else if (walletInfo.isFailed) { + // 只有失败状态才触发重试,生成中状态不重试 + debugPrint('[WalletStatusProvider] Wallet generation failed, triggering retry...'); try { await _accountService.retryWalletGeneration(); @@ -144,6 +144,14 @@ class WalletStatusNotifier extends StateNotifier { // 继续轮询,下次再试 } + state = state.copyWith( + status: WalletGenerationStatus.generating, + lastChecked: DateTime.now(), + ); + } else { + // 生成中状态,只更新状态,继续轮询等待完成 + debugPrint('[WalletStatusProvider] Wallet is generating, waiting...'); + state = state.copyWith( status: WalletGenerationStatus.generating, lastChecked: DateTime.now(),