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:
48
src/index.ts
48
src/index.ts
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user