diff --git a/src/index.ts b/src/index.ts index 5fbdcd6..a824689 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { reconcileDeposits, formatReconciliationReport } from './utils/reconcili import { handleProvisionQueue, handleProvisionDLQ } from './server-provision'; import { timingSafeEqual } from './security'; import { createLogger } from './utils/logger'; +import { notifyAdmin } from './services/notification'; const logger = createLogger('worker'); @@ -101,23 +102,50 @@ Documentation: https://github.com/your-repo // Email 핸들러 (SMS → 메일 → 파싱) async email(message: EmailMessage, env: Env): Promise { + const emailLogger = createLogger('email-handler'); + try { // 이메일 본문 읽기 const rawEmail = await new Response(message.raw).text(); // 이메일 주소 마스킹 const maskedFrom = message.from.replace(/@.+/, '@****'); - logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize }); + emailLogger.info('이메일 수신', { from: maskedFrom, size: message.rawSize }); // SMS 내용 파싱 const notification = await parseBankSMS(rawEmail, env); if (!notification) { - logger.info('은행 SMS 파싱 실패'); - return; + // Structured logging with context + emailLogger.warn('SMS 파싱 실패', { + from: maskedFrom, + subject: message.headers.get('subject') || 'N/A', + preview: rawEmail.substring(0, 200).replace(/\s+/g, ' '), + size: message.rawSize + }); + + // Admin notification for manual review + await notifyAdmin( + 'api_error', + { + service: 'Email Handler', + error: 'SMS parsing failed', + context: `From: ${maskedFrom}\nSubject: ${message.headers.get('subject') || 'N/A'}\nPreview: ${rawEmail.substring(0, 150).replace(/\s+/g, ' ')}...` + }, + { + telegram: { + sendMessage: (chatId: number, text: string) => + sendMessage(env.BOT_TOKEN, chatId, text) + }, + adminId: env.DEPOSIT_ADMIN_ID || '', + env + } + ); + + return; // Don't throw - email routing expects success } // 파싱 결과 마스킹 로깅 - logger.info('SMS 파싱 결과', { + emailLogger.info('SMS 파싱 결과', { bankName: notification.bankName, depositorName: notification.depositorName ? notification.depositorName.slice(0, 2) + '***' @@ -144,16 +172,16 @@ Documentation: https://github.com/your-repo ).run(); const notificationId = insertResult.meta.last_row_id; - logger.info('알림 저장 완료', { notificationId }); + emailLogger.info('알림 저장 완료', { notificationId }); // 자동 매칭 시도 const matched = await matchPendingDeposit(env.DB, notificationId, notification); // 매칭 결과 로깅 (민감 정보 마스킹) if (matched) { - logger.info('자동 매칭 성공', { transactionId: matched.transactionId }); + emailLogger.info('자동 매칭 성공', { transactionId: matched.transactionId }); } else { - logger.info('매칭되는 거래 없음'); + emailLogger.info('매칭되는 거래 없음'); } // 매칭 성공 시 사용자에게 알림 @@ -196,7 +224,11 @@ Documentation: https://github.com/your-repo ); } } catch (error) { - logger.error('이메일 처리 오류', error as Error); + emailLogger.error('이메일 처리 오류', error as Error, { + from: message.from.replace(/@.+/, '@****'), + size: message.rawSize + }); + // Don't rethrow - email routing expects success response } }, diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts index a22aca6..b4e6b1d 100644 --- a/src/routes/handlers/callback-handler.ts +++ b/src/routes/handlers/callback-handler.ts @@ -1,8 +1,11 @@ -import { answerCallbackQuery, editMessageText } from '../../telegram'; +import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegram'; import { UserService } from '../../services/user-service'; import { executeDomainRegister } from '../../domain-register'; +import { createLogger } from '../../utils/logger'; import type { Env, TelegramUpdate } from '../../types'; +const logger = createLogger('callback-handler'); + /** * 도메인 형식 검증 정규식 * - 최소 2글자 이상 @@ -75,19 +78,20 @@ export async function handleCallbackQuery( `⏳ ${domain} 등록 처리 중...` ); - const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); + try { + const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); - if (result.success) { - const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : ''; - const nsInfo = result.nameservers && result.nameservers.length > 0 - ? `\n\n🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` - : ''; + if (result.success) { + const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : ''; + const nsInfo = result.nameservers && result.nameservers.length > 0 + ? `\n\n🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` + : ''; - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `✅ 도메인 등록 완료! + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `✅ 도메인 등록 완료! • 도메인: ${result.domain} • 결제 금액: ${result.price?.toLocaleString()}원 @@ -95,18 +99,47 @@ export async function handleCallbackQuery( 🎉 축하합니다! 도메인이 성공적으로 등록되었습니다. 네임서버 변경이 필요하면 말씀해주세요.` - ); - } else { - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `❌ 등록 실패 + ); + } else { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 등록 실패 ${result.error} 다시 시도하시려면 도메인 등록을 요청해주세요.` + ); + } + } catch (error) { + logger.error('도메인 등록 처리 실패', error as Error, { + domain, + price, + userId: user.id, + telegramUserId + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' }); + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 처리 중 오류 발생 + +도메인 등록 처리 중 예상치 못한 오류가 발생했습니다. +잠시 후 다시 시도해주세요. + +문제가 계속되면 관리자에게 문의해주세요.` ); + + // Fallback: send as new message if editMessageText fails + await sendMessage( + env.BOT_TOKEN, + chatId, + '❌ 도메인 등록 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ).catch(e => logger.error('Fallback message send failed', e as Error)); } return; } @@ -156,98 +189,99 @@ ${result.error} '⏳ 서버 주문 처리 중...' ); - // 세션 조회 - const { getServerSession, deleteServerSession } = await import('../../server-agent'); + try { + // 세션 조회 + const { getServerSession, deleteServerSession } = await import('../../server-agent'); - if (!env.DB) { - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '❌ 세션 저장소가 설정되지 않았습니다.' - ); - return; - } + if (!env.DB) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 세션 저장소가 설정되지 않았습니다.' + ); + return; + } - // Use verified telegramUserId instead of callback userId - const session = await getServerSession(env.DB, telegramUserId); + // Use verified telegramUserId instead of callback userId + const session = await getServerSession(env.DB, telegramUserId); - if (!session || !session.lastRecommendation) { - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.' - ); - return; - } + if (!session || !session.lastRecommendation) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.' + ); + return; + } - const selected = session.lastRecommendation.recommendations[index]; + const selected = session.lastRecommendation.recommendations[index]; - if (!selected) { - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - '❌ 선택한 서버를 찾을 수 없습니다.' - ); - await deleteServerSession(env.DB, telegramUserId); - return; - } + if (!selected) { + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + '❌ 선택한 서버를 찾을 수 없습니다.' + ); + await deleteServerSession(env.DB, telegramUserId); + return; + } - // 잔액 확인 - const deposit = await env.DB.prepare( - 'SELECT balance FROM user_deposits WHERE user_id = ?' - ).bind(user.id).first<{ balance: number }>(); + // 잔액 확인 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ balance: number }>(); - const price = selected.price?.monthly_krw || 0; + const price = selected.price?.monthly_krw || 0; - if (!deposit || deposit.balance < price) { - await editMessageText( - env.BOT_TOKEN, chatId, messageId, - `❌ 잔액이 부족합니다. + if (!deposit || deposit.balance < price) { + await editMessageText( + env.BOT_TOKEN, chatId, messageId, + `❌ 잔액이 부족합니다. • 서버 가격: ${price.toLocaleString()}원/월 • 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원 • 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원 잔액을 충전 후 다시 시도해주세요.` - ); - return; - } + ); + return; + } - // Queue 확인 - if (!env.SERVER_PROVISION_QUEUE) { + // Queue 확인 + if (!env.SERVER_PROVISION_QUEUE) { + await editMessageText( + env.BOT_TOKEN, chatId, messageId, + '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.' + ); + return; + } + + // 주문 생성 (DB INSERT) + const { createServerOrder, sendProvisionMessage } = await import('../../server-provision'); + + const orderId = await createServerOrder( + env.DB, + user.id, + telegramUserId, + selected.pricing_id, + selected.region.code, + 'anvil', + price, + `${selected.plan_name} - ${session.collectedInfo?.useCase || 'server'}` + ); + + // Queue에 메시지 전송 + await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, user.id, telegramUserId); + + // 즉시 응답 await editMessageText( - env.BOT_TOKEN, chatId, messageId, - '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.' - ); - return; - } - - // 주문 생성 (DB INSERT) - const { createServerOrder, sendProvisionMessage } = await import('../../server-provision'); - - const orderId = await createServerOrder( - env.DB, - user.id, - telegramUserId, - selected.pricing_id, - selected.region.code, - 'anvil', - price, - `${selected.plan_name} - ${session.collectedInfo?.useCase || 'server'}` - ); - - // Queue에 메시지 전송 - await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, user.id, telegramUserId); - - // 즉시 응답 - await editMessageText( - env.BOT_TOKEN, - chatId, - messageId, - `📋 서버 주문 접수 완료! (주문 #${orderId}) + env.BOT_TOKEN, + chatId, + messageId, + `📋 서버 주문 접수 완료! (주문 #${orderId}) • 서버: ${selected.plan_name} • 리전: ${selected.region.name} (${selected.region.code}) @@ -255,10 +289,38 @@ ${result.error} ⏳ 서버를 생성하고 있습니다... (1-2분 소요) 완료되면 메시지로 알려드릴게요.` - ); + ); - // 세션 삭제 - await deleteServerSession(env.DB, telegramUserId); + // 세션 삭제 + await deleteServerSession(env.DB, telegramUserId); + } catch (error) { + logger.error('서버 주문 처리 실패', error as Error, { + index, + userId: user.id, + telegramUserId + }); + + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' }); + + await editMessageText( + env.BOT_TOKEN, + chatId, + messageId, + `❌ 처리 중 오류 발생 + +서버 주문 처리 중 예상치 못한 오류가 발생했습니다. +잠시 후 다시 시도해주세요. + +문제가 계속되면 관리자에게 문의해주세요.` + ); + + // Fallback: send as new message if editMessageText fails + await sendMessage( + env.BOT_TOKEN, + chatId, + '❌ 서버 주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + ).catch(e => logger.error('Fallback message send failed', e as Error)); + } return; } diff --git a/src/server-provision.ts b/src/server-provision.ts index a3c056b..64b5120 100644 --- a/src/server-provision.ts +++ b/src/server-provision.ts @@ -2,6 +2,7 @@ import type { Env, ProvisionMessage, MessageBatch, ServerOrder, ProvisionRespons import { createLogger } from './utils/logger'; import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; import { sendMessage } from './telegram'; +import { notifyAdmin as notifyAdminSystem } from './services/notification'; const logger = createLogger('server-provision'); @@ -471,37 +472,73 @@ export async function handleProvisionQueue( nextAttempt: message.attempts + 1 }); } else { - // 최대 재시도 초과 - 주문 삭제 (잔액 차감 안 됐으므로) - await deleteServerOrder(env.DB, order_id); + // 최대 재시도 초과 - DB에 실패 상태 기록 후 주문 삭제 (잔액 차감 안 됐으므로) + const errorMessage = error instanceof Error ? error.message : 'API 호출 실패'; + // DB에 실패 상태 기록 (삭제 전) + await updateOrderStatus(env.DB, order_id, 'failed', { + error_message: `${errorMessage} (max retries: ${message.attempts})` + }); + + // 관리자 알림 (Rate Limiting 적용) + await notifyAdminSystem( + 'retry_exhausted', + { + service: 'Server Provisioning', + error: errorMessage, + context: `Order #${order_id} failed after ${message.attempts} attempts\nUser: ${telegram_user_id}\n\n주문 삭제됨 (잔액 차감 안 됨)` + }, + { + telegram: { + sendMessage: (chatId: number, text: string) => sendMessage(env.BOT_TOKEN, chatId, text) + }, + adminId: env.DEPOSIT_ADMIN_ID || '', + env + } + ); + + // 사용자 알림 await notifyUser( env.BOT_TOKEN, telegram_user_id, - `❌ 서버 프로비저닝 실패\n\n사유: ${error instanceof Error ? error.message : 'API 호출 실패'}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.` + `❌ 서버 프로비저닝 실패\n\n사유: ${errorMessage}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.` ); + // 주문 삭제 + await deleteServerOrder(env.DB, order_id); + message.ack(); logger.warn('최대 재시도 초과 - 주문 삭제', { orderId: order_id, - attempts: message.attempts + attempts: message.attempts, + errorMessage }); } } } catch (error) { logger.error('프로비저닝 처리 중 예외 발생', error as Error, { orderId: message.body.order_id, + userId: message.body.user_id, + telegramUserId: message.body.telegram_user_id, attempts: message.attempts }); // 예상치 못한 에러 - 재시도 if (message.attempts < 3) { message.retry(); + logger.info('예외 발생으로 재시도 예약', { + orderId: message.body.order_id, + nextAttempt: message.attempts + 1 + }); } else { - // DLQ로 이동 + // DLQ로 이동 (handleProvisionDLQ에서 관리자 알림 처리) message.ack(); logger.warn('최대 재시도 초과 - DLQ로 이동', { orderId: message.body.order_id, - attempts: message.attempts + userId: message.body.user_id, + telegramUserId: message.body.telegram_user_id, + attempts: message.attempts, + errorMessage: error instanceof Error ? error.message : 'Unknown error' }); } } @@ -520,34 +557,47 @@ export async function handleProvisionDLQ( }); for (const message of batch.messages) { - try { - const { order_id, user_id, telegram_user_id } = message.body; + const { order_id, user_id, telegram_user_id } = message.body; + try { logger.error('DLQ 메시지 처리', new Error('Provisioning failed permanently'), { orderId: order_id, userId: user_id, telegramUserId: telegram_user_id, - attempts: message.attempts + attempts: message.attempts, + timestamp: message.body.timestamp, + retryCount: message.body.retry_count }); - // 1. DB에 실패 상태 기록 + // 1. 주문 정보 조회 (상세 에러 정보 수집) + const order = await getServerOrder(env.DB, order_id); + const errorContext = order + ? `Spec: ${order.spec_id}, Region: ${order.region}, Provider: ${order.provider}, Price: ${order.price_paid}` + : `Order not found in DB`; + + // 2. DB에 실패 상태 기록 await updateOrderStatus(env.DB, order_id, 'failed', { - error_message: 'Provisioning failed after maximum retries (moved to DLQ)' + error_message: `Provisioning failed after ${message.attempts} attempts (moved to DLQ at ${new Date().toISOString()})` }); - // 2. 관리자에게 알림 - const adminMessage = `🚨 서버 프로비저닝 영구 실패 (DLQ) + // 3. 관리자에게 Rate Limiting이 적용된 알림 전송 + await notifyAdminSystem( + 'api_error', + { + service: 'Server Provisioning', + error: `Order #${order_id} failed after ${message.attempts} retries`, + context: `User: ${telegram_user_id}\n${errorContext}\nTimestamp: ${new Date(message.body.timestamp).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' })}\n\n수동 개입 필요 - 환불 및 사용자 안내 필요` + }, + { + telegram: { + sendMessage: (chatId: number, text: string) => sendMessage(env.BOT_TOKEN, chatId, text) + }, + adminId: env.DEPOSIT_ADMIN_ID || '', + env + } + ); -주문 ID: ${order_id} -사용자 ID: ${user_id} -Telegram ID: ${telegram_user_id} -재시도 횟수: ${message.attempts} - -수동 개입 필요 - 환불 및 사용자 안내 필요`; - - await notifyAdmin(env.BOT_TOKEN, env.DEPOSIT_ADMIN_ID, adminMessage); - - // 3. 사용자에게 안내 + // 4. 사용자에게 안내 const userMessage = `❌ 서버 프로비저닝 실패 주문 #${order_id} @@ -560,12 +610,25 @@ Telegram ID: ${telegram_user_id} await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage); message.ack(); + logger.info('DLQ 메시지 처리 완료', { orderId: order_id }); } catch (error) { logger.error('DLQ 처리 중 오류', error as Error, { - orderId: message.body.order_id + orderId: order_id, + userId: user_id, + telegramUserId: telegram_user_id }); - // DLQ 처리 실패는 심각한 문제이므로 수동 개입 필요 + // DLQ 처리 실패는 심각한 문제이므로 긴급 알림 (Rate Limiting 무시) + try { + await notifyAdmin( + env.BOT_TOKEN, + env.DEPOSIT_ADMIN_ID, + `🚨 긴급: DLQ 처리 실패\n\n주문 #${order_id}\n사용자: ${telegram_user_id}\n\n수동 처리 필수!` + ); + } catch (notifyError) { + logger.error('긴급 알림 전송 실패', notifyError as Error); + } + // 메시지는 ack()하여 무한 루프 방지 message.ack(); } diff --git a/src/types.ts b/src/types.ts index 717a9d9..344cda9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -641,7 +641,7 @@ export interface ServerOrder { region: string; label?: string; price_paid: number; - status: 'pending' | 'provisioning' | 'active' | 'terminated'; + status: 'pending' | 'provisioning' | 'active' | 'terminated' | 'failed'; provider: 'linode' | 'vultr' | 'anvil'; instance_id?: string; ip_address?: string;