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

@@ -86,6 +86,59 @@ CREATE TABLE IF NOT EXISTS deposit_transactions (
FOREIGN KEY (user_id) REFERENCES users(id) 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_user ON user_domains(user_id);
CREATE INDEX IF NOT EXISTS idx_user_domains_domain ON user_domains(domain); 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_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_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_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);

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 { sendMessage, setWebhook, getWebhookInfo } from './telegram';
import { handleWebhook } from './routes/webhook'; import { handleWebhook } from './routes/webhook';
import { handleApiRequest } from './routes/api'; import { handleApiRequest } from './routes/api';
@@ -6,7 +6,11 @@ import { handleHealthCheck } from './routes/health';
import { parseBankSMS } from './services/bank-sms-parser'; import { parseBankSMS } from './services/bank-sms-parser';
import { matchPendingDeposit } from './services/deposit-matcher'; import { matchPendingDeposit } from './services/deposit-matcher';
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation'; import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
import { timingSafeEqual } from './security'; import { timingSafeEqual } from './security';
import { createLogger } from './utils/logger';
const logger = createLogger('worker');
export default { export default {
// HTTP 요청 핸들러 // HTTP 요청 핸들러
@@ -103,17 +107,17 @@ Documentation: https://github.com/your-repo
// 이메일 주소 마스킹 // 이메일 주소 마스킹
const maskedFrom = message.from.replace(/@.+/, '@****'); const maskedFrom = message.from.replace(/@.+/, '@****');
console.log('[Email] 수신:', maskedFrom, 'Size:', message.rawSize); logger.info('이메일 수신', { from: maskedFrom, size: message.rawSize });
// SMS 내용 파싱 // SMS 내용 파싱
const notification = await parseBankSMS(rawEmail, env); const notification = await parseBankSMS(rawEmail, env);
if (!notification) { if (!notification) {
console.log('[Email] 은행 SMS 파싱 실패'); logger.info('은행 SMS 파싱 실패');
return; return;
} }
// 파싱 결과 마스킹 로깅 // 파싱 결과 마스킹 로깅
console.log('[Email] 파싱 결과:', { logger.info('SMS 파싱 결과', {
bankName: notification.bankName, bankName: notification.bankName,
depositorName: notification.depositorName depositorName: notification.depositorName
? notification.depositorName.slice(0, 2) + '***' ? notification.depositorName.slice(0, 2) + '***'
@@ -140,16 +144,16 @@ Documentation: https://github.com/your-repo
).run(); ).run();
const notificationId = insertResult.meta.last_row_id; const notificationId = insertResult.meta.last_row_id;
console.log('[Email] 알림 저장 완료, ID:', notificationId); logger.info('알림 저장 완료', { notificationId });
// 자동 매칭 시도 // 자동 매칭 시도
const matched = await matchPendingDeposit(env.DB, notificationId, notification); const matched = await matchPendingDeposit(env.DB, notificationId, notification);
// 매칭 결과 로깅 (민감 정보 마스킹) // 매칭 결과 로깅 (민감 정보 마스킹)
if (matched) { if (matched) {
console.log('[Email] 자동 매칭 성공: 거래 ID', matched.transactionId); logger.info('자동 매칭 성공', { transactionId: matched.transactionId });
} else { } else {
console.log('[Email] 매칭되는 거래 없음'); logger.info('매칭되는 거래 없음');
} }
// 매칭 성공 시 사용자에게 알림 // 매칭 성공 시 사용자에게 알림
@@ -192,13 +196,120 @@ Documentation: https://github.com/your-repo
); );
} }
} catch (error) { } catch (error) {
console.error('[Email] 처리 오류:', error); logger.error('이메일 처리 오류', error as Error);
} }
}, },
// Cron Trigger: 만료된 입금 대기 자동 취소 (24시간) // Cron Triggers: 입금 대기 자동 취소 (24시간) + 서버 주문 자동 삭제 (5분)
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> { async scheduled(event: ScheduledEvent, env: Env, _ctx: ExecutionContext): Promise<void> {
console.log('[Cron] 만료된 입금 대기 정리 시작'); const cronSchedule = event.cron;
logger.info('Cron 작업 시작', { schedule: cronSchedule });
// 매 5분: pending 서버 주문 자동 삭제
if (cronSchedule === '*/5 * * * *') {
await cleanupStalePendingServerOrders(env);
return;
}
// 매일 자정 (KST): 입금 만료 + 정합성 검증
if (cronSchedule === '0 15 * * *') {
await cleanupExpiredDepositTransactions(env);
await reconcileDepositBalances(env);
return;
}
logger.warn('알 수 없는 Cron 스케줄', { schedule: cronSchedule });
},
// 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 { try {
// 24시간 이상 된 pending 거래 조회 // 24시간 이상 된 pending 거래 조회
@@ -218,11 +329,11 @@ Documentation: https://github.com/your-repo
}>(); }>();
if (!expiredTxs.results?.length) { if (!expiredTxs.results?.length) {
console.log('[Cron] 만료된 거래 없음'); logger.info('만료된 거래 없음');
return; return;
} }
console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`); logger.info('만료된 거래 발견', { count: expiredTxs.results.length });
// 단일 UPDATE 쿼리로 일괄 처리 // 단일 UPDATE 쿼리로 일괄 처리
const ids = expiredTxs.results.map(tx => tx.id); const ids = expiredTxs.results.map(tx => tx.id);
@@ -232,7 +343,7 @@ Documentation: https://github.com/your-repo
WHERE id IN (${ids.map(() => '?').join(',')})` WHERE id IN (${ids.map(() => '?').join(',')})`
).bind(...ids).run(); ).bind(...ids).run();
console.log(`[Cron] UPDATE 완료: ${ids.length}`); logger.info('UPDATE 완료', { count: ids.length });
// 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가) // 알림 병렬 처리 (개별 실패가 전체를 중단시키지 않도록 .catch() 추가)
const notificationPromises = expiredTxs.results.map(tx => const notificationPromises = expiredTxs.results.map(tx =>
@@ -245,19 +356,28 @@ Documentation: https://github.com/your-repo
`• 입금자: ${tx.depositor_name}\n\n` + `• 입금자: ${tx.depositor_name}\n\n` +
`실제 입금하셨다면 다시 신고해주세요.` `실제 입금하셨다면 다시 신고해주세요.`
).catch(err => { ).catch(err => {
console.error(`[Cron] 알림 전송 실패 (거래 #${tx.id}, 사용자 ${tx.telegram_id}):`, err); logger.error('알림 전송 실패', err as Error, {
return null; // 실패한 알림은 null로 처리 transactionId: tx.id,
userId: tx.telegram_id
});
return null;
}) })
); );
await Promise.all(notificationPromises); await Promise.all(notificationPromises);
console.log(`[Cron] ${expiredTxs.results.length}건 만료 처리 완료 (알림 전송 완료)`); logger.info('만료 처리 완료', { count: expiredTxs.results.length });
} catch (error) { } catch (error) {
console.error('[Cron] 오류:', error); logger.error('Cron 작업 오류', error as Error);
} }
}
/**
* 예치금 정합성 검증 (Reconciliation)
* 실행 주기: 매일 자정 KST (0 15 * * *)
*/
async function reconcileDepositBalances(env: Env): Promise<void> {
logger.info('예치금 정합성 검증 시작');
// 예치금 정합성 검증 (Reconciliation)
console.log('[Cron] 예치금 정합성 검증 시작');
try { try {
const report = await reconcileDeposits(env.DB); const report = await reconcileDeposits(env.DB);
@@ -267,17 +387,19 @@ Documentation: https://github.com/your-repo
if (adminId) { if (adminId) {
const message = formatReconciliationReport(report); const message = formatReconciliationReport(report);
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => { await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
console.error('[Cron] 정합성 검증 알림 전송 실패:', err); logger.error('정합성 검증 알림 전송 실패', err as Error);
}); });
} else { } else {
console.warn('[Cron] DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가'); logger.warn('DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
} }
} }
console.log(`[Cron] 정합성 검증 완료: ${report.totalUsers}명 검증, ${report.inconsistencies}건 불일치`); logger.info('정합성 검증 완료', {
totalUsers: report.totalUsers,
inconsistencies: report.inconsistencies
});
} catch (error) { } catch (error) {
console.error('[Cron] 정합성 검증 실패:', error); logger.error('정합성 검증 실패', error as Error);
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행 // 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
} }
}, }
};

