- 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>
307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
import { answerCallbackQuery, editMessageText } from '../../telegram';
|
|
import { UserService } from '../../services/user-service';
|
|
import { executeDomainRegister } from '../../domain-register';
|
|
import type { Env, TelegramUpdate } from '../../types';
|
|
|
|
/**
|
|
* 도메인 형식 검증 정규식
|
|
* - 최소 2글자 이상
|
|
* - 숫자/문자로 시작, 숫자/문자로 끝
|
|
* - 중간에 하이픈, 점 허용
|
|
* - TLD 2글자 이상
|
|
*/
|
|
const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/;
|
|
|
|
/**
|
|
* Callback Query 처리 (인라인 버튼 클릭)
|
|
*/
|
|
export async function handleCallbackQuery(
|
|
env: Env,
|
|
callbackQuery: TelegramUpdate['callback_query']
|
|
): Promise<void> {
|
|
if (!callbackQuery) return;
|
|
|
|
const { id: queryId, from, message, data } = callbackQuery;
|
|
if (!data || !message) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
|
|
return;
|
|
}
|
|
|
|
const chatId = message.chat.id;
|
|
const messageId = message.message_id;
|
|
const telegramUserId = from.id.toString();
|
|
|
|
const userService = new UserService(env.DB);
|
|
const user = await userService.getUserByTelegramId(telegramUserId);
|
|
|
|
if (!user) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' });
|
|
return;
|
|
}
|
|
|
|
// 도메인 등록 처리
|
|
if (data.startsWith('domain_reg:')) {
|
|
const parts = data.split(':');
|
|
if (parts.length !== 3) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
|
return;
|
|
}
|
|
|
|
const domain = parts[1];
|
|
const priceStr = parts[2];
|
|
|
|
// 도메인 형식 검증
|
|
if (!domain || domain.length > 253 || !DOMAIN_REGEX.test(domain)) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 도메인 형식입니다.' });
|
|
return;
|
|
}
|
|
|
|
const price = parseInt(priceStr, 10);
|
|
|
|
if (isNaN(price) || price < 0 || price > 10000000) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' });
|
|
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,
|
|
chatId,
|
|
messageId,
|
|
`⏳ <b>${domain}</b> 등록 처리 중...`
|
|
);
|
|
|
|
const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price);
|
|
|
|
if (result.success) {
|
|
const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : '';
|
|
const nsInfo = result.nameservers && result.nameservers.length > 0
|
|
? `\n\n🌐 <b>현재 네임서버:</b>\n${result.nameservers.map(ns => `• <code>${ns}</code>`).join('\n')}`
|
|
: '';
|
|
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
`✅ <b>도메인 등록 완료!</b>
|
|
|
|
• 도메인: <code>${result.domain}</code>
|
|
• 결제 금액: ${result.price?.toLocaleString()}원
|
|
• 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo}
|
|
|
|
🎉 축하합니다! 도메인이 성공적으로 등록되었습니다.
|
|
네임서버 변경이 필요하면 말씀해주세요.`
|
|
);
|
|
} else {
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
`❌ <b>등록 실패</b>
|
|
|
|
${result.error}
|
|
|
|
다시 시도하시려면 도메인 등록을 요청해주세요.`
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 도메인 등록 취소
|
|
if (data === 'domain_cancel') {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 도메인 등록이 취소되었습니다.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// 서버 주문 확인
|
|
if (data.startsWith('server_order:')) {
|
|
const parts = data.split(':');
|
|
if (parts.length !== 3) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '처리 중...' });
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'⏳ 서버 주문 처리 중...'
|
|
);
|
|
|
|
// 세션 조회
|
|
const { getServerSession, deleteServerSession } = await import('../../server-agent');
|
|
|
|
if (!env.DB) {
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 세션 저장소가 설정되지 않았습니다.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Use verified telegramUserId instead of callback userId
|
|
const session = await getServerSession(env.DB, telegramUserId);
|
|
|
|
if (!session || !session.lastRecommendation) {
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const selected = session.lastRecommendation.recommendations[index];
|
|
|
|
if (!selected) {
|
|
await editMessageText(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 선택한 서버를 찾을 수 없습니다.'
|
|
);
|
|
await deleteServerSession(env.DB, telegramUserId);
|
|
return;
|
|
}
|
|
|
|
// 잔액 확인
|
|
const deposit = await env.DB.prepare(
|
|
'SELECT balance FROM user_deposits WHERE user_id = ?'
|
|
).bind(user.id).first<{ balance: number }>();
|
|
|
|
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,
|
|
`📋 <b>서버 주문 접수 완료!</b> (주문 #${orderId})
|
|
|
|
• 서버: ${selected.plan_name}
|
|
• 리전: ${selected.region.name} (${selected.region.code})
|
|
• 가격: ${price.toLocaleString()}원/월
|
|
|
|
⏳ 서버를 생성하고 있습니다... (1-2분 소요)
|
|
완료되면 메시지로 알려드릴게요.`
|
|
);
|
|
|
|
// 세션 삭제
|
|
await deleteServerSession(env.DB, telegramUserId);
|
|
return;
|
|
}
|
|
|
|
// 서버 주문 취소
|
|
if (data.startsWith('server_cancel:')) {
|
|
const parts = data.split(':');
|
|
if (parts.length !== 2) {
|
|
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
|
return;
|
|
}
|
|
|
|
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(
|
|
env.BOT_TOKEN,
|
|
chatId,
|
|
messageId,
|
|
'❌ 서버 신청이 취소되었습니다.'
|
|
);
|
|
|
|
// 세션 삭제
|
|
const { deleteServerSession } = await import('../../server-agent');
|
|
|
|
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);
|
|
}
|