feat(mpc-system): 增强连接可靠性和消息去重机制
后端改进: - SessionEventBroadcaster: 重连时自动关闭旧 channel 防止内存泄漏 - MessageBroker: 重连时关闭旧的 party/session channel - SubscribeMessages: 订阅时自动发送数据库中的 pending 消息 客户端改进: - GrpcClient: 添加自动重连机制(指数退避,最多10次) - GrpcClient: 断开/重连/失败事件通知前端 - TSSHandler: 消息缓冲机制,进程启动前缓存收到的消息 - TSSHandler: 客户端本地消息去重,防止重连后重复处理 - Database: 添加 processed_messages 表和相关操作方法 - Main: Keygen 幂等性保护,防止重复触发 - Main: 会话事件缓存,解决前端订阅时序问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
df8a14211e
commit
0ca37ee76a
|
|
@ -87,12 +87,49 @@ func (s *MessageRouterServer) RouteMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeMessages subscribes to messages for a party (streaming)
|
// SubscribeMessages subscribes to messages for a party (streaming)
|
||||||
|
// On subscription, it first sends any pending messages from the database
|
||||||
|
// to ensure no messages are lost during reconnection
|
||||||
func (s *MessageRouterServer) SubscribeMessages(
|
func (s *MessageRouterServer) SubscribeMessages(
|
||||||
req *pb.SubscribeMessagesRequest,
|
req *pb.SubscribeMessagesRequest,
|
||||||
stream pb.MessageRouter_SubscribeMessagesServer,
|
stream pb.MessageRouter_SubscribeMessagesServer,
|
||||||
) error {
|
) error {
|
||||||
ctx := stream.Context()
|
ctx := stream.Context()
|
||||||
|
|
||||||
|
logger.Info("Party subscribing to messages",
|
||||||
|
zap.String("session_id", req.SessionId),
|
||||||
|
zap.String("party_id", req.PartyId))
|
||||||
|
|
||||||
|
// First, send any pending messages from the database (message recovery on reconnect)
|
||||||
|
if s.getPendingMessagesUC != nil && req.SessionId != "" {
|
||||||
|
input := use_cases.GetPendingMessagesInput{
|
||||||
|
SessionID: req.SessionId,
|
||||||
|
PartyID: req.PartyId,
|
||||||
|
AfterTimestamp: 0, // Get all pending messages
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingMessages, err := s.getPendingMessagesUC.Execute(ctx, input)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Failed to get pending messages on subscribe",
|
||||||
|
zap.String("session_id", req.SessionId),
|
||||||
|
zap.String("party_id", req.PartyId),
|
||||||
|
zap.Error(err))
|
||||||
|
} else if len(pendingMessages) > 0 {
|
||||||
|
logger.Info("Sending pending messages on subscribe",
|
||||||
|
zap.String("session_id", req.SessionId),
|
||||||
|
zap.String("party_id", req.PartyId),
|
||||||
|
zap.Int("count", len(pendingMessages)))
|
||||||
|
|
||||||
|
for _, msg := range pendingMessages {
|
||||||
|
if err := sendMessage(stream, msg); err != nil {
|
||||||
|
logger.Error("Failed to send pending message",
|
||||||
|
zap.String("message_id", msg.ID),
|
||||||
|
zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe to party messages
|
// Subscribe to party messages
|
||||||
partyCh, err := s.messageBroker.SubscribeToPartyMessages(ctx, req.PartyId)
|
partyCh, err := s.messageBroker.SubscribeToPartyMessages(ctx, req.PartyId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -109,6 +146,9 @@ func (s *MessageRouterServer) SubscribeMessages(
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
logger.Info("Party unsubscribed from messages",
|
||||||
|
zap.String("session_id", req.SessionId),
|
||||||
|
zap.String("party_id", req.PartyId))
|
||||||
return nil
|
return nil
|
||||||
case msg, ok := <-partyCh:
|
case msg, ok := <-partyCh:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ func (a *MessageBrokerAdapter) PublishToSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeToPartyMessages subscribes to messages for a specific party
|
// SubscribeToPartyMessages subscribes to messages for a specific party
|
||||||
|
// If the party already has an active subscription, the old channel is closed first
|
||||||
func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
partyID string,
|
partyID string,
|
||||||
|
|
@ -123,11 +124,15 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
// Create channel if not exists
|
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||||
if _, exists := a.partyChannels[partyID]; !exists {
|
if oldCh, exists := a.partyChannels[partyID]; exists {
|
||||||
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
|
close(oldCh)
|
||||||
|
logger.Info("closed existing party channel for re-subscription",
|
||||||
|
zap.String("party_id", partyID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new channel
|
||||||
|
a.partyChannels[partyID] = make(chan *entities.MessageDTO, 100)
|
||||||
ch := a.partyChannels[partyID]
|
ch := a.partyChannels[partyID]
|
||||||
|
|
||||||
// Return a read-only channel
|
// Return a read-only channel
|
||||||
|
|
@ -155,6 +160,7 @@ func (a *MessageBrokerAdapter) SubscribeToPartyMessages(
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeToSessionMessages subscribes to all messages in a session
|
// SubscribeToSessionMessages subscribes to all messages in a session
|
||||||
|
// If the party already has an active subscription for this session, the old channel is closed first
|
||||||
func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
|
func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionID string,
|
sessionID string,
|
||||||
|
|
@ -171,14 +177,18 @@ func (a *MessageBrokerAdapter) SubscribeToSessionMessages(
|
||||||
zap.String("key", key),
|
zap.String("key", key),
|
||||||
zap.Int("current_channel_count", len(a.sessionChannels)))
|
zap.Int("current_channel_count", len(a.sessionChannels)))
|
||||||
|
|
||||||
// Create channel if not exists
|
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||||
if _, exists := a.sessionChannels[key]; !exists {
|
if oldCh, exists := a.sessionChannels[key]; exists {
|
||||||
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
|
close(oldCh)
|
||||||
logger.Info("Created new session channel",
|
logger.Info("closed existing session channel for re-subscription",
|
||||||
zap.String("key", key))
|
zap.String("key", key))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new channel
|
||||||
|
a.sessionChannels[key] = make(chan *entities.MessageDTO, 100)
|
||||||
ch := a.sessionChannels[key]
|
ch := a.sessionChannels[key]
|
||||||
|
logger.Info("Created new session channel",
|
||||||
|
zap.String("key", key))
|
||||||
|
|
||||||
// Return a read-only channel
|
// Return a read-only channel
|
||||||
out := make(chan *entities.MessageDTO, 100)
|
out := make(chan *entities.MessageDTO, 100)
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@ func NewSessionEventBroadcaster() *SessionEventBroadcaster {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe subscribes a party to session events
|
// Subscribe subscribes a party to session events
|
||||||
|
// If the party already has an active subscription, the old channel is closed first
|
||||||
|
// to prevent memory leaks and ensure clean reconnection
|
||||||
func (b *SessionEventBroadcaster) Subscribe(partyID string) <-chan *pb.SessionEvent {
|
func (b *SessionEventBroadcaster) Subscribe(partyID string) <-chan *pb.SessionEvent {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Close existing channel if party is re-subscribing (e.g., after reconnect)
|
||||||
|
if oldCh, exists := b.subscribers[partyID]; exists {
|
||||||
|
close(oldCh)
|
||||||
|
}
|
||||||
|
|
||||||
// Create buffered channel for this subscriber
|
// Create buffered channel for this subscriber
|
||||||
ch := make(chan *pb.SessionEvent, 100)
|
ch := make(chan *pb.SessionEvent, 100)
|
||||||
b.subscribers[partyID] = ch
|
b.subscribers[partyID] = ch
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ interface ActiveKeygenSession {
|
||||||
}
|
}
|
||||||
let activeKeygenSession: ActiveKeygenSession | null = null;
|
let activeKeygenSession: ActiveKeygenSession | null = null;
|
||||||
|
|
||||||
|
// Keygen 幂等性保护:追踪正在进行的 keygen 会话 ID
|
||||||
|
let keygenInProgressSessionId: string | null = null;
|
||||||
|
|
||||||
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
|
// 会话事件缓存 - 解决前端订阅时可能错过事件的时序问题
|
||||||
// 当事件到达时,前端可能还在页面导航中,尚未订阅
|
// 当事件到达时,前端可能还在页面导航中,尚未订阅
|
||||||
interface SessionEventData {
|
interface SessionEventData {
|
||||||
|
|
@ -242,7 +245,9 @@ function getOrCreatePartyId(db: DatabaseManager): string {
|
||||||
// 生成一个新的 UUID 作为 partyId
|
// 生成一个新的 UUID 作为 partyId
|
||||||
partyId = crypto.randomUUID();
|
partyId = crypto.randomUUID();
|
||||||
db.setSetting('party_id', partyId);
|
db.setSetting('party_id', partyId);
|
||||||
console.log('Generated new partyId:', partyId);
|
debugLog.info('main', `Generated new partyId: ${partyId}`);
|
||||||
|
} else {
|
||||||
|
debugLog.info('main', `Loaded existing partyId: ${partyId}`);
|
||||||
}
|
}
|
||||||
return partyId;
|
return partyId;
|
||||||
}
|
}
|
||||||
|
|
@ -251,17 +256,24 @@ function getOrCreatePartyId(db: DatabaseManager): string {
|
||||||
async function initServices() {
|
async function initServices() {
|
||||||
// 初始化数据库 (必须首先初始化)
|
// 初始化数据库 (必须首先初始化)
|
||||||
database = new DatabaseManager();
|
database = new DatabaseManager();
|
||||||
|
// 等待数据库初始化完成(加载 WASM 和创建表)
|
||||||
|
await database.waitForReady();
|
||||||
|
debugLog.info('main', 'Database initialized');
|
||||||
|
|
||||||
// 初始化 gRPC 客户端
|
// 初始化 gRPC 客户端
|
||||||
grpcClient = new GrpcClient();
|
grpcClient = new GrpcClient();
|
||||||
|
|
||||||
|
// 清理过期的已处理消息记录(防止数据库膨胀)
|
||||||
|
database.cleanupOldProcessedMessages();
|
||||||
|
debugLog.debug('main', 'Cleaned up old processed messages');
|
||||||
|
|
||||||
// 初始化 TSS Handler
|
// 初始化 TSS Handler
|
||||||
if (USE_MOCK_TSS) {
|
if (USE_MOCK_TSS) {
|
||||||
debugLog.info('tss', 'Using Mock TSS Handler (development mode)');
|
debugLog.info('tss', 'Using Mock TSS Handler (development mode)');
|
||||||
tssHandler = new MockTSSHandler(grpcClient);
|
tssHandler = new MockTSSHandler(grpcClient);
|
||||||
} else {
|
} else {
|
||||||
debugLog.info('tss', 'Using real TSS Handler');
|
debugLog.info('tss', 'Using real TSS Handler');
|
||||||
tssHandler = new TSSHandler(grpcClient);
|
tssHandler = new TSSHandler(grpcClient, database);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 TSS 进度事件监听
|
// 设置 TSS 进度事件监听
|
||||||
|
|
@ -324,6 +336,18 @@ async function handleSessionStart(event: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 幂等性保护:检查是否已经在执行 keygen
|
||||||
|
if (keygenInProgressSessionId === event.sessionId) {
|
||||||
|
debugLog.debug('main', `Keygen already in progress for session ${event.sessionId}, skipping duplicate trigger`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再次检查 TSS 是否在运行(双重保护)
|
||||||
|
if (tssHandler?.getIsRunning()) {
|
||||||
|
debugLog.debug('main', 'TSS already running, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!tssHandler) {
|
if (!tssHandler) {
|
||||||
debugLog.error('tss', 'TSS handler not initialized');
|
debugLog.error('tss', 'TSS handler not initialized');
|
||||||
mainWindow?.webContents.send(`session:events:${event.sessionId}`, {
|
mainWindow?.webContents.send(`session:events:${event.sessionId}`, {
|
||||||
|
|
@ -333,6 +357,9 @@ async function handleSessionStart(event: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记 keygen 开始
|
||||||
|
keygenInProgressSessionId = event.sessionId;
|
||||||
|
|
||||||
// 从事件中更新参与者列表(如果事件包含完整列表)
|
// 从事件中更新参与者列表(如果事件包含完整列表)
|
||||||
if (event.selectedParties && event.selectedParties.length > 0) {
|
if (event.selectedParties && event.selectedParties.length > 0) {
|
||||||
const myPartyId = grpcClient?.getPartyId();
|
const myPartyId = grpcClient?.getPartyId();
|
||||||
|
|
@ -384,6 +411,8 @@ async function handleSessionStart(event: {
|
||||||
type: 'failed',
|
type: 'failed',
|
||||||
error: result.error || 'Keygen failed',
|
error: result.error || 'Keygen failed',
|
||||||
});
|
});
|
||||||
|
// 清除幂等性标志
|
||||||
|
keygenInProgressSessionId = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog.error('tss', `Keygen error: ${(error as Error).message}`);
|
debugLog.error('tss', `Keygen error: ${(error as Error).message}`);
|
||||||
|
|
@ -391,6 +420,8 @@ async function handleSessionStart(event: {
|
||||||
type: 'failed',
|
type: 'failed',
|
||||||
error: (error as Error).message,
|
error: (error as Error).message,
|
||||||
});
|
});
|
||||||
|
// 清除幂等性标志
|
||||||
|
keygenInProgressSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,8 +474,9 @@ async function handleKeygenComplete(result: KeygenResult) {
|
||||||
allCompleted: allCompleted,
|
allCompleted: allCompleted,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 清理活跃会话
|
// 4. 清理活跃会话和幂等性标志
|
||||||
activeKeygenSession = null;
|
activeKeygenSession = null;
|
||||||
|
keygenInProgressSessionId = null;
|
||||||
debugLog.info('main', 'Keygen session completed and cleaned up');
|
debugLog.info('main', 'Keygen session completed and cleaned up');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -453,6 +485,8 @@ async function handleKeygenComplete(result: KeygenResult) {
|
||||||
type: 'failed',
|
type: 'failed',
|
||||||
error: (error as Error).message,
|
error: (error as Error).message,
|
||||||
});
|
});
|
||||||
|
// 清除幂等性标志
|
||||||
|
keygenInProgressSessionId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -481,6 +515,22 @@ async function connectAndRegisterToMessageRouter() {
|
||||||
grpcClient.subscribeSessionEvents(partyId);
|
grpcClient.subscribeSessionEvents(partyId);
|
||||||
debugLog.info('grpc', 'Subscribed to session events');
|
debugLog.info('grpc', 'Subscribed to session events');
|
||||||
|
|
||||||
|
// 监听连接状态变化
|
||||||
|
grpcClient.on('disconnected', (reason: string) => {
|
||||||
|
debugLog.warn('grpc', `Disconnected from Message Router: ${reason}`);
|
||||||
|
mainWindow?.webContents.send('grpc:connectionStatus', { connected: false, reason });
|
||||||
|
});
|
||||||
|
|
||||||
|
grpcClient.on('reconnected', () => {
|
||||||
|
debugLog.info('grpc', 'Reconnected to Message Router');
|
||||||
|
mainWindow?.webContents.send('grpc:connectionStatus', { connected: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
grpcClient.on('reconnectFailed', (reason: string) => {
|
||||||
|
debugLog.error('grpc', `Failed to reconnect: ${reason}`);
|
||||||
|
mainWindow?.webContents.send('grpc:connectionStatus', { connected: false, error: reason });
|
||||||
|
});
|
||||||
|
|
||||||
// 监听会话事件并处理
|
// 监听会话事件并处理
|
||||||
grpcClient.on('sessionEvent', async (event: {
|
grpcClient.on('sessionEvent', async (event: {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
|
@ -1345,6 +1395,29 @@ function setupIpcHandlers() {
|
||||||
ipcMain.on('debug:log', (_event, { level, source, message }) => {
|
ipcMain.on('debug:log', (_event, { level, source, message }) => {
|
||||||
sendDebugLog(level as LogLevel, source as LogSource, message);
|
sendDebugLog(level as LogLevel, source as LogSource, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 会话事件订阅(带缓存事件发送)
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// 前端订阅会话事件时,立即发送缓存的事件
|
||||||
|
ipcMain.on('grpc:subscribeSessionEvents', (_event, { sessionId }) => {
|
||||||
|
debugLog.debug('main', `Frontend subscribing to session events: ${sessionId}`);
|
||||||
|
|
||||||
|
// 获取并发送缓存的事件
|
||||||
|
const cachedEvents = getAndClearCachedEvents(sessionId);
|
||||||
|
if (cachedEvents.length > 0) {
|
||||||
|
debugLog.info('main', `Sending ${cachedEvents.length} cached events to frontend for session ${sessionId}`);
|
||||||
|
for (const event of cachedEvents) {
|
||||||
|
mainWindow?.webContents.send(`session:events:${sessionId}`, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 前端取消订阅
|
||||||
|
ipcMain.on('grpc:unsubscribeSessionEvents', (_event, { sessionId }) => {
|
||||||
|
debugLog.debug('main', `Frontend unsubscribing from session events: ${sessionId}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用生命周期
|
// 应用生命周期
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,13 @@ export class DatabaseManager {
|
||||||
await this.initPromise;
|
await this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待数据库初始化完成(公开方法)
|
||||||
|
*/
|
||||||
|
async waitForReady(): Promise<void> {
|
||||||
|
await this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建表结构
|
* 创建表结构
|
||||||
*/
|
*/
|
||||||
|
|
@ -213,12 +220,22 @@ export class DatabaseManager {
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 已处理消息表 - 用于消息去重,防止重连后重复处理消息
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS processed_messages (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
processed_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 创建索引
|
// 创建索引
|
||||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id)`);
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_shares_session ON shares(session_id)`);
|
||||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_share ON derived_addresses(share_id)`);
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_share ON derived_addresses(share_id)`);
|
||||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_chain ON derived_addresses(chain)`);
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_addresses_chain ON derived_addresses(chain)`);
|
||||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_share ON signing_history(share_id)`);
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_share ON signing_history(share_id)`);
|
||||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_status ON signing_history(status)`);
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_history_status ON signing_history(status)`);
|
||||||
|
this.db.run(`CREATE INDEX IF NOT EXISTS idx_processed_messages_session ON processed_messages(session_id)`);
|
||||||
|
|
||||||
// 插入默认设置
|
// 插入默认设置
|
||||||
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['message_router_url', 'mpc-grpc.szaiai.com:443']);
|
this.db.run(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, ['message_router_url', 'mpc-grpc.szaiai.com:443']);
|
||||||
|
|
@ -610,6 +627,55 @@ export class DatabaseManager {
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// 消息去重操作
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查消息是否已处理
|
||||||
|
*/
|
||||||
|
isMessageProcessed(messageId: string): boolean {
|
||||||
|
const row = this.queryOne<{ message_id: string }>(
|
||||||
|
`SELECT message_id FROM processed_messages WHERE message_id = ?`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记消息为已处理
|
||||||
|
*/
|
||||||
|
markMessageProcessed(messageId: string, sessionId: string): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
this.db.run(
|
||||||
|
`INSERT OR IGNORE INTO processed_messages (message_id, session_id, processed_at) VALUES (?, ?, ?)`,
|
||||||
|
[messageId, sessionId, now]
|
||||||
|
);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理指定会话的已处理消息记录
|
||||||
|
* 当会话完成后调用,释放空间
|
||||||
|
*/
|
||||||
|
clearProcessedMessages(sessionId: string): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
this.db.run(`DELETE FROM processed_messages WHERE session_id = ?`, [sessionId]);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的已处理消息记录(超过24小时)
|
||||||
|
* 可在应用启动时调用
|
||||||
|
*/
|
||||||
|
cleanupOldProcessedMessages(): void {
|
||||||
|
if (!this.db) return;
|
||||||
|
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
this.db.run(`DELETE FROM processed_messages WHERE processed_at < ?`, [cutoff]);
|
||||||
|
this.saveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 导入导出
|
// 导入导出
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -98,23 +98,62 @@ interface GetRegisteredPartiesResponse {
|
||||||
parties: RegisteredParty[];
|
parties: RegisteredParty[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重连配置
|
||||||
|
interface ReconnectConfig {
|
||||||
|
maxRetries: number;
|
||||||
|
initialDelayMs: number;
|
||||||
|
maxDelayMs: number;
|
||||||
|
backoffMultiplier: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||||
|
maxRetries: 10,
|
||||||
|
initialDelayMs: 1000,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* gRPC 客户端 - 连接到 Message Router
|
* gRPC 客户端 - 连接到 Message Router
|
||||||
*
|
*
|
||||||
* 连接地址格式:
|
* 连接地址格式:
|
||||||
* - 开发环境: localhost:50051 (不加密)
|
* - 开发环境: localhost:50051 (不加密)
|
||||||
* - 生产环境: mpc-grpc.szaiai.com:443 (TLS 加密)
|
* - 生产环境: mpc-grpc.szaiai.com:443 (TLS 加密)
|
||||||
|
*
|
||||||
|
* 特性:
|
||||||
|
* - 自动重连机制(指数退避)
|
||||||
|
* - 事件流断开后自动重新订阅
|
||||||
|
* - 心跳失败后自动重连
|
||||||
*/
|
*/
|
||||||
export class GrpcClient extends EventEmitter {
|
export class GrpcClient extends EventEmitter {
|
||||||
private client: grpc.Client | null = null;
|
private client: grpc.Client | null = null;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private partyId: string | null = null;
|
private partyId: string | null = null;
|
||||||
|
private partyRole: string | null = null;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
private messageStream: grpc.ClientReadableStream<MPCMessage> | null = null;
|
private messageStream: grpc.ClientReadableStream<MPCMessage> | null = null;
|
||||||
private eventStream: grpc.ClientReadableStream<SessionEvent> | null = null;
|
private eventStream: grpc.ClientReadableStream<SessionEvent> | null = null;
|
||||||
|
|
||||||
constructor() {
|
// 重连相关
|
||||||
|
private reconnectConfig: ReconnectConfig;
|
||||||
|
private currentAddress: string | null = null;
|
||||||
|
private currentUseTLS: boolean | undefined;
|
||||||
|
private isReconnecting = false;
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private reconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private shouldReconnect = true;
|
||||||
|
|
||||||
|
// 消息流状态(用于重连后恢复)
|
||||||
|
private activeMessageSubscription: { sessionId: string; partyId: string } | null = null;
|
||||||
|
private eventStreamSubscribed = false;
|
||||||
|
|
||||||
|
// 心跳失败计数
|
||||||
|
private heartbeatFailCount = 0;
|
||||||
|
private readonly MAX_HEARTBEAT_FAILS = 3;
|
||||||
|
|
||||||
|
constructor(reconnectConfig?: Partial<ReconnectConfig>) {
|
||||||
super();
|
super();
|
||||||
|
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...reconnectConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,6 +162,15 @@ export class GrpcClient extends EventEmitter {
|
||||||
* @param useTLS 是否使用 TLS 加密 (默认: 自动检测,端口 443 使用 TLS)
|
* @param useTLS 是否使用 TLS 加密 (默认: 自动检测,端口 443 使用 TLS)
|
||||||
*/
|
*/
|
||||||
async connect(address: string, useTLS?: boolean): Promise<void> {
|
async connect(address: string, useTLS?: boolean): Promise<void> {
|
||||||
|
// 保存连接参数用于重连
|
||||||
|
this.currentAddress = address;
|
||||||
|
this.currentUseTLS = useTLS;
|
||||||
|
this.shouldReconnect = true;
|
||||||
|
|
||||||
|
return this.doConnect(address, useTLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doConnect(address: string, useTLS?: boolean): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const definition = loadProtoDefinition();
|
const definition = loadProtoDefinition();
|
||||||
const proto = grpc.loadPackageDefinition(definition) as ProtoPackage;
|
const proto = grpc.loadPackageDefinition(definition) as ProtoPackage;
|
||||||
|
|
@ -148,7 +196,7 @@ export class GrpcClient extends EventEmitter {
|
||||||
? grpc.credentials.createSsl() // TLS 加密 (生产环境)
|
? grpc.credentials.createSsl() // TLS 加密 (生产环境)
|
||||||
: grpc.credentials.createInsecure(); // 不加密 (开发环境)
|
: grpc.credentials.createInsecure(); // 不加密 (开发环境)
|
||||||
|
|
||||||
console.log(`Connecting to Message Router: ${targetAddress} (TLS: ${shouldUseTLS})`);
|
console.log(`[gRPC] Connecting to Message Router: ${targetAddress} (TLS: ${shouldUseTLS})`);
|
||||||
|
|
||||||
this.client = new MessageRouter(
|
this.client = new MessageRouter(
|
||||||
targetAddress,
|
targetAddress,
|
||||||
|
|
@ -165,6 +213,10 @@ export class GrpcClient extends EventEmitter {
|
||||||
reject(err);
|
reject(err);
|
||||||
} else {
|
} else {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
|
this.reconnectAttempts = 0; // 重置重连计数
|
||||||
|
this.heartbeatFailCount = 0;
|
||||||
|
console.log('[gRPC] Connected successfully');
|
||||||
|
this.emit('connected');
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -172,31 +224,121 @@ export class GrpcClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开连接
|
* 断开连接(不会自动重连)
|
||||||
*/
|
*/
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
|
this.shouldReconnect = false;
|
||||||
|
this.cleanupConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理连接资源
|
||||||
|
*/
|
||||||
|
private cleanupConnection(): void {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout);
|
||||||
|
this.reconnectTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.heartbeatInterval) {
|
if (this.heartbeatInterval) {
|
||||||
clearInterval(this.heartbeatInterval);
|
clearInterval(this.heartbeatInterval);
|
||||||
this.heartbeatInterval = null;
|
this.heartbeatInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.messageStream) {
|
if (this.messageStream) {
|
||||||
this.messageStream.cancel();
|
try {
|
||||||
|
this.messageStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略取消错误
|
||||||
|
}
|
||||||
this.messageStream = null;
|
this.messageStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.eventStream) {
|
if (this.eventStream) {
|
||||||
this.eventStream.cancel();
|
try {
|
||||||
|
this.eventStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略取消错误
|
||||||
|
}
|
||||||
this.eventStream = null;
|
this.eventStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
(this.client as grpc.Client & { close: () => void }).close();
|
try {
|
||||||
|
(this.client as grpc.Client & { close: () => void }).close();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略关闭错误
|
||||||
|
}
|
||||||
this.client = null;
|
this.client = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.partyId = null;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发重连
|
||||||
|
*/
|
||||||
|
private async triggerReconnect(reason: string): Promise<void> {
|
||||||
|
if (!this.shouldReconnect || this.isReconnecting || !this.currentAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[gRPC] Triggering reconnect: ${reason}`);
|
||||||
|
this.isReconnecting = true;
|
||||||
|
this.connected = false;
|
||||||
|
this.emit('disconnected', reason);
|
||||||
|
|
||||||
|
// 清理现有连接
|
||||||
|
this.cleanupConnection();
|
||||||
|
|
||||||
|
// 计算延迟时间(指数退避)
|
||||||
|
const delay = Math.min(
|
||||||
|
this.reconnectConfig.initialDelayMs * Math.pow(this.reconnectConfig.backoffMultiplier, this.reconnectAttempts),
|
||||||
|
this.reconnectConfig.maxDelayMs
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[gRPC] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.reconnectConfig.maxRetries})`);
|
||||||
|
|
||||||
|
this.reconnectTimeout = setTimeout(async () => {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
|
if (this.reconnectAttempts > this.reconnectConfig.maxRetries) {
|
||||||
|
console.error('[gRPC] Max reconnect attempts reached');
|
||||||
|
this.isReconnecting = false;
|
||||||
|
this.emit('reconnectFailed', 'Max retries exceeded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.doConnect(this.currentAddress!, this.currentUseTLS);
|
||||||
|
|
||||||
|
// 重新注册
|
||||||
|
if (this.partyId && this.partyRole) {
|
||||||
|
console.log(`[gRPC] Re-registering as party: ${this.partyId}`);
|
||||||
|
await this.registerParty(this.partyId, this.partyRole);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新订阅事件流
|
||||||
|
if (this.eventStreamSubscribed && this.partyId) {
|
||||||
|
console.log('[gRPC] Re-subscribing to session events');
|
||||||
|
this.subscribeSessionEvents(this.partyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新订阅消息流
|
||||||
|
if (this.activeMessageSubscription) {
|
||||||
|
console.log(`[gRPC] Re-subscribing to messages for session: ${this.activeMessageSubscription.sessionId}`);
|
||||||
|
this.subscribeMessages(this.activeMessageSubscription.sessionId, this.activeMessageSubscription.partyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isReconnecting = false;
|
||||||
|
this.emit('reconnected');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[gRPC] Reconnect attempt ${this.reconnectAttempts} failed:`, (err as Error).message);
|
||||||
|
this.isReconnecting = false;
|
||||||
|
// 继续尝试重连
|
||||||
|
this.triggerReconnect('Previous reconnect attempt failed');
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -236,6 +378,7 @@ export class GrpcClient extends EventEmitter {
|
||||||
reject(new Error('Registration failed'));
|
reject(new Error('Registration failed'));
|
||||||
} else {
|
} else {
|
||||||
this.partyId = partyId;
|
this.partyId = partyId;
|
||||||
|
this.partyRole = role;
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|
@ -245,13 +388,15 @@ export class GrpcClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始心跳
|
* 开始心跳(带重连逻辑)
|
||||||
*/
|
*/
|
||||||
private startHeartbeat(): void {
|
private startHeartbeat(): void {
|
||||||
if (this.heartbeatInterval) {
|
if (this.heartbeatInterval) {
|
||||||
clearInterval(this.heartbeatInterval);
|
clearInterval(this.heartbeatInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.heartbeatFailCount = 0;
|
||||||
|
|
||||||
this.heartbeatInterval = setInterval(() => {
|
this.heartbeatInterval = setInterval(() => {
|
||||||
if (this.client && this.partyId) {
|
if (this.client && this.partyId) {
|
||||||
(this.client as grpc.Client & { heartbeat: (req: unknown, callback: (err: Error | null) => void) => void })
|
(this.client as grpc.Client & { heartbeat: (req: unknown, callback: (err: Error | null) => void) => void })
|
||||||
|
|
@ -259,8 +404,17 @@ export class GrpcClient extends EventEmitter {
|
||||||
{ party_id: this.partyId },
|
{ party_id: this.partyId },
|
||||||
(err: Error | null) => {
|
(err: Error | null) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Heartbeat failed:', err.message);
|
this.heartbeatFailCount++;
|
||||||
|
console.error(`[gRPC] Heartbeat failed (${this.heartbeatFailCount}/${this.MAX_HEARTBEAT_FAILS}):`, err.message);
|
||||||
this.emit('connectionError', err);
|
this.emit('connectionError', err);
|
||||||
|
|
||||||
|
// 连续失败多次后触发重连
|
||||||
|
if (this.heartbeatFailCount >= this.MAX_HEARTBEAT_FAILS) {
|
||||||
|
this.triggerReconnect('Heartbeat failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 心跳成功,重置失败计数
|
||||||
|
this.heartbeatFailCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -296,13 +450,25 @@ export class GrpcClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅会话事件
|
* 订阅会话事件(带自动重连)
|
||||||
*/
|
*/
|
||||||
subscribeSessionEvents(partyId: string): void {
|
subscribeSessionEvents(partyId: string): void {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Not connected');
|
throw new Error('Not connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记已订阅(用于重连后恢复)
|
||||||
|
this.eventStreamSubscribed = true;
|
||||||
|
|
||||||
|
// 取消现有流
|
||||||
|
if (this.eventStream) {
|
||||||
|
try {
|
||||||
|
this.eventStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.eventStream = (this.client as grpc.Client & { subscribeSessionEvents: (req: unknown) => grpc.ClientReadableStream<SessionEvent> })
|
this.eventStream = (this.client as grpc.Client & { subscribeSessionEvents: (req: unknown) => grpc.ClientReadableStream<SessionEvent> })
|
||||||
.subscribeSessionEvents({ party_id: partyId });
|
.subscribeSessionEvents({ party_id: partyId });
|
||||||
|
|
||||||
|
|
@ -311,24 +477,61 @@ export class GrpcClient extends EventEmitter {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventStream.on('error', (err: Error) => {
|
this.eventStream.on('error', (err: Error) => {
|
||||||
console.error('Session event stream error:', err.message);
|
console.error('[gRPC] Session event stream error:', err.message);
|
||||||
this.emit('streamError', err);
|
this.emit('streamError', err);
|
||||||
|
|
||||||
|
// 非主动取消的错误触发重连
|
||||||
|
if (!err.message.includes('CANCELLED') && this.shouldReconnect) {
|
||||||
|
this.triggerReconnect('Event stream error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventStream.on('end', () => {
|
this.eventStream.on('end', () => {
|
||||||
console.log('Session event stream ended');
|
console.log('[gRPC] Session event stream ended');
|
||||||
this.emit('streamEnd');
|
this.emit('streamEnd');
|
||||||
|
|
||||||
|
// 流结束也触发重连
|
||||||
|
if (this.shouldReconnect && this.eventStreamSubscribed) {
|
||||||
|
this.triggerReconnect('Event stream ended');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 订阅 MPC 消息
|
* 取消订阅会话事件
|
||||||
|
*/
|
||||||
|
unsubscribeSessionEvents(): void {
|
||||||
|
this.eventStreamSubscribed = false;
|
||||||
|
if (this.eventStream) {
|
||||||
|
try {
|
||||||
|
this.eventStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
this.eventStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅 MPC 消息(带自动重连)
|
||||||
*/
|
*/
|
||||||
subscribeMessages(sessionId: string, partyId: string): void {
|
subscribeMessages(sessionId: string, partyId: string): void {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Not connected');
|
throw new Error('Not connected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存订阅状态(用于重连后恢复)
|
||||||
|
this.activeMessageSubscription = { sessionId, partyId };
|
||||||
|
|
||||||
|
// 取消现有流
|
||||||
|
if (this.messageStream) {
|
||||||
|
try {
|
||||||
|
this.messageStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
|
this.messageStream = (this.client as grpc.Client & { subscribeMessages: (req: unknown) => grpc.ClientReadableStream<MPCMessage> })
|
||||||
.subscribeMessages({
|
.subscribeMessages({
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
|
@ -340,16 +543,41 @@ export class GrpcClient extends EventEmitter {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.messageStream.on('error', (err: Error) => {
|
this.messageStream.on('error', (err: Error) => {
|
||||||
console.error('Message stream error:', err.message);
|
console.error('[gRPC] Message stream error:', err.message);
|
||||||
this.emit('messageStreamError', err);
|
this.emit('messageStreamError', err);
|
||||||
|
|
||||||
|
// 非主动取消的错误触发重连
|
||||||
|
if (!err.message.includes('CANCELLED') && this.shouldReconnect && this.activeMessageSubscription) {
|
||||||
|
this.triggerReconnect('Message stream error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.messageStream.on('end', () => {
|
this.messageStream.on('end', () => {
|
||||||
console.log('Message stream ended');
|
console.log('[gRPC] Message stream ended');
|
||||||
this.emit('messageStreamEnd');
|
this.emit('messageStreamEnd');
|
||||||
|
|
||||||
|
// 流结束也触发重连
|
||||||
|
if (this.shouldReconnect && this.activeMessageSubscription) {
|
||||||
|
this.triggerReconnect('Message stream ended');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅 MPC 消息
|
||||||
|
*/
|
||||||
|
unsubscribeMessages(): void {
|
||||||
|
this.activeMessageSubscription = null;
|
||||||
|
if (this.messageStream) {
|
||||||
|
try {
|
||||||
|
this.messageStream.cancel();
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
this.messageStream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送 MPC 消息
|
* 发送 MPC 消息
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { GrpcClient } from './grpc-client';
|
import { GrpcClient } from './grpc-client';
|
||||||
|
import { DatabaseManager } from './database';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TSS 协议处理结果
|
* TSS 协议处理结果
|
||||||
|
|
@ -54,6 +55,7 @@ interface ParticipantInfo {
|
||||||
export class TSSHandler extends EventEmitter {
|
export class TSSHandler extends EventEmitter {
|
||||||
private tssProcess: ChildProcess | null = null;
|
private tssProcess: ChildProcess | null = null;
|
||||||
private grpcClient: GrpcClient;
|
private grpcClient: GrpcClient;
|
||||||
|
private database: DatabaseManager | null = null;
|
||||||
private sessionId: string | null = null;
|
private sessionId: string | null = null;
|
||||||
private partyId: string | null = null;
|
private partyId: string | null = null;
|
||||||
private partyIndex: number = -1;
|
private partyIndex: number = -1;
|
||||||
|
|
@ -61,9 +63,26 @@ export class TSSHandler extends EventEmitter {
|
||||||
private partyIndexMap: Map<string, number> = new Map();
|
private partyIndexMap: Map<string, number> = new Map();
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
|
||||||
constructor(grpcClient: GrpcClient) {
|
// 消息缓冲:在 TSS 进程启动前缓冲收到的消息
|
||||||
|
private messageBuffer: Array<{
|
||||||
|
messageId: string;
|
||||||
|
fromParty: string;
|
||||||
|
isBroadcast: boolean;
|
||||||
|
payload: Buffer;
|
||||||
|
}> = [];
|
||||||
|
private isProcessReady = false;
|
||||||
|
|
||||||
|
constructor(grpcClient: GrpcClient, database?: DatabaseManager) {
|
||||||
super();
|
super();
|
||||||
this.grpcClient = grpcClient;
|
this.grpcClient = grpcClient;
|
||||||
|
this.database = database || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据库管理器(用于消息去重)
|
||||||
|
*/
|
||||||
|
setDatabase(database: DatabaseManager): void {
|
||||||
|
this.database = database;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -114,6 +133,8 @@ export class TSSHandler extends EventEmitter {
|
||||||
this.partyIndex = partyIndex;
|
this.partyIndex = partyIndex;
|
||||||
this.participants = participants;
|
this.participants = participants;
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.messageBuffer = []; // 清空消息缓冲
|
||||||
|
|
||||||
// 构建 party index map
|
// 构建 party index map
|
||||||
this.partyIndexMap.clear();
|
this.partyIndexMap.clear();
|
||||||
|
|
@ -142,10 +163,21 @@ export class TSSHandler extends EventEmitter {
|
||||||
|
|
||||||
let resultData = '';
|
let resultData = '';
|
||||||
|
|
||||||
|
// 先订阅消息(可能在进程就绪前就收到消息,会被缓冲)
|
||||||
|
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
||||||
|
this.grpcClient.subscribeMessages(sessionId, partyId);
|
||||||
|
|
||||||
// 处理标准输出 (JSON 消息)
|
// 处理标准输出 (JSON 消息)
|
||||||
this.tssProcess.stdout?.on('data', (data: Buffer) => {
|
this.tssProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
const lines = data.toString().split('\n').filter(line => line.trim());
|
const lines = data.toString().split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
// 收到第一条输出时,标记进程就绪并发送缓冲的消息
|
||||||
|
if (!this.isProcessReady && this.tssProcess?.stdin) {
|
||||||
|
this.isProcessReady = true;
|
||||||
|
console.log(`[TSS] Process ready, flushing ${this.messageBuffer.length} buffered messages`);
|
||||||
|
this.flushMessageBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
try {
|
try {
|
||||||
const message: TSSMessage = JSON.parse(line);
|
const message: TSSMessage = JSON.parse(line);
|
||||||
|
|
@ -168,13 +200,22 @@ export class TSSHandler extends EventEmitter {
|
||||||
|
|
||||||
// 处理进程退出
|
// 处理进程退出
|
||||||
this.tssProcess.on('close', (code) => {
|
this.tssProcess.on('close', (code) => {
|
||||||
|
const completedSessionId = this.sessionId;
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
this.tssProcess = null;
|
this.tssProcess = null;
|
||||||
|
// 清理消息监听器,防止下次 keygen 时重复注册
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
|
|
||||||
if (code === 0 && resultData) {
|
if (code === 0 && resultData) {
|
||||||
try {
|
try {
|
||||||
const result: TSSMessage = JSON.parse(resultData);
|
const result: TSSMessage = JSON.parse(resultData);
|
||||||
if (result.publicKey && result.encryptedShare) {
|
if (result.publicKey && result.encryptedShare) {
|
||||||
|
// 成功完成后清理该会话的已处理消息记录
|
||||||
|
if (this.database && completedSessionId) {
|
||||||
|
this.database.clearProcessedMessages(completedSessionId);
|
||||||
|
}
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
publicKey: Buffer.from(result.publicKey, 'base64'),
|
publicKey: Buffer.from(result.publicKey, 'base64'),
|
||||||
|
|
@ -195,14 +236,14 @@ export class TSSHandler extends EventEmitter {
|
||||||
// 处理进程错误
|
// 处理进程错误
|
||||||
this.tssProcess.on('error', (err) => {
|
this.tssProcess.on('error', (err) => {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
this.tssProcess = null;
|
this.tssProcess = null;
|
||||||
|
// 清理消息监听器
|
||||||
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 订阅 MPC 消息并转发给 TSS 进程
|
|
||||||
this.grpcClient.on('mpcMessage', this.handleIncomingMessage.bind(this));
|
|
||||||
this.grpcClient.subscribeMessages(sessionId, partyId);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
reject(err);
|
reject(err);
|
||||||
|
|
@ -254,14 +295,44 @@ export class TSSHandler extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理从 gRPC 接收的 MPC 消息
|
* 处理从 gRPC 接收的 MPC 消息(带去重)
|
||||||
*/
|
*/
|
||||||
private handleIncomingMessage(message: {
|
private handleIncomingMessage(message: {
|
||||||
|
messageId: string;
|
||||||
fromParty: string;
|
fromParty: string;
|
||||||
isBroadcast: boolean;
|
isBroadcast: boolean;
|
||||||
payload: Buffer;
|
payload: Buffer;
|
||||||
}): void {
|
}): void {
|
||||||
if (!this.tssProcess || !this.tssProcess.stdin) {
|
// 消息去重检查
|
||||||
|
if (this.database && message.messageId) {
|
||||||
|
if (this.database.isMessageProcessed(message.messageId)) {
|
||||||
|
console.log(`[TSS] Skipping duplicate message: ${message.messageId.substring(0, 8)}...`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果进程未就绪,缓冲消息
|
||||||
|
if (!this.isProcessReady || !this.tssProcess || !this.tssProcess.stdin) {
|
||||||
|
if (this.isRunning) {
|
||||||
|
console.log(`[TSS] Buffering message from ${message.fromParty.substring(0, 8)}... (process not ready)`);
|
||||||
|
this.messageBuffer.push(message);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendMessageToProcess(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息给 TSS 进程(并标记为已处理)
|
||||||
|
*/
|
||||||
|
private sendMessageToProcess(message: {
|
||||||
|
messageId: string;
|
||||||
|
fromParty: string;
|
||||||
|
isBroadcast: boolean;
|
||||||
|
payload: Buffer;
|
||||||
|
}): void {
|
||||||
|
if (!this.tssProcess?.stdin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,6 +351,26 @@ export class TSSHandler extends EventEmitter {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.tssProcess.stdin.write(inputMessage + '\n');
|
this.tssProcess.stdin.write(inputMessage + '\n');
|
||||||
|
|
||||||
|
// 标记消息为已处理(防止重连后重复处理)
|
||||||
|
if (this.database && message.messageId && this.sessionId) {
|
||||||
|
this.database.markMessageProcessed(message.messageId, this.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送缓冲的消息
|
||||||
|
*/
|
||||||
|
private flushMessageBuffer(): void {
|
||||||
|
if (this.messageBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[TSS] Flushing ${this.messageBuffer.length} buffered messages`);
|
||||||
|
for (const msg of this.messageBuffer) {
|
||||||
|
this.sendMessageToProcess(msg);
|
||||||
|
}
|
||||||
|
this.messageBuffer = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -291,6 +382,8 @@ export class TSSHandler extends EventEmitter {
|
||||||
this.tssProcess = null;
|
this.tssProcess = null;
|
||||||
}
|
}
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
this.isProcessReady = false;
|
||||||
|
this.messageBuffer = [];
|
||||||
this.grpcClient.removeAllListeners('mpcMessage');
|
this.grpcClient.removeAllListeners('mpcMessage');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue