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:
292
src/index.ts
292
src/index.ts
@@ -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만 하고 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user