fix: add comprehensive error handling for P1 critical issues

P1-1: Callback query error handling
- Add try-catch around domain registration and server order
- Send user-friendly error messages on failure
- Use answerCallbackQuery to acknowledge button clicks
- Add structured logging with createLogger

P1-2: Queue DLQ monitoring
- Add admin notification when server provisioning fails
- Update order status to 'failed' in database
- Include detailed context in notifications
- Apply rate limiting (1 notification per hour)

P1-3: Email handler error recovery
- Add admin notification when SMS parsing fails
- Include email preview in notifications
- Mask email addresses for privacy
- Add structured logging with emailLogger

Also add 'failed' status to ServerOrder type.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 20:36:49 +09:00
parent 9fc6820b77
commit 97b8f3c7f7
4 changed files with 289 additions and 132 deletions

View File

@@ -9,6 +9,7 @@ import { reconcileDeposits, formatReconciliationReport } from './utils/reconcili
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision'; import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
import { timingSafeEqual } from './security'; import { timingSafeEqual } from './security';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { notifyAdmin } from './services/notification';
const logger = createLogger('worker'); const logger = createLogger('worker');
@@ -101,23 +102,50 @@ Documentation: https://github.com/your-repo
// Email 핸들러 (SMS → 메일 → 파싱) // Email 핸들러 (SMS → 메일 → 파싱)
async email(message: EmailMessage, env: Env): Promise<void> { async email(message: EmailMessage, env: Env): Promise<void> {
const emailLogger = createLogger('email-handler');
try { try {
// 이메일 본문 읽기 // 이메일 본문 읽기
const rawEmail = await new Response(message.raw).text(); const rawEmail = await new Response(message.raw).text();
// 이메일 주소 마스킹 // 이메일 주소 마스킹
const maskedFrom = message.from.replace(/@.+/, '@****'); const maskedFrom = message.from.replace(/@.+/, '@****');
logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize }); emailLogger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
// SMS 내용 파싱 // SMS 내용 파싱
const notification = await parseBankSMS(rawEmail, env); const notification = await parseBankSMS(rawEmail, env);
if (!notification) { if (!notification) {
logger.info('은행 SMS 파싱 실패'); // Structured logging with context
return; 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, bankName: notification.bankName,
depositorName: notification.depositorName depositorName: notification.depositorName
? notification.depositorName.slice(0, 2) + '***' ? notification.depositorName.slice(0, 2) + '***'
@@ -144,16 +172,16 @@ Documentation: https://github.com/your-repo
).run(); ).run();
const notificationId = insertResult.meta.last_row_id; const notificationId = insertResult.meta.last_row_id;
logger.info('알림 저장 완료', { notificationId }); emailLogger.info('알림 저장 완료', { notificationId });
// 자동 매칭 시도 // 자동 매칭 시도
const matched = await matchPendingDeposit(env.DB, notificationId, notification); const matched = await matchPendingDeposit(env.DB, notificationId, notification);
// 매칭 결과 로깅 (민감 정보 마스킹) // 매칭 결과 로깅 (민감 정보 마스킹)
if (matched) { if (matched) {
logger.info('자동 매칭 성공', { transactionId: matched.transactionId }); emailLogger.info('자동 매칭 성공', { transactionId: matched.transactionId });
} else { } else {
logger.info('매칭되는 거래 없음'); emailLogger.info('매칭되는 거래 없음');
} }
// 매칭 성공 시 사용자에게 알림 // 매칭 성공 시 사용자에게 알림
@@ -196,7 +224,11 @@ Documentation: https://github.com/your-repo
); );
} }
} catch (error) { } 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
} }
}, },

View File

@@ -1,8 +1,11 @@
import { answerCallbackQuery, editMessageText } from '../../telegram'; import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegram';
import { UserService } from '../../services/user-service'; import { UserService } from '../../services/user-service';
import { executeDomainRegister } from '../../domain-register'; import { executeDomainRegister } from '../../domain-register';
import { createLogger } from '../../utils/logger';
import type { Env, TelegramUpdate } from '../../types'; import type { Env, TelegramUpdate } from '../../types';
const logger = createLogger('callback-handler');
/** /**
* 도메인 형식 검증 정규식 * 도메인 형식 검증 정규식
* - 최소 2글자 이상 * - 최소 2글자 이상
@@ -75,6 +78,7 @@ export async function handleCallbackQuery(
`⏳ <b>${domain}</b> 등록 처리 중...` `⏳ <b>${domain}</b> 등록 처리 중...`
); );
try {
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
if (result.success) { if (result.success) {
@@ -108,6 +112,35 @@ ${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,
`❌ <b>처리 중 오류 발생</b>
도메인 등록 처리 중 예상치 못한 오류가 발생했습니다.
잠시 후 다시 시도해주세요.
문제가 계속되면 관리자에게 문의해주세요.`
);
// 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; return;
} }
@@ -156,6 +189,7 @@ ${result.error}
'⏳ 서버 주문 처리 중...' '⏳ 서버 주문 처리 중...'
); );
try {
// 세션 조회 // 세션 조회
const { getServerSession, deleteServerSession } = await import('../../server-agent'); const { getServerSession, deleteServerSession } = await import('../../server-agent');
@@ -259,6 +293,34 @@ ${result.error}
// 세션 삭제 // 세션 삭제
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,
`❌ <b>처리 중 오류 발생</b>
서버 주문 처리 중 예상치 못한 오류가 발생했습니다.
잠시 후 다시 시도해주세요.
문제가 계속되면 관리자에게 문의해주세요.`
);
// 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; return;
} }

View File

@@ -2,6 +2,7 @@ import type { Env, ProvisionMessage, MessageBatch, ServerOrder, ProvisionRespons
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
import { sendMessage } from './telegram'; import { sendMessage } from './telegram';
import { notifyAdmin as notifyAdminSystem } from './services/notification';
const logger = createLogger('server-provision'); const logger = createLogger('server-provision');
@@ -471,37 +472,73 @@ export async function handleProvisionQueue(
nextAttempt: message.attempts + 1 nextAttempt: message.attempts + 1
}); });
} else { } else {
// 최대 재시도 초과 - 주문 삭제 (잔액 차감 안 됐으므로) // 최대 재시도 초과 - DB에 실패 상태 기록 후 주문 삭제 (잔액 차감 안 됐으므로)
await deleteServerOrder(env.DB, order_id); 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( await notifyUser(
env.BOT_TOKEN, env.BOT_TOKEN,
telegram_user_id, 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(); message.ack();
logger.warn('최대 재시도 초과 - 주문 삭제', { logger.warn('최대 재시도 초과 - 주문 삭제', {
orderId: order_id, orderId: order_id,
attempts: message.attempts attempts: message.attempts,
errorMessage
}); });
} }
} }
} catch (error) { } catch (error) {
logger.error('프로비저닝 처리 중 예외 발생', error as Error, { logger.error('프로비저닝 처리 중 예외 발생', error as Error, {
orderId: message.body.order_id, orderId: message.body.order_id,
userId: message.body.user_id,
telegramUserId: message.body.telegram_user_id,
attempts: message.attempts attempts: message.attempts
}); });
// 예상치 못한 에러 - 재시도 // 예상치 못한 에러 - 재시도
if (message.attempts < 3) { if (message.attempts < 3) {
message.retry(); message.retry();
logger.info('예외 발생으로 재시도 예약', {
orderId: message.body.order_id,
nextAttempt: message.attempts + 1
});
} else { } else {
// DLQ로 이동 // DLQ로 이동 (handleProvisionDLQ에서 관리자 알림 처리)
message.ack(); message.ack();
logger.warn('최대 재시도 초과 - DLQ로 이동', { logger.warn('최대 재시도 초과 - DLQ로 이동', {
orderId: message.body.order_id, 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) { 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'), { logger.error('DLQ 메시지 처리', new Error('Provisioning failed permanently'), {
orderId: order_id, orderId: order_id,
userId: user_id, userId: user_id,
telegramUserId: telegram_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', { 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. 관리자에게 알림 // 3. 관리자에게 Rate Limiting이 적용된 알림 전송
const adminMessage = `🚨 서버 프로비저닝 영구 실패 (DLQ) 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} // 4. 사용자에게 안내
사용자 ID: ${user_id}
Telegram ID: ${telegram_user_id}
재시도 횟수: ${message.attempts}
수동 개입 필요 - 환불 및 사용자 안내 필요`;
await notifyAdmin(env.BOT_TOKEN, env.DEPOSIT_ADMIN_ID, adminMessage);
// 3. 사용자에게 안내
const userMessage = `❌ 서버 프로비저닝 실패 const userMessage = `❌ 서버 프로비저닝 실패
주문 #${order_id} 주문 #${order_id}
@@ -560,12 +610,25 @@ Telegram ID: ${telegram_user_id}
await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage); await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage);
message.ack(); message.ack();
logger.info('DLQ 메시지 처리 완료', { orderId: order_id });
} catch (error) { } catch (error) {
logger.error('DLQ 처리 중 오류', error as 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()하여 무한 루프 방지 // 메시지는 ack()하여 무한 루프 방지
message.ack(); message.ack();
} }

View File

@@ -641,7 +641,7 @@ export interface ServerOrder {
region: string; region: string;
label?: string; label?: string;
price_paid: number; price_paid: number;
status: 'pending' | 'provisioning' | 'active' | 'terminated'; status: 'pending' | 'provisioning' | 'active' | 'terminated' | 'failed';
provider: 'linode' | 'vultr' | 'anvil'; provider: 'linode' | 'vultr' | 'anvil';
instance_id?: string; instance_id?: string;
ip_address?: string; ip_address?: string;