View File

@@ -63,6 +63,10 @@ export async function handleCallbackQuery(
return; 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 answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' });
await editMessageText( await editMessageText(
env.BOT_TOKEN, env.BOT_TOKEN,
@@ -127,9 +131,18 @@ ${result.error}
return; return;
} }
const userId = parts[1]; const callbackUserId = parts[1];
const index = parseInt(parts[2], 10); 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) { if (isNaN(index) || index < 0 || index > 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' }); await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
return; return;
@@ -146,7 +159,7 @@ ${result.error}
// 세션 조회 // 세션 조회
const { getServerSession, deleteServerSession } = await import('../../server-agent'); const { getServerSession, deleteServerSession } = await import('../../server-agent');
if (!env.SESSION_KV) { if (!env.DB) {
await editMessageText( await editMessageText(
env.BOT_TOKEN, env.BOT_TOKEN,
chatId, chatId,
@@ -156,7 +169,8 @@ ${result.error}
return; 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) { if (!session || !session.lastRecommendation) {
await editMessageText( await editMessageText(
@@ -177,33 +191,74 @@ ${result.error}
messageId, messageId,
'❌ 선택한 서버를 찾을 수 없습니다.' '❌ 선택한 서버를 찾을 수 없습니다.'
); );
await deleteServerSession(env.SESSION_KV, userId); await deleteServerSession(env.DB, telegramUserId);
return; 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( const price = selected.price?.monthly_krw || 0;
'order',
{ if (!deposit || deposit.balance < price) {
server_id: selected.plan_name, // 임시 await editMessageText(
region_code: selected.region.code, env.BOT_TOKEN, chatId, messageId,
label: `${session.collectedInfo.useCase || 'server'}-1` `❌ 잔액이 부족합니다.
},
env, • 서버 가격: ${price.toLocaleString()}원/월
userId • 현재 잔액: ${(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( await editMessageText(
env.BOT_TOKEN, env.BOT_TOKEN,
chatId, chatId,
messageId, messageId,
`📋 ${selected.plan_name} 신청\n\n${result}` `📋 <b>서버 주문 접수 완료!</b> (주문 #${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; return;
} }
@@ -215,7 +270,16 @@ ${result.error}
return; 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 answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
await editMessageText( await editMessageText(
@@ -228,13 +292,15 @@ ${result.error}
// 세션 삭제 // 세션 삭제
const { deleteServerSession } = await import('../../server-agent'); const { deleteServerSession } = await import('../../server-agent');
if (env.SESSION_KV) { if (env.DB) {
await deleteServerSession(env.SESSION_KV, userId); await deleteServerSession(env.DB, telegramUserId);
} }
return; return;
} }
// Note: server_delete callback handler removed - now using text-based confirmation
// 알 수 없는 callback data // 알 수 없는 callback data
await answerCallbackQuery(env.BOT_TOKEN, queryId); await answerCallbackQuery(env.BOT_TOKEN, queryId);
} }

View File

@@ -53,8 +53,167 @@ export async function handleMessage(
return; 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 { 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,
`📋 <b>서버 주문 접수 완료!</b> (주문 #${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('/')) { if (text.startsWith('/')) {
const [command, ...argParts] = text.split(' '); const [command, ...argParts] = text.split(' ');
const args = argParts.join(' '); const args = argParts.join(' ');
@@ -74,7 +233,7 @@ export async function handleMessage(
return; return;
} }
// 5. 일반 대화 처리 (ConversationService 위임) // 9. 일반 대화 처리 (ConversationService 위임)
const result = await conversationService.processUserMessage( const result = await conversationService.processUserMessage(
userId, userId,
chatIdStr, chatIdStr,
@@ -87,7 +246,7 @@ export async function handleMessage(
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>'; finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
} }
// 6. 응답 전송 (키보드 포함 여부 확인) // 10. 응답 전송 (키보드 포함 여부 확인)
if (result.keyboardData) { if (result.keyboardData) {
console.log('[Webhook] Keyboard data received:', result.keyboardData.type); console.log('[Webhook] Keyboard data received:', result.keyboardData.type);
if (result.keyboardData.type === 'domain_register') { if (result.keyboardData.type === 'domain_register') {

573
src/server-provision.ts Normal file
View File

@@ -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<ServerOrder | null> {
const order = await db.prepare(
`SELECT * FROM server_orders WHERE id = ?`
).bind(orderId).first<ServerOrder>();
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ProvisionResponse> {
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<string, string> = {
'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<number> {
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<ProvisionMessage>,
orderId: number,
userId: number,
telegramUserId: string
): Promise<void> {
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<ProvisionMessage>,
env: Env
): Promise<void> {
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<ProvisionMessage>,
env: Env
): Promise<void> {
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();
}
}
}

View File

@@ -64,7 +64,7 @@ const RedditSearchArgsSchema = z.object({
}); });
const ManageServerArgsSchema = 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']), 'start_consultation', 'continue_consultation', 'cancel_consultation']),
tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(), tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(),
expected_users: z.number().int().positive().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(), region_code: z.string().min(1).max(50).optional(),
label: z.string().min(1).max(100).optional(), label: z.string().min(1).max(100).optional(),
message: z.string().min(1).max(500).optional(), // For continue_consultation 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({ const ManageTroubleshootArgsSchema = z.object({
@@ -152,6 +155,45 @@ export function selectToolsForMessage(message: string): typeof tools {
return selectedTools; return selectedTools;
} }
// Generic validated executor helper
function createValidatedExecutor<T extends z.ZodType>(
schema: T,
executor: (data: z.infer<T>, env?: Env, userId?: string, db?: D1Database) => Promise<string>,
toolName: string
) {
return async (
args: Record<string, unknown>,
env?: Env,
userId?: string,
db?: D1Database
): Promise<string> => {
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<string, unknown>, env?: Env, userId?: string, db?: D1Database) => Promise<string>
> = {
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 // Tool execution dispatcher with validation
export async function executeTool( export async function executeTool(
name: string, name: string,
@@ -161,109 +203,11 @@ export async function executeTool(
db?: D1Database db?: D1Database
): Promise<string> { ): Promise<string> {
try { try {
switch (name) { const executor = toolExecutors[name];
case 'get_weather': { if (executor) {
const result = GetWeatherArgsSchema.safeParse(args); return executor(args, env, telegramUserId, db);
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}`; return `알 수 없는 도구: ${name}`;
}
} catch (error) { } catch (error) {
logger.error('Tool execution error', error as Error, { name, args }); logger.error('Tool execution error', error as Error, { name, args });
return `⚠️ 도구 실행 중 오류가 발생했습니다.`; return `⚠️ 도구 실행 중 오류가 발생했습니다.`;

View File

@@ -14,6 +14,7 @@ export interface Env {
DEPOSIT_ADMIN_ID?: string; DEPOSIT_ADMIN_ID?: string;
BRAVE_API_KEY?: string; BRAVE_API_KEY?: string;
DEPOSIT_API_SECRET?: string; DEPOSIT_API_SECRET?: string;
PROVISION_API_KEY?: string;
OPENAI_API_BASE?: string; OPENAI_API_BASE?: string;
NAMECHEAP_API_URL?: string; NAMECHEAP_API_URL?: string;
WHOIS_API_URL?: string; WHOIS_API_URL?: string;
@@ -25,6 +26,7 @@ export interface Env {
CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding CLOUD_ORCHESTRATOR?: Fetcher; // Service Binding
RATE_LIMIT_KV: KVNamespace; RATE_LIMIT_KV: KVNamespace;
SESSION_KV: KVNamespace; SESSION_KV: KVNamespace;
SERVER_PROVISION_QUEUE: Queue<ProvisionMessage>;
} }
export interface IntentAnalysis { export interface IntentAnalysis {
@@ -212,6 +214,8 @@ export interface ManageServerArgs {
| "stop" | "stop"
| "delete" | "delete"
| "list" | "list"
| "info"
| "images"
| "start_consultation" | "start_consultation"
| "continue_consultation" | "continue_consultation"
| "cancel_consultation"; | "cancel_consultation";
@@ -226,6 +230,9 @@ export interface ManageServerArgs {
region_code?: string; region_code?: string;
label?: string; label?: string;
message?: string; // For continue_consultation 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 { export interface ManageMemoryArgs {
@@ -251,6 +258,7 @@ export interface ServerSession {
updatedAt: number; updatedAt: number;
lastRecommendation?: { lastRecommendation?: {
recommendations: Array<{ recommendations: Array<{
pricing_id: number; // cloud-instances-db.anvil_pricing.id
plan_name: string; plan_name: string;
provider: string; provider: string;
specs: { vcpu: number; ram_gb: number; storage_gb: number }; 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) cdn_cache_hit_rate?: number; // CDN 히트율 (0.0-1.0)
overage_tb?: number; overage_tb?: number;
overage_cost_krw?: number; overage_cost_krw?: number;
currency?: string; // 통화 단위 (KRW, USD 등)
}; };
score: number; score: number;
max_users: number; max_users: number;
@@ -392,9 +401,21 @@ export interface BraveSearchResponse {
} }
// OpenAI API 응답 타입 // OpenAI API 응답 타입
export interface ToolCall {
id: string;
type: 'function';
function: {
name: string;
arguments: string;
};
}
export interface OpenAIMessage { export interface OpenAIMessage {
role: string; role: 'system' | 'user' | 'assistant' | 'tool';
content: string; content: string | null;
tool_calls?: ToolCall[];
tool_call_id?: string;
name?: string; // For tool responses
} }
export interface OpenAIChoice { export interface OpenAIChoice {
@@ -438,7 +459,14 @@ export interface ServerOrderKeyboardData {
plan: string; // 플랜 이름 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) // Bandwidth Info (shared by server-agent and server-tool)
export interface BandwidthInfo { export interface BandwidthInfo {
@@ -454,6 +482,84 @@ export interface BandwidthInfo {
cdn_cache_hit_rate?: number; 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) // Workers AI Types (from worker-configuration.d.ts)
export type WorkersAIModel = export type WorkersAIModel =
| "@cf/meta/llama-3.1-8b-instruct" | "@cf/meta/llama-3.1-8b-instruct"
@@ -481,3 +587,116 @@ export interface WorkersAITextGenerationOutput {
total_tokens: number; 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<T> {
readonly queue: string;
readonly messages: Message<T>[];
ack(idOrIds: string | string[]): void;
retry(idOrIds: string | string[]): void;
retryAll(): void;
ackAll(): void;
}
export interface Message<T> {
readonly id: string;
readonly body: T;
readonly timestamp: Date;
readonly attempts: number;
ack(): void;
retry(): void;
}

View File

@@ -48,9 +48,11 @@ preview_id = "302ad556567447cbac49c20bded4eb7e"
binding = "CLOUD_ORCHESTRATOR" binding = "CLOUD_ORCHESTRATOR"
service = "cloud-orchestrator" service = "cloud-orchestrator"
# Cron Trigger: 매일 자정(KST) 실행 - 24시간 경과된 입금 대기 자동 취소 # Cron Triggers:
# - 매일 자정(KST): 24시간 경과된 입금 대기 자동 취소 + 정합성 검증
# - 매 5분: pending 상태 서버 주문 자동 삭제 (5분 경과)
[triggers] [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 으로 설정): # Secrets (wrangler secret put 으로 설정):
# - BOT_TOKEN: Telegram Bot Token # - 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 연동) # - DEPOSIT_API_SECRET: Deposit API 인증 키 (namecheap-api 연동)
# - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장) # - DOMAIN_OWNER_ID: 도메인 관리 권한 Telegram ID (보안상 secrets 권장)
# - DEPOSIT_ADMIN_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