feat: add server provisioning system with Queue

- Add server-provision.ts for async server creation
- Add SERVER_PROVISION_QUEUE with DLQ for reliability
- Add cron job for auto-cleanup of pending orders (5min)
- Add server delete confirmation with inline keyboard
- Update types for server orders, images, and provisioning
- Add server tables to schema (server_orders, server_instances)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 20:26:17 +09:00
parent d3b743c3c1
commit 5ba555864a
8 changed files with 1378 additions and 216 deletions

View File

@@ -1,4 +1,4 @@
import { Env, EmailMessage } from './types';
import { Env, EmailMessage, ProvisionMessage, MessageBatch } from './types';
import { sendMessage, setWebhook, getWebhookInfo } from './telegram';
import { handleWebhook } from './routes/webhook';
import { handleApiRequest } from './routes/api';
@@ -6,7 +6,11 @@ import { handleHealthCheck } from './routes/health';
import { parseBankSMS } from './services/bank-sms-parser';
import { matchPendingDeposit } from './services/deposit-matcher';
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
import { timingSafeEqual } from './security';
import { createLogger } from './utils/logger';
const logger = createLogger('worker');
export default {
// HTTP 요청 핸들러
@@ -103,17 +107,17 @@ Documentation: https://github.com/your-repo
// 이메일 주소 마스킹
const maskedFrom = message.from.replace(/@.+/, '@****');
console.log('[Email] 수신:', maskedFrom, 'Size:', message.rawSize);
logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
// SMS 내용 파싱
const notification = await parseBankSMS(rawEmail, env);
if (!notification) {
console.log('[Email] 은행 SMS 파싱 실패');
logger.info('은행 SMS 파싱 실패');
return;
}
// 파싱 결과 마스킹 로깅
console.log('[Email] 파싱 결과:', {
logger.info('SMS 파싱 결과', {
bankName: notification.bankName,
depositorName: notification.depositorName
? notification.depositorName.slice(0, 2) + '***'
@@ -140,16 +144,16 @@ Documentation: https://github.com/your-repo
).run();
const notificationId = insertResult.meta.last_row_id;
console.log('[Email] 알림 저장 완료, ID:', notificationId);
logger.info('알림 저장 완료', { notificationId });
// 자동 매칭 시도
const matched = await matchPendingDeposit(env.DB, notificationId, notification);
// 매칭 결과 로깅 (민감 정보 마스킹)
if (matched) {
console.log('[Email] 자동 매칭 성공: 거래 ID', matched.transactionId);
logger.info('자동 매칭 성공', { transactionId: matched.transactionId });
} else {
console.log('[Email] 매칭되는 거래 없음');
logger.info('매칭되는 거래 없음');
}
// 매칭 성공 시 사용자에게 알림
@@ -192,92 +196,210 @@ Documentation: https://github.com/your-repo
);
}
} catch (error) {
console.error('[Email] 처리 오류:', error);
logger.error('이메일 처리 오류', error as Error);
}
},
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간)
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
console.log('[Cron] 만료된 입금 대기 정리 시작');
// Cron Triggers: 입금 대기 자동 취소 (24시간) + 서버 주문 자동 삭제 (5분)
async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
const cronSchedule = event.cron;
logger.info('Cron 작업 시작', { schedule: cronSchedule });
try {
// 24시간 이상 된 pending 거래 조회
const expiredTxs = await env.DB.prepare(
`SELECT dt.id, dt.amount, dt.depositor_name, u.telegram_id
FROM deposit_transactions dt
JOIN users u ON dt.user_id = u.id
WHERE dt.status = 'pending'
AND dt.type = 'deposit'
AND datetime(dt.created_at) < datetime('now', '-1 day')
LIMIT 100`
).all<{
id: number;
amount: number;
depositor_name: string;
telegram_id: string;
}>();
if (!expiredTxs.results?.length) {
console.log('[Cron] 만료된 거래 없음');
return;
}
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`);
// 단일 UPDATE 쿼리로 일괄 처리
const ids = expiredTxs.results.map(tx => tx.id);
await env.DB.prepare(
`UPDATE deposit_transactions
SET status = 'cancelled', description = '입금 대기 만료 (24시간)'
WHERE id IN (${ids.map(() => '?').join(',')})`
).bind(...ids).run();
console.log(`[Cron] UPDATE 완료: ${ids.length}`);
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
const notificationPromises = expiredTxs.results.map(tx =>
sendMessage(
env.BOT_TOKEN,
parseInt(tx.telegram_id),
`⏰ <b>입금 대기 자동 취소</b>\n\n` +
`거래 #${tx.id}이 24시간 내 확인되지 않아 자동 취소되었습니다.\n` +
`• 입금액: ${tx.amount.toLocaleString()}\n` +
`• 입금자: ${tx.depositor_name}\n\n` +
`실제 입금하셨다면 다시 신고해주세요.`
).catch(err => {
console.error(`[Cron] 알림 전송 실패 (거래 #${tx.id}, 사용자 ${tx.telegram_id}):`, err);
return null; // 실패한 알림은 null로 처리
})
);
await Promise.all(notificationPromises);
console.log(`[Cron] ${expiredTxs.results.length}건 만료 처리 완료 (알림 전송 완료)`);
} catch (error) {
console.error('[Cron] 오류:', error);
// 매 5분: pending 서버 주문 자동 삭제
if (cronSchedule === '*/5 * * * *') {
await cleanupStalePendingServerOrders(env);
return;
}
// 예치금 정합성 검증 (Reconciliation)
console.log('[Cron] 예치금 정합성 검증 시작');
try {
const report = await reconcileDeposits(env.DB);
// 매일 자정 (KST): 입금 만료 + 정합성 검증
if (cronSchedule === '0 15 * * *') {
await cleanupExpiredDepositTransactions(env);
await reconcileDepositBalances(env);
return;
}
if (report.inconsistencies > 0) {
// 관리자 알림 전송
const adminId = env.DEPOSIT_ADMIN_ID;
if (adminId) {
const message = formatReconciliationReport(report);
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
console.error('[Cron] 정합성 검증 알림 전송 실패:', err);
});
} else {
console.warn('[Cron] DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
}
}
logger.warn('알 수 없는 Cron 스케줄', { schedule: cronSchedule });
},
console.log(`[Cron] 정합성 검증 완료: ${report.totalUsers}명 검증, ${report.inconsistencies}건 불일치`);
} catch (error) {
console.error('[Cron] 정합성 검증 실패:', error);
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
// Queue Consumer 핸들러
async queue(batch: MessageBatch<ProvisionMessage>, env: Env): Promise<void> {
// Queue 이름으로 구분
const queueName = batch.queue;
if (queueName === 'server-provision-queue') {
await handleProvisionQueue(batch, env);
} else if (queueName === 'provision-dlq') {
await handleProvisionDLQ(batch, env);
} else {
logger.warn('알 수 없는 Queue', { queue: queueName });
}
},
};
// ============================================================================
// Cron Job Helper Functions
// ============================================================================
/**
* 5분 이상 pending 상태인 서버 주문 자동 삭제
* 실행 주기: 매 5분 (every 5 minutes)
*/
async function cleanupStalePendingServerOrders(env: Env): Promise<void> {
logger.info('서버 주문 정리 시작 (5분 경과)');
try {
// 5분 이상 된 pending 서버 주문 조회
const staleOrders = await env.DB.prepare(
`SELECT so.id, so.label, so.price_paid, u.telegram_id
FROM server_orders so
JOIN users u ON so.user_id = u.id
WHERE so.status = 'pending'
AND datetime(so.created_at) < datetime('now', '-5 minutes')
LIMIT 50`
).all<{
id: number;
label: string | null;
price_paid: number;
telegram_id: string;
}>();
if (!staleOrders.results?.length) {
logger.info('삭제할 서버 주문 없음');
return;
}
logger.info('방치된 서버 주문 발견', { count: staleOrders.results.length });
// 서버 주문 삭제
const orderIds = staleOrders.results.map(order => order.id);
await env.DB.prepare(
`DELETE FROM server_orders WHERE id IN (${orderIds.map(() => '?').join(',')})`
).bind(...orderIds).run();
logger.info('서버 주문 삭제 완료', { count: orderIds.length });
// 사용자 알림 병렬 처리 (개별 실패 무시)
const notificationPromises = staleOrders.results.map(order =>
sendMessage(
env.BOT_TOKEN,
parseInt(order.telegram_id),
`❌ <b>서버 주문 자동 취소</b>\n\n` +
`주문 #${order.id}이 처리되지 않아 자동 취소되었습니다.\n` +
`• 서버명: ${order.label || '(미지정)'}\n` +
`• 결제 금액: ${order.price_paid.toLocaleString()}\n\n` +
`다시 시도해주세요.`
).catch(err => {
logger.error('알림 전송 실패', err as Error, {
orderId: order.id,
userId: order.telegram_id
});
return null;
})
);
await Promise.all(notificationPromises);
logger.info('서버 주문 정리 완료', { count: staleOrders.results.length });
} catch (error) {
logger.error('서버 주문 정리 오류', error as Error);
}
}
/**
* 24시간 이상 pending 상태인 입금 거래 자동 취소
* 실행 주기: 매일 자정 KST (0 15 * * *)
*/
async function cleanupExpiredDepositTransactions(env: Env): Promise<void> {
logger.info('만료된 입금 대기 정리 시작');
try {
// 24시간 이상 된 pending 거래 조회
const expiredTxs = await env.DB.prepare(
`SELECT dt.id, dt.amount, dt.depositor_name, u.telegram_id
FROM deposit_transactions dt
JOIN users u ON dt.user_id = u.id
WHERE dt.status = 'pending'
AND dt.type = 'deposit'
AND datetime(dt.created_at) < datetime('now', '-1 day')
LIMIT 100`
).all<{
id: number;
amount: number;
depositor_name: string;
telegram_id: string;
}>();
if (!expiredTxs.results?.length) {
logger.info('만료된 거래 없음');
return;
}
logger.info('만료된 거래 발견', { count: expiredTxs.results.length });
// 단일 UPDATE 쿼리로 일괄 처리
const ids = expiredTxs.results.map(tx => tx.id);
await env.DB.prepare(
`UPDATE deposit_transactions
SET status = 'cancelled', description = '입금 대기 만료 (24시간)'
WHERE id IN (${ids.map(() => '?').join(',')})`
).bind(...ids).run();
logger.info('UPDATE 완료', { count: ids.length });
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
const notificationPromises = expiredTxs.results.map(tx =>
sendMessage(
env.BOT_TOKEN,
parseInt(tx.telegram_id),
`⏰ <b>입금 대기 자동 취소</b>\n\n` +
`거래 #${tx.id}이 24시간 내 확인되지 않아 자동 취소되었습니다.\n` +
`• 입금액: ${tx.amount.toLocaleString()}\n` +
`• 입금자: ${tx.depositor_name}\n\n` +
`실제 입금하셨다면 다시 신고해주세요.`
).catch(err => {
logger.error('알림 전송 실패', err as Error, {
transactionId: tx.id,
userId: tx.telegram_id
});
return null;
})
);
await Promise.all(notificationPromises);
logger.info('만료 처리 완료', { count: expiredTxs.results.length });
} catch (error) {
logger.error('Cron 작업 오류', error as Error);
}
}
/**
* 예치금 정합성 검증 (Reconciliation)
* 실행 주기: 매일 자정 KST (0 15 * * *)
*/
async function reconcileDepositBalances(env: Env): Promise<void> {
logger.info('예치금 정합성 검증 시작');
try {
const report = await reconcileDeposits(env.DB);
if (report.inconsistencies > 0) {
// 관리자 알림 전송
const adminId = env.DEPOSIT_ADMIN_ID;
if (adminId) {
const message = formatReconciliationReport(report);
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
logger.error('정합성 검증 알림 전송 실패', err as Error);
});
} else {
logger.warn('DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
}
}
logger.info('정합성 검증 완료', {
totalUsers: report.totalUsers,
inconsistencies: report.inconsistencies
});
} catch (error) {
logger.error('정합성 검증 실패', error as Error);
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
}
}