fix: 防止钱包生成中状态下重复触发MPC keygen

问题:
- 前端在钱包状态为"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 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-21 18:55:51 -08:00
parent 5b731498de
commit 10b25e222e
3 changed files with 81 additions and 6 deletions

View File

@ -1078,6 +1078,11 @@ export class UserApplicationService {
*
*
* UserAccountCreatedEvent
*
*
* -
* - generating/deriving/pending
* -
*/
async retryWalletGeneration(userId: string): Promise<void> {
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 小时
);

View File

@ -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}`);
}
}

View File

@ -132,9 +132,9 @@ class WalletStatusNotifier extends StateNotifier<WalletStatusState> {
);
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<WalletStatusState> {
//
}
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(),