diff --git a/schema.sql b/schema.sql index bd202b3..2f2a3cf 100644 --- a/schema.sql +++ b/schema.sql @@ -86,6 +86,59 @@ CREATE TABLE IF NOT EXISTS deposit_transactions ( FOREIGN KEY (user_id) REFERENCES users(id) ); +-- 서버 상담 세션 테이블 +CREATE TABLE IF NOT EXISTS server_sessions ( + user_id TEXT PRIMARY KEY, + status TEXT NOT NULL CHECK(status IN ('gathering', 'recommending', 'selecting', 'ordering', 'completed')), + collected_info TEXT, + last_recommendation TEXT, + messages TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +-- 서버 주문 테이블 +CREATE TABLE IF NOT EXISTS server_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + telegram_user_id TEXT NOT NULL, + spec_id INTEGER NOT NULL, + region TEXT NOT NULL, + label TEXT, + price_paid INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'provisioning', 'active', 'failed', 'cancelled', 'terminated')), + provider TEXT NOT NULL CHECK(provider IN ('linode', 'vultr', 'anvil')), + provider_instance_id TEXT, + ip_address TEXT, + root_password TEXT, + error_message TEXT, + provisioned_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + idempotency_key TEXT UNIQUE, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- 사용자 서버 테이블 (활성 서버 관리) +CREATE TABLE IF NOT EXISTS user_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + order_id INTEGER NOT NULL UNIQUE, + provider TEXT NOT NULL, + instance_id TEXT NOT NULL, + label TEXT, + ip_address TEXT, + region TEXT, + spec_label TEXT, + monthly_price INTEGER, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'stopped', 'terminated')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (order_id) REFERENCES server_orders(id) +); + -- 인덱스 CREATE INDEX IF NOT EXISTS idx_user_domains_user ON user_domains(user_id); CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain); @@ -100,3 +153,9 @@ CREATE INDEX IF NOT EXISTS idx_buffer_chat ON message_buffer(user_id, chat_id); CREATE INDEX IF NOT EXISTS idx_summary_user ON summaries(user_id, chat_id); CREATE INDEX IF NOT EXISTS idx_summary_latest ON summaries(user_id, chat_id, generation DESC); CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id); +CREATE INDEX IF NOT EXISTS idx_server_sessions_expires ON server_sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_server_orders_user ON server_orders(user_id); +CREATE INDEX IF NOT EXISTS idx_server_orders_status ON server_orders(status); +CREATE INDEX IF NOT EXISTS idx_server_orders_idempotency ON server_orders(idempotency_key) WHERE idempotency_key IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_user_servers_user ON user_servers(user_id); +CREATE INDEX IF NOT EXISTS idx_user_servers_status ON user_servers(status); diff --git a/src/index.ts b/src/index.ts index 45dc02d..5fbdcd6 100644 --- a/src/index.ts +++ b/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 { - console.log('[Cron] 만료된 입금 대기 정리 시작'); + // Cron Triggers: 입금 대기 자동 취소 (24시간) + 서버 주문 자동 삭제 (5분) + async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise { + 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), - `⏰ 입금 대기 자동 취소\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, env: Env): Promise { + // 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 { + 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), + `❌ 서버 주문 자동 취소\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 { + 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), + `⏰ 입금 대기 자동 취소\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 { + 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만 하고 계속 진행 + } +} diff --git a/src/routes/handlers/callback-handler.ts b/src/routes/handlers/callback-handler.ts index 4954092..a22aca6 100644 --- a/src/routes/handlers/callback-handler.ts +++ b/src/routes/handlers/callback-handler.ts @@ -63,6 +63,10 @@ export async function handleCallbackQuery( return; } + // SECURITY NOTE: Price from callback_data is range-validated here (0-10M) + // but real-time price verification happens in executeDomainRegister() + // which fetches current price from Namecheap API before charging + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' }); await editMessageText( env.BOT_TOKEN, @@ -127,9 +131,18 @@ ${result.error} return; } - const userId = parts[1]; + const callbackUserId = parts[1]; const index = parseInt(parts[2], 10); + // SECURITY: Verify callback userId matches the actual user + if (callbackUserId !== telegramUserId) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { + text: '⚠️ 권한이 없습니다.', + show_alert: true + }); + return; + } + if (isNaN(index) || index < 0 || index > 2) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' }); return; @@ -146,7 +159,7 @@ ${result.error} // 세션 조회 const { getServerSession, deleteServerSession } = await import('../../server-agent'); - if (!env.SESSION_KV) { + if (!env.DB) { await editMessageText( env.BOT_TOKEN, chatId, @@ -156,7 +169,8 @@ ${result.error} return; } - const session = await getServerSession(env.SESSION_KV, userId); + // Use verified telegramUserId instead of callback userId + const session = await getServerSession(env.DB, telegramUserId); if (!session || !session.lastRecommendation) { await editMessageText( @@ -177,33 +191,74 @@ ${result.error} messageId, '❌ 선택한 서버를 찾을 수 없습니다.' ); - await deleteServerSession(env.SESSION_KV, userId); + await deleteServerSession(env.DB, telegramUserId); return; } - // 주문 처리 (현재는 준비 중) - const { executeServerAction } = await import('../../tools/server-tool'); + // 잔액 확인 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ balance: number }>(); - const result = await executeServerAction( - 'order', - { - server_id: selected.plan_name, // 임시 - region_code: selected.region.code, - label: `${session.collectedInfo.useCase || 'server'}-1` - }, - env, - userId + const price = selected.price?.monthly_krw || 0; + + if (!deposit || deposit.balance < price) { + await editMessageText( + env.BOT_TOKEN, chatId, messageId, + `❌ 잔액이 부족합니다. + +• 서버 가격: ${price.toLocaleString()}원/월 +• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원 +• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원 + +잔액을 충전 후 다시 시도해주세요.` + ); + return; + } + + // 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, - `📋 ${selected.plan_name} 신청\n\n${result}` + `📋 서버 주문 접수 완료! (주문 #${orderId}) + +• 서버: ${selected.plan_name} +• 리전: ${selected.region.name} (${selected.region.code}) +• 가격: ${price.toLocaleString()}원/월 + +⏳ 서버를 생성하고 있습니다... (1-2분 소요) +완료되면 메시지로 알려드릴게요.` ); // 세션 삭제 - await deleteServerSession(env.SESSION_KV, userId); + await deleteServerSession(env.DB, telegramUserId); return; } @@ -215,7 +270,16 @@ ${result.error} return; } - const userId = parts[1]; + const callbackUserId = parts[1]; + + // SECURITY: Verify callback userId matches the actual user + if (callbackUserId !== telegramUserId) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { + text: '⚠️ 권한이 없습니다.', + show_alert: true + }); + return; + } await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); await editMessageText( @@ -228,13 +292,15 @@ ${result.error} // 세션 삭제 const { deleteServerSession } = await import('../../server-agent'); - if (env.SESSION_KV) { - await deleteServerSession(env.SESSION_KV, userId); + if (env.DB) { + await deleteServerSession(env.DB, telegramUserId); } return; } + // Note: server_delete callback handler removed - now using text-based confirmation + // 알 수 없는 callback data await answerCallbackQuery(env.BOT_TOKEN, queryId); } diff --git a/src/routes/handlers/message-handler.ts b/src/routes/handlers/message-handler.ts index 92af514..51f40c2 100644 --- a/src/routes/handlers/message-handler.ts +++ b/src/routes/handlers/message-handler.ts @@ -53,8 +53,167 @@ export async function handleMessage( return; } + // 4. Session 데이터 미리 읽기 (KV 중복 호출 방지) + const deleteSessionKey = `delete_confirm:${telegramUserId}`; + const orderSessionKey = `server_order_confirm:${telegramUserId}`; + + const [deleteSessionData, orderSessionData] = await Promise.all([ + env.SESSION_KV.get(deleteSessionKey), + env.SESSION_KV.get(orderSessionKey), + ]); + try { - // 4. 명령어 처리 + // 5. 서버 삭제 확인 처리 (텍스트 기반) + if (text.trim() === '삭제') { + + if (deleteSessionData) { + try { + const { orderId } = JSON.parse(deleteSessionData); + + // Import and execute server deletion + const { executeServerDelete } = await import('../../tools/server-tool'); + const result = await executeServerDelete(orderId, telegramUserId, env); + + // Delete session after execution + await env.SESSION_KV.delete(deleteSessionKey); + + await sendMessage(env.BOT_TOKEN, chatId, result.message); + return; + } catch (error) { + console.error('[handleMessage] 서버 삭제 처리 오류:', error); + await sendMessage( + env.BOT_TOKEN, + chatId, + '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.' + ); + return; + } + } + } + + // 6. 서버 삭제 취소 처리 (다른 메시지 입력 시) + if (deleteSessionData && text.trim() !== '삭제') { + try { + const { label } = JSON.parse(deleteSessionData); + await env.SESSION_KV.delete(deleteSessionKey); + + // Don't show cancellation message if it's a command (let command handler process it) + if (!text.startsWith('/')) { + await sendMessage( + env.BOT_TOKEN, + chatId, + `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}` + ); + return; + } + } catch (error) { + console.error('[handleMessage] 삭제 세션 취소 오류:', error); + } + } + + // 7. 서버 신청 확인 처리 (텍스트 기반) - Queue 기반 + if (text.trim() === '신청') { + if (orderSessionData) { + try { + const orderData = JSON.parse(orderSessionData); + + // 1. 서버 세션에서 가격 정보 가져오기 + const { getServerSession, deleteServerSession } = await import('../../server-agent'); + const session = await getServerSession(env.DB, telegramUserId); + + if (!session || !session.lastRecommendation) { + await env.SESSION_KV.delete(orderSessionKey); + await sendMessage( + env.BOT_TOKEN, + chatId, + '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.' + ); + return; + } + + const selected = session.lastRecommendation.recommendations[orderData.index]; + if (!selected) { + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + await sendMessage(env.BOT_TOKEN, chatId, '❌ 선택한 서버를 찾을 수 없습니다.'); + return; + } + + const price = selected.price?.monthly_krw || 0; + + // 2. 잔액 확인 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number }>(); + + if (!deposit || deposit.balance < price) { + await sendMessage( + env.BOT_TOKEN, + chatId, + `❌ 잔액이 부족합니다.\n\n` + + `• 서버 가격: ${price.toLocaleString()}원/월\n` + + `• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` + + `• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` + + `잔액을 충전 후 다시 시도해주세요.` + ); + return; + } + + // 3. Queue 확인 + if (!env.SERVER_PROVISION_QUEUE) { + await sendMessage( + env.BOT_TOKEN, + chatId, + '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.' + ); + return; + } + + // 4. 주문 생성 및 Queue 전송 + const { createServerOrder, sendProvisionMessage } = await import('../../server-provision'); + + const orderId = await createServerOrder( + env.DB, + userId, + telegramUserId, + selected.pricing_id, + selected.region.code, + 'anvil', + price, + `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` + ); + + await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); + + // 5. 세션 정리 + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + + // 6. 즉시 응답 + await sendMessage( + env.BOT_TOKEN, + chatId, + `📋 서버 주문 접수 완료! (주문 #${orderId})\n\n` + + `• 서버: ${selected.plan_name}\n` + + `• 리전: ${selected.region.name} (${selected.region.code})\n` + + `• 가격: ${price.toLocaleString()}원/월\n\n` + + `⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` + + `완료되면 메시지로 알려드릴게요.` + ); + return; + } catch (error) { + console.error('[handleMessage] 서버 신청 처리 오류:', error); + await sendMessage( + env.BOT_TOKEN, + chatId, + '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.' + ); + return; + } + } + } + + // 8. 명령어 처리 if (text.startsWith('/')) { const [command, ...argParts] = text.split(' '); const args = argParts.join(' '); @@ -74,7 +233,7 @@ export async function handleMessage( return; } - // 5. 일반 대화 처리 (ConversationService 위임) + // 9. 일반 대화 처리 (ConversationService 위임) const result = await conversationService.processUserMessage( userId, chatIdStr, @@ -87,7 +246,7 @@ export async function handleMessage( finalResponse += '\n\n👤 프로필이 업데이트되었습니다.'; } - // 6. 응답 전송 (키보드 포함 여부 확인) + // 10. 응답 전송 (키보드 포함 여부 확인) if (result.keyboardData) { console.log('[Webhook] Keyboard data received:', result.keyboardData.type); if (result.keyboardData.type === 'domain_register') { diff --git a/src/server-provision.ts b/src/server-provision.ts new file mode 100644 index 0000000..a3c056b --- /dev/null +++ b/src/server-provision.ts @@ -0,0 +1,573 @@ +import type { Env, ProvisionMessage, MessageBatch, ServerOrder, ProvisionResponse } from './types'; +import { createLogger } from './utils/logger'; +import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; +import { sendMessage } from './telegram'; + +const logger = createLogger('server-provision'); + +/** + * DB에서 서버 주문 정보 조회 + */ +async function getServerOrder( + db: D1Database, + orderId: number +): Promise { + const order = await db.prepare( + `SELECT * FROM server_orders WHERE id = ?` + ).bind(orderId).first(); + + return order || null; +} + +/** + * DB에 서버 주문 상태 업데이트 + * @param db - D1 Database + * @param orderId - 주문 ID + * @param status - 업데이트할 상태 + * @param updates - 추가 업데이트 필드 (선택) + */ +async function updateOrderStatus( + db: D1Database, + orderId: number, + status: ServerOrder['status'], + updates?: { + provider_instance_id?: string; + ip_address?: string; + root_password?: string; + error_message?: string; + provisioned_at?: string; + } +): Promise { + const fields: string[] = ['status = ?', 'updated_at = CURRENT_TIMESTAMP']; + const values: (string | number)[] = [status]; + + if (updates) { + if (updates.provider_instance_id) { + fields.push('provider_instance_id = ?'); + values.push(updates.provider_instance_id); + } + if (updates.ip_address) { + fields.push('ip_address = ?'); + values.push(updates.ip_address); + } + if (updates.root_password) { + fields.push('root_password = ?'); + values.push(updates.root_password); + } + if (updates.error_message) { + fields.push('error_message = ?'); + values.push(updates.error_message); + } + if (updates.provisioned_at) { + fields.push('provisioned_at = ?'); + values.push(updates.provisioned_at); + } + } + + const result = await db.prepare( + `UPDATE server_orders SET ${fields.join(', ')} WHERE id = ?` + ).bind(...values, orderId).run(); + + if (!result.success) { + throw new Error(`Failed to update order status: ${orderId}`); + } + + logger.info('서버 주문 상태 업데이트', { + orderId, + status, + updates + }); +} + +/** + * 서버 주문 삭제 (실패한 주문 정리용) + */ +async function deleteServerOrder(db: D1Database, orderId: number): Promise { + const result = await db.prepare( + 'DELETE FROM server_orders WHERE id = ?' + ).bind(orderId).run(); + + if (!result.success) { + throw new Error(`Failed to delete order: ${orderId}`); + } + + logger.info('서버 주문 삭제', { orderId }); +} + +/** + * 잔액 차감 (Optimistic Locking 적용) + */ +async function deductBalance( + db: D1Database, + userId: number, + amount: number, + reason: string +): Promise { + await executeWithOptimisticLock(db, async () => { + // 현재 잔액 및 version 조회 + const current = await db.prepare( + 'SELECT balance, version FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number; version: number }>(); + + if (!current) { + throw new Error('User deposit account not found'); + } + + if (current.balance < amount) { + throw new Error('Insufficient balance'); + } + + // 잔액 차감 및 version 증가 (Optimistic Locking) + const updateResult = await db.prepare( + `UPDATE user_deposits + SET balance = balance - ?, + version = version + 1, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND version = ?` + ).bind(amount, userId, current.version).run(); + + if (updateResult.meta.changes === 0) { + throw new OptimisticLockError('Version mismatch during balance deduction'); + } + + // 거래 내역 기록 + const txResult = await db.prepare( + `INSERT INTO deposit_transactions + (user_id, type, amount, status, description, confirmed_at) + VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` + ).bind(userId, amount, reason).run(); + + if (!txResult.success) { + throw new Error('Failed to record withdrawal transaction'); + } + + logger.info('잔액 차감 완료', { + userId, + amount, + reason, + newBalance: current.balance - amount + }); + }); +} + +/** + * 사용자에게 Telegram 알림 전송 + */ +async function notifyUser( + botToken: string, + telegramUserId: string, + message: string +): Promise { + try { + await sendMessage(botToken, parseInt(telegramUserId), message); + logger.info('사용자 알림 전송 완료', { telegramUserId }); + } catch (error) { + logger.error('사용자 알림 전송 실패', error as Error, { telegramUserId }); + // 알림 실패는 치명적이지 않으므로 에러를 전파하지 않음 + } +} + +/** + * 관리자에게 Telegram 알림 전송 + */ +async function notifyAdmin( + botToken: string, + adminId: string | undefined, + message: string +): Promise { + if (!adminId) { + logger.warn('DEPOSIT_ADMIN_ID 미설정 - 관리자 알림 생략'); + return; + } + + try { + await sendMessage(botToken, parseInt(adminId), message); + logger.info('관리자 알림 전송 완료', { adminId }); + } catch (error) { + logger.error('관리자 알림 전송 실패', error as Error, { adminId }); + // 알림 실패는 치명적이지 않으므로 에러를 전파하지 않음 + } +} + +/** + * Cloud Orchestrator API 호출 (Service Binding) + */ +async function callCloudOrchestrator( + orchestrator: Fetcher | undefined, + apiKey: string | undefined, + orderId: number, + userId: string, + pricingId: number, + label: string, + image?: string +): Promise { + if (!orchestrator) { + throw new Error('CLOUD_ORCHESTRATOR Service Binding not configured'); + } + + const body = { + user_id: userId, + pricing_id: pricingId, + label, + idempotency_key: `telegram-bot-order-${orderId}`, + ...(image && { image }) + }; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // API 키 추가 (필수) + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + logger.info('Cloud Orchestrator API 호출', { ...body, hasApiKey: !!apiKey }); + + const response = await orchestrator.fetch('https://internal/api/provision', { + method: 'POST', + headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Cloud Orchestrator API failed: ${response.status} ${errorText}`); + } + + const data = await response.json() as ProvisionResponse; + + logger.info('Cloud Orchestrator API 응답', data); + + if (!data.success) { + throw new Error(data.error || 'Provisioning failed'); + } + + return data; +} + +/** + * DB에 서버 주문 생성 + * @returns order_id + */ +export async function createServerOrder( + db: D1Database, + userId: number, + telegramUserId: string, + specId: number, + region: string, + provider: 'anvil' | 'linode' | 'vultr', + pricePaid: number, + label?: string +): Promise { + try { + const result = await db.prepare( + `INSERT INTO server_orders + (user_id, telegram_user_id, spec_id, region, provider, price_paid, label, status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')` + ).bind(userId, telegramUserId, specId, region, provider, pricePaid, label || null).run(); + + if (!result.success || !result.meta.last_row_id) { + throw new Error('Failed to create server order'); + } + + const orderId = result.meta.last_row_id as number; + + // Set idempotency_key for Cloud Orchestrator deduplication + const idempotencyKey = `telegram-bot-order-${orderId}`; + await db.prepare( + `UPDATE server_orders SET idempotency_key = ? WHERE id = ?` + ).bind(idempotencyKey, orderId).run(); + + logger.info('서버 주문 생성', { + orderId, + userId, + telegramUserId, + specId, + region, + provider, + pricePaid, + idempotencyKey + }); + + return orderId; + } catch (error) { + logger.error('서버 주문 생성 실패', error as Error, { + userId, + telegramUserId, + specId, + region + }); + throw error; + } +} + +/** + * Queue에 프로비저닝 메시지 전송 + */ +export async function sendProvisionMessage( + queue: Queue, + orderId: number, + userId: number, + telegramUserId: string +): Promise { + try { + await queue.send({ + order_id: orderId, + user_id: userId, + telegram_user_id: telegramUserId, + timestamp: Date.now(), + retry_count: 0 + }); + + logger.info('Queue 메시지 전송 완료', { + orderId, + userId, + telegramUserId + }); + } catch (error) { + logger.error('Queue 메시지 전송 실패', error as Error, { + orderId, + userId, + telegramUserId + }); + throw error; + } +} + +/** + * 메인 Queue 핸들러 - 서버 프로비저닝 요청 처리 + */ +export async function handleProvisionQueue( + batch: MessageBatch, + env: Env +): Promise { + logger.info('Provision Queue 처리 시작', { + messageCount: batch.messages.length + }); + + for (const message of batch.messages) { + try { + const { order_id, user_id, telegram_user_id } = message.body; + + logger.info('프로비저닝 처리 중', { + orderId: order_id, + userId: user_id, + telegramUserId: telegram_user_id, + attempts: message.attempts + }); + + // 1. 주문 정보 조회 + const order = await getServerOrder(env.DB, order_id); + if (!order) { + throw new Error(`Order not found: ${order_id}`); + } + + // 이미 처리된 주문인지 확인 (pending, provisioning만 처리) + if (order.status !== 'pending' && order.status !== 'provisioning') { + logger.warn('이미 처리된 주문', { + orderId: order_id, + currentStatus: order.status + }); + message.ack(); + continue; + } + + // 2. 상태를 provisioning으로 업데이트 (pending일 때만) + if (order.status === 'pending') { + await updateOrderStatus(env.DB, order_id, 'provisioning'); + } + + // 3. Cloud Orchestrator API 호출 + try { + const provisionResult = await callCloudOrchestrator( + env.CLOUD_ORCHESTRATOR, + env.PROVISION_API_KEY, + order_id, + telegram_user_id, + parseInt(order.spec_id), + order.label || `server-${order_id}`, + undefined // image는 향후 추가 예정 + ); + + if (!provisionResult.order) { + throw new Error('Provisioning response missing order data'); + } + + // 4. 잔액 차감 (성공 후에만 실행) + try { + await deductBalance( + env.DB, + user_id, + order.price_paid, + `서버 주문 #${order_id} - ${order.label || order.spec_id}` + ); + } catch (balanceError) { + // 잔액 차감 실패 시 - 서버는 생성됐지만 결제 실패 + // 이 경우 관리자 알림 필요 (서버는 수동 삭제 필요) + logger.error('잔액 차감 실패 (서버는 생성됨)', balanceError as Error, { + orderId: order_id, + userId: user_id + }); + + // 주문 상태는 active로 변경하되, 결제 실패 표시 + await updateOrderStatus(env.DB, order_id, 'active', { + provider_instance_id: provisionResult.order.provider_instance_id || undefined, + ip_address: provisionResult.order.ip_address || undefined, + // root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음 + provisioned_at: new Date().toISOString(), + error_message: '결제 실패 - 관리자 확인 필요' + }); + + // 관리자 알림 + await notifyAdmin( + env.BOT_TOKEN, + env.DEPOSIT_ADMIN_ID, + `🚨 결제 실패 알림\n\n주문 #${order_id}\n서버는 생성됐으나 잔액 차감 실패\n사용자: ${telegram_user_id}\n금액: ${order.price_paid.toLocaleString()}원\n\n수동 처리 필요` + ); + + // 사용자 알림 + await notifyUser( + env.BOT_TOKEN, + telegram_user_id, + `⚠️ 서버 생성 완료, 결제 처리 중 문제 발생\n\nIP: ${provisionResult.order.ip_address || 'N/A'}\n\n관리자가 확인 후 연락드리겠습니다.` + ); + + message.ack(); + continue; + } + + // 5. 성공 시 DB 업데이트 + // Note: root_password는 Cloud Orchestrator가 생성하여 DB에 저장함 + // API 응답의 root_password는 마스킹된 값이므로 업데이트하지 않음 + await updateOrderStatus(env.DB, order_id, 'active', { + provider_instance_id: provisionResult.order.provider_instance_id || undefined, + ip_address: provisionResult.order.ip_address || undefined, + // root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음 + provisioned_at: new Date().toISOString() + }); + + // 6. 사용자 알림은 Cloud Orchestrator에서 처리 + // (실제 IP와 비밀번호가 할당된 후 전송) + logger.info('프로비저닝 요청 완료 - Cloud Orchestrator에서 알림 처리', { + orderId: order_id, + providerInstanceId: provisionResult.order.provider_instance_id + }); + + message.ack(); + logger.info('프로비저닝 완료', { orderId: order_id }); + } catch (error) { + // Cloud Orchestrator API 실패 + logger.error('Cloud Orchestrator API 호출 실패', error as Error, { + orderId: order_id, + attempts: message.attempts + }); + + // 재시도 로직 + if (message.attempts < 3) { + message.retry(); + logger.info('프로비저닝 재시도 예약', { + orderId: order_id, + nextAttempt: message.attempts + 1 + }); + } else { + // 최대 재시도 초과 - 주문 삭제 (잔액 차감 안 됐으므로) + await deleteServerOrder(env.DB, order_id); + + await notifyUser( + env.BOT_TOKEN, + telegram_user_id, + `❌ 서버 프로비저닝 실패\n\n사유: ${error instanceof Error ? error.message : 'API 호출 실패'}\n\n잔액 차감은 이루어지지 않았습니다.\n다시 시도해주세요.` + ); + + message.ack(); + logger.warn('최대 재시도 초과 - 주문 삭제', { + orderId: order_id, + attempts: message.attempts + }); + } + } + } catch (error) { + logger.error('프로비저닝 처리 중 예외 발생', error as Error, { + orderId: message.body.order_id, + attempts: message.attempts + }); + + // 예상치 못한 에러 - 재시도 + if (message.attempts < 3) { + message.retry(); + } else { + // DLQ로 이동 + message.ack(); + logger.warn('최대 재시도 초과 - DLQ로 이동', { + orderId: message.body.order_id, + attempts: message.attempts + }); + } + } + } +} + +/** + * Dead Letter Queue 핸들러 - 프로비저닝 실패 건 처리 + */ +export async function handleProvisionDLQ( + batch: MessageBatch, + env: Env +): Promise { + logger.info('DLQ 처리 시작', { + messageCount: batch.messages.length + }); + + for (const message of batch.messages) { + try { + const { order_id, user_id, telegram_user_id } = message.body; + + logger.error('DLQ 메시지 처리', new Error('Provisioning failed permanently'), { + orderId: order_id, + userId: user_id, + telegramUserId: telegram_user_id, + attempts: message.attempts + }); + + // 1. DB에 실패 상태 기록 + await updateOrderStatus(env.DB, order_id, 'failed', { + error_message: 'Provisioning failed after maximum retries (moved to DLQ)' + }); + + // 2. 관리자에게 알림 + const adminMessage = `🚨 서버 프로비저닝 영구 실패 (DLQ) + +주문 ID: ${order_id} +사용자 ID: ${user_id} +Telegram ID: ${telegram_user_id} +재시도 횟수: ${message.attempts} + +수동 개입 필요 - 환불 및 사용자 안내 필요`; + + await notifyAdmin(env.BOT_TOKEN, env.DEPOSIT_ADMIN_ID, adminMessage); + + // 3. 사용자에게 안내 + const userMessage = `❌ 서버 프로비저닝 실패 + +주문 #${order_id} + +죄송합니다. 서버 프로비저닝 중 문제가 발생했습니다. +잔액 차감은 이루어지지 않았습니다. + +문의사항이 있으시면 관리자에게 연락해주세요.`; + + await notifyUser(env.BOT_TOKEN, telegram_user_id, userMessage); + + message.ack(); + } catch (error) { + logger.error('DLQ 처리 중 오류', error as Error, { + orderId: message.body.order_id + }); + + // DLQ 처리 실패는 심각한 문제이므로 수동 개입 필요 + // 메시지는 ack()하여 무한 루프 방지 + message.ack(); + } + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 8156bc6..3380803 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -64,7 +64,7 @@ const RedditSearchArgsSchema = z.object({ }); const ManageServerArgsSchema = z.object({ - action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list', + action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list', 'info', 'images', 'start_consultation', 'continue_consultation', 'cancel_consultation']), tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(), expected_users: z.number().int().positive().optional(), @@ -77,6 +77,9 @@ const ManageServerArgsSchema = z.object({ region_code: z.string().min(1).max(50).optional(), label: z.string().min(1).max(100).optional(), message: z.string().min(1).max(500).optional(), // For continue_consultation + pricing_id: z.number().int().positive().optional(), // For order + order_id: z.number().int().positive().optional(), // For info, delete + image: z.string().min(1).max(50).optional(), // For order (OS image) }); const ManageTroubleshootArgsSchema = z.object({ @@ -152,6 +155,45 @@ export function selectToolsForMessage(message: string): typeof tools { return selectedTools; } +// Generic validated executor helper +function createValidatedExecutor( + schema: T, + executor: (data: z.infer, env?: Env, userId?: string, db?: D1Database) => Promise, + toolName: string +) { + return async ( + args: Record, + env?: Env, + userId?: string, + db?: D1Database + ): Promise => { + const result = schema.safeParse(args); + if (!result.success) { + logger.error(`Invalid ${toolName} args`, new Error(result.error.message), { args }); + return `❌ 잘못된 입력: ${result.error.issues.map(e => e.message).join(', ')}`; + } + return executor(result.data, env, userId, db); + }; +} + +// Tool executor registry +const toolExecutors: Record< + string, + (args: Record, env?: Env, userId?: string, db?: D1Database) => Promise +> = { + get_weather: createValidatedExecutor(GetWeatherArgsSchema, executeWeather, 'weather'), + search_web: createValidatedExecutor(SearchWebArgsSchema, executeSearchWeb, 'search'), + lookup_docs: createValidatedExecutor(LookupDocsArgsSchema, executeLookupDocs, 'lookup_docs'), + get_current_time: createValidatedExecutor(GetCurrentTimeArgsSchema, executeGetCurrentTime, 'time'), + calculate: createValidatedExecutor(CalculateArgsSchema, executeCalculate, 'calculate'), + manage_domain: createValidatedExecutor(ManageDomainArgsSchema, executeManageDomain, 'domain'), + suggest_domains: createValidatedExecutor(SuggestDomainsArgsSchema, executeSuggestDomains, 'suggest_domains'), + manage_deposit: createValidatedExecutor(ManageDepositArgsSchema, executeManageDeposit, 'deposit'), + manage_server: createValidatedExecutor(ManageServerArgsSchema, executeManageServer, 'server'), + search_reddit: createValidatedExecutor(RedditSearchArgsSchema, executeRedditSearch, 'reddit'), + manage_troubleshoot: createValidatedExecutor(ManageTroubleshootArgsSchema, executeManageTroubleshoot, 'troubleshoot'), +}; + // Tool execution dispatcher with validation export async function executeTool( name: string, @@ -161,109 +203,11 @@ export async function executeTool( db?: D1Database ): Promise { try { - switch (name) { - case 'get_weather': { - const result = GetWeatherArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid weather args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeWeather(result.data, env); - } - - case 'search_web': { - const result = SearchWebArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid search args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeSearchWeb(result.data, env); - } - - case 'lookup_docs': { - const result = LookupDocsArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid lookup_docs args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeLookupDocs(result.data, env); - } - - case 'get_current_time': { - const result = GetCurrentTimeArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid time args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeGetCurrentTime(result.data); - } - - case 'calculate': { - const result = CalculateArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid calculate args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeCalculate(result.data); - } - - case 'manage_domain': { - const result = ManageDomainArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid domain args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeManageDomain(result.data, env, telegramUserId, db); - } - - case 'suggest_domains': { - const result = SuggestDomainsArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid suggest_domains args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeSuggestDomains(result.data, env); - } - - case 'manage_deposit': { - const result = ManageDepositArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid deposit args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeManageDeposit(result.data, env, telegramUserId, db); - } - - case 'manage_server': { - const result = ManageServerArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid server args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeManageServer(result.data, env, telegramUserId); - } - - case 'search_reddit': { - const result = RedditSearchArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid reddit args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeRedditSearch(result.data, env); - } - - case 'manage_troubleshoot': { - const result = ManageTroubleshootArgsSchema.safeParse(args); - if (!result.success) { - logger.error('Invalid troubleshoot args', new Error(result.error.message), { args }); - return `❌ Invalid arguments: ${result.error.issues.map(e => e.message).join(', ')}`; - } - return executeManageTroubleshoot(result.data, env, telegramUserId); - } - - default: - return `알 수 없는 도구: ${name}`; + const executor = toolExecutors[name]; + if (executor) { + return executor(args, env, telegramUserId, db); } + return `알 수 없는 도구: ${name}`; } catch (error) { logger.error('Tool execution error', error as Error, { name, args }); return `⚠️ 도구 실행 중 오류가 발생했습니다.`; diff --git a/src/types.ts b/src/types.ts index 091c002..717a9d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,6 +14,7 @@ export interface Env { DEPOSIT_ADMIN_ID?: string; BRAVE_API_KEY?: string; DEPOSIT_API_SECRET?: string; + PROVISION_API_KEY?: string; OPENAI_API_BASE?: string; NAMECHEAP_API_URL?: string; WHOIS_API_URL?: string; @@ -25,6 +26,7 @@ export interface Env { CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding RATE_LIMIT_KV: KVNamespace; SESSION_KV: KVNamespace; + SERVER_PROVISION_QUEUE: Queue; } export interface IntentAnalysis { @@ -212,6 +214,8 @@ export interface ManageServerArgs { | "stop" | "delete" | "list" + | "info" + | "images" | "start_consultation" | "continue_consultation" | "cancel_consultation"; @@ -226,6 +230,9 @@ export interface ManageServerArgs { region_code?: string; label?: string; message?: string; // For continue_consultation + pricing_id?: number; // For order + order_id?: number; // For info, delete + image?: string; // For order (OS image key) } export interface ManageMemoryArgs { @@ -251,6 +258,7 @@ export interface ServerSession { updatedAt: number; lastRecommendation?: { recommendations: Array<{ + pricing_id: number; // cloud-instances-db.anvil_pricing.id plan_name: string; provider: string; specs: { vcpu: number; ram_gb: number; storage_gb: number }; @@ -263,6 +271,7 @@ export interface ServerSession { cdn_cache_hit_rate?: number; // CDN 히트율 (0.0-1.0) overage_tb?: number; overage_cost_krw?: number; + currency?: string; // 통화 단위 (KRW, USD 등) }; score: number; max_users: number; @@ -392,9 +401,21 @@ export interface BraveSearchResponse { } // OpenAI API 응답 타입 +export interface ToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + export interface OpenAIMessage { - role: string; - content: string; + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; + name?: string; // For tool responses } export interface OpenAIChoice { @@ -438,7 +459,14 @@ export interface ServerOrderKeyboardData { plan: string; // 플랜 이름 } -export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData; +export interface ServerDeleteKeyboardData { + type: "server_delete"; + orderId: number; + label: string; + userId: string; +} + +export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerDeleteKeyboardData; // Bandwidth Info (shared by server-agent and server-tool) export interface BandwidthInfo { @@ -454,6 +482,84 @@ export interface BandwidthInfo { cdn_cache_hit_rate?: number; } +// Server-related types (Cloud Orchestrator API responses) +export interface ServerSpec { + id: number; + provider_name: string; + instance_id: string; + instance_name: string; + vcpu: number; + memory_mb: number; + memory_gb: number; + storage_gb: number; + network_speed_gbps: number | null; + instance_family: string; + gpu_count: number; + gpu_type: string | null; + monthly_price: number; + region_name: string; + region_code: string; + country_code: string; + transfer_tb: number; + transfer_price_per_gb: number; + currency: string; +} + +export interface BenchmarkItem { + name: string; + category: string; + score: number; + percentile: number; +} + +export interface AvailableRegion { + region_name: string; + region_code: string; + monthly_price: number; +} + +export interface ServerRecommendation { + server: ServerSpec; + score: number; + analysis: { + tech_fit: string; + capacity: string; + cost_efficiency: string; + scalability: string; + }; + estimated_capacity: { + max_concurrent_users: number; + requests_per_second: number; + }; + bandwidth_info?: BandwidthInfo; + benchmark_reference?: { + processor_name: string; + benchmarks: BenchmarkItem[]; + }; + vps_benchmark_reference?: { + plan_name: string; + geekbench_single: number; + geekbench_multi: number; + monthly_price_usd: number; + performance_per_dollar: number; + }; + available_regions?: AvailableRegion[]; +} + +export interface RecommendResponse { + recommendations: ServerRecommendation[]; + infrastructure_tips?: string[]; + bandwidth_estimate?: { + monthly_tb: number; + monthly_gb: number; + daily_gb: number; + category: string; + description: string; + }; + total_candidates?: number; + cached?: boolean; +} + // Workers AI Types (from worker-configuration.d.ts) export type WorkersAIModel = | "@cf/meta/llama-3.1-8b-instruct" @@ -481,3 +587,116 @@ export interface WorkersAITextGenerationOutput { total_tokens: number; }; } + +// Cloud Orchestrator Provision API Types +export interface ProvisionOrder { + id: number; + user_id: number; + status: 'provisioning' | 'active' | 'stopped' | 'deleted' | 'failed'; + price_paid: number; + label: string; + ip_address?: string; + root_password?: string; + provider_instance_id?: string; + image?: string; + created_at: string; + provisioned_at?: string; + deleted_at?: string; + expires_at?: string; +} + +export interface OSImage { + key: string; + name: string; + family?: string; + is_default: boolean | number; // API returns boolean, but some DBs return 0/1 +} + +export interface ProvisionResponse { + success: boolean; + message?: string; + error?: string; + order?: ProvisionOrder; + orders?: ProvisionOrder[]; + images?: OSImage[]; + balance_krw?: number; + currency?: string; +} + +// Server Provision Queue Message +export interface ProvisionMessage { + order_id: number; + user_id: number; + telegram_user_id: string; + timestamp: number; + retry_count?: number; +} + +// Server Order (Local Database) +export interface ServerOrder { + id: number; + user_id: number; + telegram_user_id: string; + spec_id: string; + region: string; + label?: string; + price_paid: number; + status: 'pending' | 'provisioning' | 'active' | 'terminated'; + provider: 'linode' | 'vultr' | 'anvil'; + instance_id?: string; + ip_address?: string; + root_password?: string; + error_message?: string; + provisioned_at?: string; + terminated_at?: string; + created_at: string; + updated_at: string; +} + +// User Server +export interface UserServer { + id: number; + user_id: number; + order_id: number; + provider: string; + instance_id: string; + label?: string; + ip_address?: string; + region?: string; + spec_label?: string; + monthly_price?: number; + status: 'active' | 'stopped' | 'terminated'; + created_at: string; + updated_at: string; +} + +// Provisioning Result +export interface ProvisionResult { + success: boolean; + order_id: number; + instance_id?: string; + ip_address?: string; + root_password?: string; + plan_label?: string; + region?: string; + error?: string; +} + +// Cloudflare Queue MessageBatch 타입 +export interface MessageBatch { + readonly queue: string; + readonly messages: Message[]; + ack(idOrIds: string | string[]): void; + retry(idOrIds: string | string[]): void; + retryAll(): void; + ackAll(): void; +} + +export interface Message { + readonly id: string; + readonly body: T; + readonly timestamp: Date; + readonly attempts: number; + ack(): void; + retry(): void; +} diff --git a/wrangler.toml b/wrangler.toml index 3a9dc0a..e87f2a9 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -48,9 +48,11 @@ preview_id = "302ad556567447cbac49c20bded4eb7e" binding = "CLOUD_ORCHESTRATOR" service = "cloud-orchestrator" -# Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소 +# Cron Triggers: +# - 매일 자정(KST): 24시간 경과된 입금 대기 자동 취소 + 정합성 검증 +# - 매 5분: pending 상태 서버 주문 자동 삭제 (5분 경과) [triggers] -crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 +crons = ["0 15 * * *", "*/5 * * * *"] # UTC 15:00 = KST 00:00, 매 5분 # Secrets (wrangler secret put 으로 설정): # - BOT_TOKEN: Telegram Bot Token @@ -62,3 +64,21 @@ crons = ["0 15 * * *"] # UTC 15:00 = KST 00:00 # - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동) # - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장) # - DEPOSIT_ADMIN_ID: 예치금 관리 권한 Telegram ID (보안상 secrets 권장) + +# Server Provision Queue +[[queues.producers]] +queue = "server-provision-queue" +binding = "SERVER_PROVISION_QUEUE" + +[[queues.consumers]] +queue = "server-provision-queue" +max_retries = 3 +max_batch_size = 10 +max_batch_timeout = 30 +max_concurrency = 5 +dead_letter_queue = "provision-dlq" + +# Dead Letter Queue +[[queues.consumers]] +queue = "provision-dlq" +max_retries = 0