feat: improve server management and refund display

Server Management:
- Fix /server command API auth (query param instead of header)
- Show server specs (vCPU/RAM/Bandwidth) in /server list
- Prevent AI from refusing server deletion based on expiration date
- Add explicit instructions in tool description and system prompt

Refund Display:
- Show before/after balance in server deletion refund message
- Format: 환불 전 잔액 → 환불 금액 → 환불 후 잔액

Other Changes:
- Add stopped status migration for server orders
- Clean up callback handler (remove deprecated code)
- Update constants and pattern utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-30 05:30:59 +09:00
parent 18e7d3ca6e
commit 2b1bc6a371
17 changed files with 1237 additions and 364 deletions

View File

@@ -15,40 +15,66 @@ export async function handleCommand(
switch (command) {
case '/start':
return `👋 안녕하세요! AI 어시스턴트입니다.
return `👋 <b>AnvilHosting 고객센터</b>입니다!
대화를 나눌수록 당신을 더 잘 이해합니다 💡
<b>제공 서비스:</b>
• 🌐 도메인 등록/관리
• 🖥️ 클라우드 서버 (서울/도쿄/오사카/싱가폴)
• 🛡️ DDoS 방어
• 🔐 PhantomX VPN (Xray 기반 차세대 보안)
<b>명령어:</b>
/profile - 내 프로필 보기
/help - 도움말
/deposit - 예치금 잔액
/domain - 내 도메인 목록
/server - 내 서버 목록
/security - DDoS 방어 현황
/phantomx - PhantomX VPN
💡 중요한 정보는 "기억해줘"로 저장하세요!`;
무엇을 도와드릴까요?`;
case '/help':
return `📖 <b>도움말</b>
/profile - 내 프로필 보기
<b>명령어:</b>
/deposit - 예치금 잔액
/domain - 내 도메인 목록
/server - 내 서버 목록
/security - DDoS 방어 서비스
/phantomx - PhantomX VPN 서비스
<b>기억 기능:</b>
• "OOO 기억해줘" - 정보 저장
• "내 기억 보여줘" - 저장 목록
• "OOO 잊어줘" - 삭제
<b>자연어로 요청:</b>
• "도메인 등록" - 도메인 검색/등록
• "서버 추천" - 맞춤 서버 추천
대화할수록 당신을 더 잘 이해합니다 💡`;
궁금한 점은 편하게 물어보세요!`;
case '/deposit': {
const deposit = await env.DB
.prepare('SELECT balance FROM user_deposits WHERE user_id = ?')
.bind(userId)
.first<{ balance: number }>();
const balance = deposit?.balance ?? 0;
return `💰 <b>예치금 잔액</b>
현재 잔액: <b>${balance.toLocaleString()}원</b>
<b>입금 계좌:</b>
하나은행 427-910018-27104
예금주: (주)아이언클래드
입금 후 "홍길동 10000원 입금" 형식으로 알려주세요.`;
}
case '/context': {
const ctx = await getConversationContext(env.DB, userId, chatId);
const remaining = config.threshold - ctx.recentMessages.length;
return `📊 <b>현재 컨텍스트</b>
분석된 메시지: ${ctx.previousSummary?.message_count ?? 0}
버퍼 메시지: ${ctx.recentMessages.length}
프로필 버전: ${ctx.previousSummary?.generation ?? 0}
총 메시지: ${ctx.totalMessages}
💡 ${remaining > 0 ? `${remaining}개 메시지 후 프로필 업데이트` : '업데이트 대기 중'}`;
버퍼: ${ctx.recentMessages.length}`;
}
case '/profile':
@@ -83,6 +109,162 @@ ${summary.summary}
버퍼 대기: ${ctx.recentMessages.length}`;
}
case '/domain': {
const domains = await env.DB
.prepare(`
SELECT domain, created_at
FROM user_domains
WHERE user_id = ? AND verified = 1
ORDER BY created_at DESC
`)
.bind(userId)
.all<{ domain: string; created_at: string }>();
if (!domains.results || domains.results.length === 0) {
return `🌐 <b>내 도메인</b>
등록된 도메인이 없습니다.
"도메인 등록" 또는 "example.com 등록"으로 시작하세요!`;
}
const domainList = domains.results.map((d, i) => {
const date = new Date(d.created_at).toLocaleDateString('ko-KR');
return `${i + 1}. ${d.domain} (${date})`;
}).join('\n');
return `🌐 <b>내 도메인</b> (${domains.results.length}개)
${domainList}
도메인 관리: "도메인명 네임서버 변경"`;
}
case '/server': {
// Cloud Orchestrator API를 통해 스펙 정보 포함된 서버 목록 조회
const telegramUserId = chatId; // chatId가 실제로는 telegram_user_id
interface ServerWithSpecs {
id: number;
label: string | null;
status: string;
region: string;
vcpu?: number;
memory_gb?: number;
bandwidth_tb?: number;
spec_name?: string;
}
let servers: ServerWithSpecs[] = [];
if (env.CLOUD_ORCHESTRATOR) {
try {
const response = await env.CLOUD_ORCHESTRATOR.fetch(`https://internal/api/provision/orders?user_id=${telegramUserId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json() as { orders?: ServerWithSpecs[] };
servers = data.orders || [];
}
} catch {
// API 실패 시 로컬 DB 폴백
}
}
// API 실패 시 로컬 DB에서 조회 (스펙 정보 없이)
if (servers.length === 0) {
const localServers = await env.DB
.prepare(`
SELECT id, label, status, region
FROM server_orders
WHERE telegram_user_id = ? AND status IN ('active', 'stopped', 'provisioning')
ORDER BY created_at DESC
`)
.bind(telegramUserId)
.all<{ id: number; label: string; status: string; region: string }>();
servers = localServers.results || [];
}
if (servers.length === 0) {
return `🖥️ <b>내 서버</b>
보유한 서버가 없습니다.
"서버 추천" 또는 "서버 신청"으로 시작하세요!`;
}
const statusIcon: Record<string, string> = {
active: '🟢',
stopped: '🔴',
provisioning: '🟡',
};
const serverList = servers
.filter(s => ['active', 'stopped', 'provisioning'].includes(s.status))
.map((s) => {
const icon = statusIcon[s.status] || '⚪';
const label = s.label || '(이름없음)';
// 스펙 정보가 있으면 표시
let specInfo = '';
if (s.vcpu && s.memory_gb) {
specInfo = `\n ${s.vcpu}vCPU / ${s.memory_gb}GB RAM`;
if (s.bandwidth_tb) {
specInfo += ` / ${s.bandwidth_tb}TB`;
}
}
return `#${s.id} ${icon} <b>${label}</b> (${s.region})${specInfo}`;
}).join('\n\n');
return `🖥️ <b>내 서버</b> (${servers.filter(s => ['active', 'stopped', 'provisioning'].includes(s.status)).length}개)
${serverList}
서버 관리: "N번 시작/중지" 또는 "#N 재시작"`;
}
case '/security': {
return `🛡️ <b>DDoS 방어 서비스</b>
<b>AnvilShield</b> - 엔터프라이즈급 DDoS 방어
• L3/L4 네트워크 공격 방어
• L7 애플리케이션 공격 방어
• 실시간 트래픽 모니터링
• 자동 위협 탐지 및 차단
<b>요금제:</b>
• Basic: 10Gbps 방어 - ₩99,000/월
• Pro: 100Gbps 방어 - ₩299,000/월
• Enterprise: 무제한 - 별도 문의
🔜 <i>서비스 준비 중입니다. 문의: @AnvilSupport</i>`;
}
case '/phantomx': {
return `🔐 <b>PhantomX VPN</b>
<b>Xray 기반 차세대 보안 VPN</b>
• 🚀 초고속 연결 (Xray-core 엔진)
• 👻 트래픽 위장 (탐지 우회)
• 🌍 글로벌 서버 (한국/일본/미국/유럽)
• 📱 멀티 디바이스 지원
• 🔒 제로 로그 정책
<b>요금제:</b>
• 월간: ₩9,900/월
• 연간: ₩79,000/년 (33% 할인)
🔜 <i>서비스 준비 중입니다. 문의: @AnvilSupport</i>`;
}
case '/debug': {
// Admin only - exposes internal debug info
const adminId = env.DEPOSIT_ADMIN_ID ? parseInt(env.DEPOSIT_ADMIN_ID, 10) : null;

View File

@@ -40,7 +40,6 @@ export const MESSAGE_MARKERS = {
*/
export const KEYBOARD_TYPES = {
DOMAIN_REGISTER: 'domain_register',
SERVER_ORDER: 'server_order',
} as const;
/**
@@ -49,18 +48,13 @@ export const KEYBOARD_TYPES = {
* Format: prefix:action:params
* Examples:
* - domain_reg:example.com:15000
* - server_order:userId:index
* - server_cancel:userId
* - domain_cancel
*/
export const CALLBACK_PREFIXES = {
DOMAIN_REGISTER: 'domain_reg',
DOMAIN_CANCEL: 'domain_cancel',
SERVER_ORDER: 'server_order',
SERVER_CANCEL: 'server_cancel',
CONFIRM_DOMAIN_REGISTER: 'confirm_domain_register',
CANCEL_DOMAIN_REGISTER: 'cancel_domain_register',
SERVER_ORDER_CONFIRM: 'confirm_server_order',
SERVER_ORDER_CANCEL: 'cancel_server_order',
DELETE_CONFIRM: 'confirm_delete',
DELETE_CANCEL: 'cancel_delete',
} as const;

View File

@@ -307,25 +307,30 @@ export default {
// ============================================================================
/**
* 5분 이상 pending 상태인 서버 주문 자동 삭제
* 오래된 서버 주문 자동 삭제
* - pending: 10분 경과 (Queue 전송 실패 감지)
* - provisioning: 30분 경과 (Cloud API 느린 응답 대비)
* 실행 주기: 매 5분 (every 5 minutes)
*/
async function cleanupStalePendingServerOrders(env: Env): Promise<void> {
logger.info('서버 주문 정리 시작 (5분 경과)');
logger.info('서버 주문 정리 시작 (pending 10분, provisioning 30분 경과)');
try {
// 5분 이상 된 pending 서버 주문 조회
// 10분 이상 된 pending 또는 30분 이상 된 provisioning 서버 주문 조회
// pending: Queue 전송 실패 감지를 위해 10분으로 설정
// provisioning: Cloud Orchestrator API 처리 시간을 고려하여 30분으로 설정
const staleOrders = await env.DB.prepare(
`SELECT so.id, so.label, so.price_paid, u.telegram_id
`SELECT so.id, so.label, so.price_paid, so.status, 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')
WHERE (so.status = 'pending' AND datetime(so.created_at) < datetime('now', '-10 minutes'))
OR (so.status = 'provisioning' AND datetime(so.created_at) < datetime('now', '-30 minutes'))
LIMIT 50`
).all<{
id: number;
label: string | null;
price_paid: number;
status: string;
telegram_id: string;
}>();
@@ -336,35 +341,91 @@ async function cleanupStalePendingServerOrders(env: Env): Promise<void> {
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();
// 주문별 환불 + 삭제 + 알림 처리 (개별 실패 허용)
let successCount = 0;
let refundCount = 0;
logger.info('서버 주문 삭제 완료', { count: orderIds.length });
for (const order of staleOrders.results) {
try {
// 1. 사용자 ID 조회 (telegram_id → user_id)
const user = await env.DB.prepare(
'SELECT id FROM users WHERE telegram_id = ?'
).bind(order.telegram_id).first<{ id: number }>();
// 사용자 알림 병렬 처리 (개별 실패 무시)
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
if (!user) {
logger.warn('사용자 정보 없음 - 주문만 삭제', { orderId: order.id, telegramId: order.telegram_id });
await env.DB.prepare('DELETE FROM server_orders WHERE id = ?').bind(order.id).run();
successCount++;
continue;
}
// 2. 환불 처리 (결제 금액이 있는 경우만)
if (order.price_paid > 0) {
// 2-1. 잔액 환불
await env.DB.prepare(
`UPDATE user_deposits
SET balance = balance + ?,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`
).bind(order.price_paid, user.id).run();
// 2-2. 환불 거래 기록
await env.DB.prepare(
`INSERT INTO deposit_transactions
(user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'refund', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(
user.id,
order.price_paid,
`서버 주문 #${order.id} 자동 취소 환불 (${order.status} 타임아웃)`
).run();
refundCount++;
logger.info('Stale order 환불 완료', {
orderId: order.id,
userId: user.id,
amount: order.price_paid
});
}
// 3. 주문 삭제
await env.DB.prepare('DELETE FROM server_orders WHERE id = ?')
.bind(order.id).run();
// 4. 사용자 알림
const reason = order.status === 'provisioning'
? '서버 생성 중 문제가 발생하여'
: '처리되지 않아';
await sendMessage(
env.BOT_TOKEN,
parseInt(order.telegram_id),
`⏰ <b>서버 주문 자동 취소</b>\n\n` +
`주문 #${order.id}${reason} 자동 취소되었습니다.\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 });
successCount++;
} catch (error) {
logger.error('Stale order 환불/삭제 실패', error as Error, { orderId: order.id });
}
}
logger.info('Stale server orders 정리 완료', {
total: staleOrders.results.length,
success: successCount,
refunded: refundCount,
pendingTimeout: '10분',
provisioningTimeout: '30분'
});
} catch (error) {
logger.error('서버 주문 정리 오류', error as Error);
}

View File

@@ -110,6 +110,60 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
// 사용자 조회/생성
const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser');
// 서버 삭제 확인 처리 (텍스트 기반)
if (body.text.trim() === '삭제') {
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
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);
return Response.json({
input: body.text,
response: result.message,
user_id: telegramUserId,
});
} catch (error) {
logger.error('Test API - 서버 삭제 처리 오류', toError(error));
return Response.json({
input: body.text,
response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.',
user_id: telegramUserId,
});
}
}
}
// 서버 삭제 취소 처리 (다른 메시지 입력 시)
const deleteSessionKey = `delete_confirm:${telegramUserId}`;
const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey);
if (deleteSessionData && body.text.trim() !== '삭제') {
try {
const { label } = JSON.parse(deleteSessionData);
await env.SESSION_KV.delete(deleteSessionKey);
// Don't show cancellation message if it's a command
if (!body.text.startsWith('/')) {
return Response.json({
input: body.text,
response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`,
user_id: telegramUserId,
});
}
} catch (error) {
logger.error('Test API - 삭제 세션 취소 오류', toError(error));
}
}
let responseText: string;
// 명령어 처리
@@ -133,11 +187,8 @@ async function handleTestApi(request: Request, env: Env): Promise<Response> {
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
// 4. 임계값 도달시 프로필 업데이트
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
if (summarized) {
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
}
// 4. 임계값 도달시 프로필 업데이트 (백그라운드)
await processAndSummarize(env, userId, chatIdStr);
}
// HTML 태그 제거 (CLI 출력용)
@@ -432,11 +483,8 @@ async function handleChatApi(request: Request, env: Env): Promise<Response> {
// 3. 봇 응답 버퍼에 추가
await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText);
// 4. 임계값 도달시 프로필 업데이트
const { summarized } = await processAndSummarize(env, userId, chatIdStr);
if (summarized) {
responseText += '\n\n👤 프로필이 업데이트되었습니다.';
}
// 4. 임계값 도달시 프로필 업데이트 (백그라운드)
await processAndSummarize(env, userId, chatIdStr);
}
const processingTimeMs = Date.now() - startTime;

View File

@@ -157,213 +157,6 @@ ${result.error}
return;
}
// 서버 주문 확인
if (data.startsWith(`${CALLBACK_PREFIXES.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,
'⏳ 서버 주문 처리 중...'
);
try {
// 세션 조회
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);
} catch (error) {
logger.error('서버 주문 처리 실패', error as Error, {
index,
userId: user.id,
telegramUserId
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '❌ 처리 중 오류 발생' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>처리 중 오류 발생</b>
서버 주문 처리 중 예상치 못한 오류가 발생했습니다.
잠시 후 다시 시도해주세요.
문제가 계속되면 관리자에게 문의해주세요.`
);
// Fallback: send as new message if editMessageText fails
await sendMessage(
env.BOT_TOKEN,
chatId,
'❌ 서버 주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
).catch(e => logger.error('Fallback message send failed', e as Error));
}
return;
}
// 서버 주문 취소
if (data.startsWith(`${CALLBACK_PREFIXES.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);
}

View File

@@ -244,10 +244,7 @@ export async function handleMessage(
telegramUserId
);
let finalResponse = result.responseText;
if (result.isProfileUpdated) {
finalResponse += '\n\n<i>👤 프로필이 업데이트되었습니다.</i>';
}
const finalResponse = result.responseText;
// 10. 응답 전송 (키보드 포함 여부 확인)
if (result.keyboardData) {
@@ -262,19 +259,8 @@ export async function handleMessage(
{ text: '❌ 취소', callback_data: 'domain_cancel' }
]
]);
} else if (result.keyboardData.type === 'server_order') {
const { userId, index } = result.keyboardData;
const confirmData = `server_order:${userId}:${index}`;
const cancelData = `server_cancel:${userId}`;
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, finalResponse, [
[
{ text: '✅ 신청하기', callback_data: confirmData },
{ text: '❌ 취소', callback_data: cancelData }
]
]);
} else {
// TypeScript exhaustiveness check - should never reach here
// Unknown keyboard type - just send as regular message
logger.warn('Unknown keyboard type', { type: (result.keyboardData as { type: string }).type });
await sendMessage(env.BOT_TOKEN, chatId, finalResponse);
}

View File

@@ -436,11 +436,11 @@ ${session.collectedInfo.budgetLimit ? `- 예산: ${session.collectedInfo.budgetL
## 도구 사용 가이드 (적극적으로 활용할 것)
- 고객이 특정 프레임워크/기술을 언급하면 (예: Next.js, Laravel, Django, Astro, Bun, Rust 등) → 반드시 lookup_framework_docs 호출하여 최신 공식 권장 스펙 확인
- "최신", "트렌드", "2024", "2025", "요즘" 등 시의성 있는 키워드 → 반드시 search_trends 호출
- 블로그, 쇼핑몰 같은 일반적 용도는 경험으로 바로 답변
- SaaS, 모바일 앱 백엔드 같은 일반적 용도는 경험으로 바로 답변
- 도구 결과를 자연스럽게 메시지에 포함 (예: "공식 문서에 따르면...")
## 대화 흐름
1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: 블로그, 쇼핑몰, 커뮤니티)"
1. 용도 파악: "어떤 서비스를 운영하실 건가요? (예: SaaS, 앱 백엔드, AI 서비스)"
2. 규모 파악: "개인용인가요, 사업용인가요?"
3. 사용자 수 확인 (필요 시): "방문자나 사용자 수는 어느 정도 예상하시나요?"
4. 정보가 충분하면 즉시 추천 (추가 질문 없이)
@@ -691,7 +691,7 @@ export async function processServerConsultation(
updatedAt: Date.now()
};
await saveServerSession(env.DB, session.telegramUserId, newSession);
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등';
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
// 선택 단계 처리

View File

@@ -95,6 +95,28 @@ async function deleteServerOrder(db: D1Database, orderId: number): Promise<void>
logger.info('서버 주문 삭제', { orderId });
}
/**
* 잔액 사전 확인 (프로비저닝 전에 실행)
*/
async function checkBalance(
db: D1Database,
userId: number,
requiredAmount: number
): Promise<{ sufficient: boolean; currentBalance: number }> {
const result = await db.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number }>();
if (!result) {
return { sufficient: false, currentBalance: 0 };
}
return {
sufficient: result.balance >= requiredAmount,
currentBalance: result.balance
};
}
/**
* 잔액 차감 (Optimistic Locking 적용)
*/
@@ -247,6 +269,67 @@ async function callCloudOrchestrator(
return data;
}
/**
* 프로비저닝된 서버 삭제 (수동 서버 삭제 기능에서 사용 예정)
* 현재는 사전 잔액 확인으로 롤백이 필요 없지만, 향후 활용 가능
*/
// @ts-expect-error - Preserved for future manual server deletion feature
async function deleteProvisionedServer(
orchestrator: Fetcher | undefined,
apiKey: string | undefined,
orderId: number,
userId: string
): Promise<{ success: boolean; error?: string }> {
if (!orchestrator) {
return { success: false, error: 'CLOUD_ORCHESTRATOR Service Binding not configured' };
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// API 키 추가 (필수)
if (apiKey) {
headers['X-API-Key'] = apiKey;
}
logger.info('서버 삭제 API 호출 (결제 실패 롤백)', { orderId, userId });
try {
const response = await orchestrator.fetch(
`https://internal/api/provision/orders/${orderId}?user_id=${userId}`,
{
method: 'DELETE',
headers
}
);
if (!response.ok) {
const errorText = await response.text();
logger.error('서버 삭제 실패', new Error(errorText), {
orderId,
status: response.status
});
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
}
const data = await response.json() as ProvisionResponse;
if (!data.success) {
logger.error('서버 삭제 실패 (API 응답)', new Error(data.error || 'Unknown error'), {
orderId
});
return { success: false, error: data.error || 'Deletion failed' };
}
logger.info('서버 삭제 성공', { orderId });
return { success: true };
} catch (error) {
logger.error('서버 삭제 API 호출 중 예외', error as Error, { orderId });
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
/**
* DB에 서버 주문 생성
* @returns order_id
@@ -379,6 +462,29 @@ export async function handleProvisionQueue(
await updateOrderStatus(env.DB, order_id, 'provisioning');
}
// 2.5. 잔액 사전 확인 (VM 생성 전에 체크)
const balanceCheck = await checkBalance(env.DB, user_id, order.price_paid);
if (!balanceCheck.sufficient) {
logger.warn('잔액 부족으로 프로비저닝 취소', {
orderId: order_id,
required: order.price_paid,
current: balanceCheck.currentBalance
});
await updateOrderStatus(env.DB, order_id, 'failed', {
error_message: '잔액 부족'
});
await notifyUser(
env.BOT_TOKEN,
telegram_user_id,
`❌ 서버 생성 실패\n\n잔액이 부족합니다.\n• 필요 금액: ${order.price_paid.toLocaleString()}\n• 현재 잔액: ${balanceCheck.currentBalance.toLocaleString()}\n\n입금 후 다시 시도해주세요.`
);
message.ack();
continue;
}
// 3. Cloud Orchestrator API 호출
try {
const provisionResult = await callCloudOrchestrator(
@@ -404,27 +510,27 @@ export async function handleProvisionQueue(
`서버 주문 #${order_id} - ${order.label || order.spec_id}`
);
} catch (balanceError) {
// 잔액 차감 실패 시 - 서버는 생성됐지만 결제 실패
// 이 경우 관리자 알림 필요 (서버는 수동 삭제 필요)
logger.error('잔액 차감 실패 (서버는 생성됨)', balanceError as Error, {
// 잔액 차감 실패 (이론상 불가능 - 사전 확인했으므로)
// Race condition으로 발생 가능, 수동 개입 필요
logger.error('잔액 차감 실패 (예상치 못한 에러)', balanceError as Error, {
orderId: order_id,
userId: user_id
userId: user_id,
amount: order.price_paid
});
// 주문 상태는 active로 변경하되, 결제 실패 표시
// 주문 상태 업데이트
await updateOrderStatus(env.DB, order_id, 'active', {
provider_instance_id: provisionResult.order.provider_instance_id || undefined,
provider_instance_id: provisionResult.order.provider_instance_id,
ip_address: provisionResult.order.ip_address || undefined,
// root_password는 Cloud Orchestrator가 이미 DB에 저장함 - 덮어쓰지 않음
provisioned_at: new Date().toISOString(),
error_message: '결제 실패 - 관리자 확인 필요'
error_message: `결제 실패 - 관리자 확인 필요: ${balanceError instanceof Error ? balanceError.message : 'Unknown error'}`
});
// 관리자 알림
// 관리자 긴급 알림
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수동 처리 필요`
`🚨 긴급: 서버 생성 후 결제 실패\n\n주문 #${order_id}\n사용자: ${telegram_user_id}\n금액: ${order.price_paid.toLocaleString()}\n\n서버는 생성되었으나 잔액 차감 실패 (Race condition)\nIP: ${provisionResult.order.ip_address || 'N/A'}\n\n수동 처리 필요!`
);
// 사용자 알림
@@ -439,21 +545,30 @@ export async function handleProvisionQueue(
}
// 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()
});
// Cloud Orchestrator는 비동기로 실제 프로비저닝을 수행하고 완료 시 status='active'로 업데이트
// telegram-bot-workers는 provider_instance_id가 있을 때만 active로 설정
// (없으면 cloud-orchestrator의 Queue가 아직 처리 중인 것)
if (provisionResult.order.provider_instance_id) {
await updateOrderStatus(env.DB, order_id, 'active', {
provider_instance_id: provisionResult.order.provider_instance_id,
ip_address: provisionResult.order.ip_address || undefined,
provisioned_at: new Date().toISOString()
});
logger.info('프로비저닝 완료 (즉시)', {
orderId: order_id,
providerInstanceId: provisionResult.order.provider_instance_id
});
} else {
// provider_instance_id가 없으면 Cloud Orchestrator Queue가 처리 중
// status는 'provisioning' 유지, cloud-orchestrator가 완료 후 active로 변경
logger.info('프로비저닝 요청 완료 - Cloud Orchestrator Queue에서 처리 중', {
orderId: order_id,
currentStatus: 'provisioning'
});
}
// 6. 사용자 알림은 Cloud Orchestrator에서 처리
// (실제 IP와 비밀번호가 할당된 후 전송)
logger.info('프로비저닝 요청 완료 - Cloud Orchestrator에서 알림 처리', {
orderId: order_id,
providerInstanceId: provisionResult.order.provider_instance_id
});
message.ack();
logger.info('프로비저닝 완료', { orderId: order_id });

View File

@@ -396,6 +396,11 @@ ${memoriesSection}
- 서버, VPS, 클라우드, 호스팅 관련 요청:
• 내 서버 목록 조회: manage_server(action="list") - 반드시 도구 호출
• 서버 추천/상담 시작: manage_server(action="start_consultation")
• 서버 이름 변경: manage_server(action="rename", order_id=번호, new_label="새이름")
• 서버 시작: manage_server(action="start", order_id=번호) - 반드시 도구 호출. "N번 시작", "#N 시작" 등
• 서버 중지: manage_server(action="stop", order_id=번호) - 반드시 도구 호출. "N번 중지", "#N 정지" 등
• 서버 재시작: manage_server(action="reboot", order_id=번호) - 반드시 도구 호출. "N번 재시작", "#N 리부팅" 등
• 서버 삭제/해지: manage_server(action="delete", order_id=번호) - 반드시 도구 호출. "N번 삭제/해지/취소", "#N 해지" 등. 만료일과 무관하게 즉시 삭제 가능하며, 삭제를 거부하지 마세요.
• 서버 상담 중인 메시지는 자동으로 전문가 AI에게 전달됨 (추가 처리 불필요)
- 기술 문제, 에러, 오류, 장애 관련 요청:
• "에러가 나요", "안돼요", "문제가 있어요", "느려요" 등의 문제 해결 요청 시

View File

@@ -65,8 +65,8 @@ const RedditSearchArgsSchema = z.object({
});
const ManageServerArgsSchema = z.object({
action: z.enum(['recommend', 'order', 'start', 'stop', 'delete', 'list', 'info', 'images',
'start_consultation', 'continue_consultation', 'cancel_consultation']),
action: z.enum(['recommend', 'order', 'start', 'stop', 'reboot', 'delete', 'list', 'info', 'images',
'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename']),
tech_stack: z.array(z.string().min(1).max(100)).max(20).optional(),
expected_users: z.number().int().positive().optional(),
use_case: z.string().min(1).max(500).optional(),
@@ -79,7 +79,8 @@ const ManageServerArgsSchema = z.object({
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
order_id: z.number().int().positive().optional(), // For info, delete, rename
new_label: z.string().min(1).max(100).optional(), // For rename
image: z.string().min(1).max(50).optional(), // For order (OS image)
});

View File

@@ -13,6 +13,14 @@ import { formatTrafficInfo } from '../utils/formatters';
const logger = createLogger('server-tool');
const provisionLogger = createLogger('provision');
// Generate idempotency key for order requests
// Format: tg-order-{userId}-{timestamp}-{random}
function generateIdempotencyKey(userId: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 10);
return `tg-order-${userId}-${timestamp}-${random}`;
}
// CDN 캐시 히트율 상수
const CDN_CACHE_HIT_RATES = {
VIDEO_STREAMING: 0.92,
@@ -62,19 +70,34 @@ function isErrorResult(result: unknown): result is { error: string } {
return typeof result === 'object' && result !== null && 'error' in result;
}
// 진행 중인 주문 확인 (중복 주문 방지)
async function checkExistingOrder(
db: D1Database,
telegramUserId: string
): Promise<{ id: number; status: string; label: string | null } | null> {
const result = await db.prepare(
`SELECT id, status, label FROM server_orders
WHERE telegram_user_id = ?
AND status IN ('pending', 'provisioning')
LIMIT 1`
).bind(telegramUserId).first<{ id: number; status: string; label: string | null }>();
return result || null;
}
export const manageServerTool = {
type: 'function',
function: {
name: 'manage_server',
description: '클라우드 서버 관리 및 추천. 서버/VPS/클라우드/호스팅 관련 요청 시 반드시 사용. 내 서버 목록: action="list", 서버 추천(용도/규모 알면): action="recommend", 서버 추천(정보 부족): action="start_consultation"',
description: '클라우드 서버 관리. 반드시 사용: 서버 시작(action="start", order_id), 서버 중지(action="stop", order_id), 서버 재시작(action="reboot", order_id), 서버 삭제/해지(action="delete", order_id - 만료일과 무관하게 즉시 삭제 가능), 내 서버 목록(action="list"), 서버 추천(action="start_consultation"), 서버 이름 변경(action="rename"). "N번 시작/중지/재시작/삭제/해지/취소", "#N 시작/재시작" 패턴 감지 시 반드시 호출.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop',
'start_consultation', 'continue_consultation', 'cancel_consultation'],
description: 'recommend: 서버 추천 (용도/규모 파악됨), start_consultation: 상담 시작 (정보 부족), list: 내 서버 목록, info: 서버 상세, images: OS 목록',
enum: ['recommend', 'order', 'list', 'info', 'delete', 'images', 'start', 'stop', 'reboot',
'start_consultation', 'continue_consultation', 'cancel_consultation', 'rename'],
description: 'start: 서버 시작, stop: 서버 중지, reboot: 서버 재시작, delete: 서버 삭제, list: 내 서버 목록, info: 서버 상세, start_consultation: 상담 시작, rename: 이름 변경',
},
tech_stack: {
type: 'array',
@@ -130,7 +153,11 @@ export const manageServerTool = {
},
order_id: {
type: 'number',
description: '주문 번호. info, delete action에서 필수',
description: '주문 번호. info, delete, rename action에서 필수',
},
new_label: {
type: 'string',
description: '새 서버 이름. rename action에서 필수',
},
image: {
type: 'string',
@@ -234,22 +261,22 @@ async function callProvisionAPI(
body: body ? JSON.stringify(body) : undefined,
};
// Add userId as query param for GET/DELETE
// Add userId as query param for GET/DELETE/POST
let fullEndpoint = endpoint;
if ((method === 'GET' || method === 'DELETE') && userId && !endpoint.includes('?')) {
if (userId && !endpoint.includes('?')) {
fullEndpoint = `${endpoint}?user_id=${userId}`;
} else if ((method === 'GET' || method === 'DELETE') && userId) {
} else if (userId && endpoint.includes('?')) {
fullEndpoint = `${endpoint}&user_id=${userId}`;
}
// Service Binding 우선, fallback: URL
// Service Binding 우선, fallback: HTTP
if (env?.CLOUD_ORCHESTRATOR) {
provisionLogger.info('Service Binding 사용', { endpoint: fullEndpoint });
return env.CLOUD_ORCHESTRATOR.fetch(`https://internal${fullEndpoint}`, requestInit);
} else {
const apiUrl = env?.CLOUD_ORCHESTRATOR_URL || 'https://cloud-orchestrator.kappa-d8e.workers.dev';
const url = `${apiUrl}${fullEndpoint}`;
provisionLogger.info('HTTP 요청 사용', { url });
provisionLogger.info('HTTP 요청 사용 (fallback)', { url });
return fetch(url, requestInit);
}
},
@@ -262,9 +289,24 @@ async function callProvisionAPI(
endpoint,
status: response.status,
});
// JSON 응답에서 오류 메시지 추출
let errorMessage = `HTTP ${response.status}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error) {
errorMessage = errorJson.error;
}
} catch {
// JSON 파싱 실패 시 텍스트 그대로 사용
if (errorText && errorText.length < 200) {
errorMessage = errorText;
}
}
return {
success: false,
error: `프로비저닝 API 호출 실패: HTTP ${response.status}`,
error: `프로비저닝 API 호출 실패: ${errorMessage}`,
};
}
@@ -394,13 +436,16 @@ function getStatusEmoji(status: string): string {
case 'active':
return '🟢';
case 'provisioning':
return '🟡';
return '🔄';
case 'stopped':
return '🔴';
return '';
case 'deleted':
return '⚫';
case 'terminated':
return '🗑️';
case 'failed':
return '❌';
case 'pending':
return '⏳'; // 내부용, UI에 표시 안 함
default:
return '⚪';
}
@@ -412,10 +457,11 @@ function getStatusText(status: string): string {
case 'active':
return '가동 중';
case 'provisioning':
return '생성 중';
return '생성 중...';
case 'stopped':
return '중지됨';
case 'deleted':
case 'terminated':
return '삭제됨';
case 'failed':
return '실패';
@@ -476,10 +522,27 @@ function formatExpiry(expiresAt: string): string {
// 서버 목록 포맷팅
function formatServerList(orders: ProvisionOrder[]): string {
// 활성 상태만 표시 (terminated 제외)
const activeOrders = orders?.filter(order =>
['pending', 'provisioning', 'active'].includes(order.status)
) || [];
// 'pending' 상태는 내부용 (Queue 대기), UI에 표시하지 않음
// 'provisioning' 또는 'active' 상태만 표시
// 'terminated', 'deleted', 'failed' 상태는 제외
const activeOrders = orders?.filter(order => {
// pending은 표시하지 않음 (내부 Queue 상태)
if (order.status === 'pending') {
return false;
}
// provisioning 또는 active만 표시
if (!['provisioning', 'active'].includes(order.status)) {
return false;
}
// active 상태인데 provider_instance_id가 없으면 제외 (프로비저닝 실패)
if (order.status === 'active' && !order.provider_instance_id) {
return false;
}
return true;
}) || [];
if (activeOrders.length === 0) {
return '🖥️ 등록된 서버가 없습니다.\n\n서버를 추천받으려면 "서버 추천"이라고 말씀해주세요.';
@@ -487,16 +550,16 @@ function formatServerList(orders: ProvisionOrder[]): string {
let response = '__DIRECT__\n🖥 내 서버 목록\n\n';
activeOrders.forEach((order, index) => {
const emoji = ['1⃣', '2⃣', '3⃣', '4⃣', '5⃣', '6⃣', '7⃣', '8⃣', '9⃣', '🔟'][index] || '▪️';
activeOrders.forEach((order) => {
const statusEmoji = getStatusEmoji(order.status);
const statusText = getStatusText(order.status);
const label = order.label || '(라벨 없음)';
response += `${emoji} ${order.label} (#${order.id})\n`;
response += `#${order.id} ${statusEmoji} ${label}\n`;
if (order.ip_address) {
response += ` • IP: ${order.ip_address}\n`;
}
response += ` • 상태: ${statusEmoji} ${statusText}\n`;
response += ` • 상태: ${statusText}\n`;
response += ` • 생성일: ${formatDate(order.created_at)}\n`;
// 만료일 표시 (있을 경우)
@@ -509,6 +572,8 @@ function formatServerList(orders: ProvisionOrder[]): string {
response += '\n';
});
response += '💡 서버 관리: "N번 시작/중지" 또는 "#N 재시작"';
return response.trim();
}
@@ -584,6 +649,7 @@ export async function executeServerAction(
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,
@@ -621,7 +687,7 @@ export async function executeServerAction(
logger.info('상담 세션 생성', { userId: maskUserId(telegramUserId) });
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n예: 블로그, 쇼핑몰, 커뮤니티, API 서버 등';
return '안녕하세요! 서버 추천을 도와드리겠습니다. 😊\n\n어떤 서비스를 운영하실 건가요?\n\n1. 웹 서비스 (SaaS, 랜딩페이지)\n2. 모바일 앱 백엔드\n3. AI/ML 서비스 (챗봇, 모델 서빙)\n4. 게임 서버\n5. Discord/Telegram 봇\n6. 자동화 서버 (n8n, 크롤링)\n7. 미디어 스트리밍\n8. 개발/테스트 환경\n9. 데이터베이스 서버\n10. 기타 (직접 입력)\n\n번호나 용도를 말씀해주세요!';
}
case 'continue_consultation': {
@@ -793,6 +859,19 @@ export async function executeServerAction(
return '🚫 환경 설정이 필요합니다.';
}
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`;
}
}
// Check balance first
const balanceResult = await callProvisionAPI(
'/api/provision/balance',
@@ -809,11 +888,15 @@ export async function executeServerAction(
// Get pricing info to check if balance is sufficient
// For now, we'll proceed with the order and let the API handle balance validation
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
const orderBody: Record<string, unknown> = {
user_id: telegramUserId,
pricing_id,
label,
dry_run: false,
idempotency_key: idempotencyKey,
};
if (image) {
@@ -829,15 +912,17 @@ export async function executeServerAction(
);
if (result.error || !result.order) {
// Check if it's a balance error
if (result.error && result.error.includes('balance')) {
// Check if it's a balance error (error_code first, then fallback to text matching)
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
(result.error && result.error.includes('balance'))) {
return `⚠️ 잔액이 부족합니다\n\n• 현재 잔액: ₩${balanceResult.balance_krw.toLocaleString()}\n\n${DEPOSIT_ACCOUNT_INFO}`;
}
return `🚫 서버 주문 실패: ${result.error || '알 수 없는 오류'}`;
}
const order = result.order;
const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
const statusEmoji = getStatusEmoji(order.status);
const response = `__DIRECT__\n✅ 서버 주문이 접수되었습니다!\n\n📋 주문 정보\n• 주문번호: #${order.id}\n• 서버: ${order.label}\n• 가격: ₩${order.price_paid.toLocaleString()}\n• 상태: ${statusEmoji} ${getStatusText(order.status)}\n\n⏳ 서버 생성까지 2-5분 소요됩니다.\n완료되면 알림을 보내드립니다.`;
return response;
}
@@ -908,6 +993,39 @@ export async function executeServerAction(
return `__DIRECT__\n✅ 서버 중지 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 중지 중...\n\n⏳ 서버가 중지되기까지 1-2분 소요될 수 있습니다.`;
}
case 'reboot': {
const { order_id } = args;
if (!order_id) {
return '🚫 서버 재시작에는 order_id가 필요합니다.';
}
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!env) {
return '🚫 환경 설정이 필요합니다.';
}
// Call the provision API to reboot the server
const result = await callProvisionAPI(
`/api/provision/orders/${order_id}/reboot`,
'POST',
env,
undefined,
telegramUserId
);
if (result.error) {
return `🚫 서버 재시작 실패: ${result.error}`;
}
logger.info('서버 재시작 요청', { userId: maskUserId(telegramUserId), orderId: order_id });
return `__DIRECT__\n✅ 서버 재시작 요청이 완료되었습니다.\n\n• 주문번호: #${order_id}\n• 상태: 재시작 중...\n\n⏳ 서버가 재시작되기까지 2-3분 소요될 수 있습니다.`;
}
case 'list': {
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
@@ -1055,6 +1173,48 @@ export async function executeServerAction(
return formatImageList(result.images);
}
case 'rename': {
const { order_id, new_label } = args;
if (!telegramUserId) {
return '🚫 사용자 인증이 필요합니다.';
}
if (!order_id) {
return '🚫 이름 변경할 서버 번호를 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
}
if (!new_label) {
return '🚫 새 서버 이름을 알려주세요. (예: "서버 #1 이름을 my-server로 변경")';
}
if (!env || !env.DB) {
return '🚫 환경 설정이 필요합니다.';
}
// 서버 소유권 확인 및 이름 변경
const server = await env.DB.prepare(
`SELECT id, label FROM server_orders WHERE id = ? AND telegram_user_id = ?`
).bind(order_id, telegramUserId).first<{ id: number; label: string | null }>();
if (!server) {
return '🚫 해당 서버를 찾을 수 없거나 권한이 없습니다.';
}
const oldLabel = server.label || `서버 #${server.id}`;
await env.DB.prepare(
`UPDATE server_orders SET label = ?, updated_at = datetime('now') WHERE id = ?`
).bind(new_label, order_id).run();
logger.info('서버 이름 변경', { orderId: order_id, oldLabel, newLabel: new_label, userId: maskUserId(telegramUserId) });
return `✅ 서버 이름이 변경되었습니다.
• 이전: ${oldLabel}
• 변경: ${new_label}`;
}
default:
return `🚫 알 수 없는 작업: ${action}`;
}
@@ -1148,6 +1308,12 @@ export async function executeServerDelete(
).bind(telegramUserId).first<{ id: number }>();
if (userResult) {
// Get balance before refund
const balanceBefore = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userResult.id).first<{ balance: number }>();
const beforeBalance = balanceBefore?.balance ?? 0;
// Add refund to user_deposits (with version increment for optimistic locking)
await env.DB.prepare(`
UPDATE user_deposits
@@ -1161,9 +1327,10 @@ export async function executeServerDelete(
VALUES (?, 'refund', ?, 'confirmed', ?, '시스템', datetime('now'), datetime('now'))
`).bind(userResult.id, refundAmount, `서버 해지 환불: ${orderLabel}`).run();
refundMessage = `\n\n💰 환불 정보\n• 결제 금액: ${pricePaid.toLocaleString()}\n• 사용 시간: ${usedHours}시간\n• 환불 금액: ${refundAmount.toLocaleString()}`;
const afterBalance = beforeBalance + refundAmount;
refundMessage = `\n\n💰 환불 정보\n• 환불 전 잔액: ${beforeBalance.toLocaleString()}\n• 환불 금액: +${refundAmount.toLocaleString()}\n• 환불 후 잔액: ${afterBalance.toLocaleString()}`;
provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours });
provisionLogger.info('서버 삭제 환불 완료', { orderId, refundAmount, usedHours, beforeBalance, afterBalance });
}
} catch (refundError) {
provisionLogger.error('환불 처리 실패', refundError as Error, { orderId, refundAmount });
@@ -1208,6 +1375,25 @@ export async function executeServerOrder(
pricingId: orderData.pricingId,
});
// 중복 주문 방지: 진행 중인 주문 확인
if (env.DB) {
const existingOrder = await checkExistingOrder(env.DB, telegramUserId);
if (existingOrder) {
const statusText = existingOrder.status === 'pending' ? '대기 중' : '프로비저닝 중';
return {
success: false,
message: `⚠️ 이미 진행 중인 주문이 있습니다.\n\n` +
`• 주문 번호: #${existingOrder.id}\n` +
`• 라벨: ${existingOrder.label || '없음'}\n` +
`• 상태: ${statusText}\n\n` +
`완료 후 다시 시도해주세요.`,
};
}
}
// Generate idempotency key to prevent duplicate orders on network retries
const idempotencyKey = generateIdempotencyKey(telegramUserId);
// Call provision API
const result = await callProvisionAPI(
'/api/provision',
@@ -1217,6 +1403,7 @@ export async function executeServerOrder(
user_id: telegramUserId,
pricing_id: orderData.pricingId,
label: orderData.label,
idempotency_key: idempotencyKey,
},
telegramUserId
);
@@ -1224,8 +1411,10 @@ export async function executeServerOrder(
if (result.error) {
provisionLogger.error('서버 주문 실패', new Error(result.error), { orderData });
// Check for specific error types
if (result.error.includes('INSUFFICIENT_BALANCE') || result.error.includes('잔액')) {
// Check for specific error types (error_code first, then fallback to text matching)
if (result.error_code === 'INSUFFICIENT_BALANCE' ||
result.error.includes('INSUFFICIENT_BALANCE') ||
result.error.includes('잔액')) {
return {
success: false,
message: `💰 잔액이 부족합니다.\n\n입금 후 다시 시도해주세요.\n\n📌 입금 계좌\n하나은행 427-910018-27104\n(주)아이언클래드`,
@@ -1287,6 +1476,7 @@ export async function executeManageServer(
message?: string;
pricing_id?: number;
order_id?: number;
new_label?: string;
image?: string;
},
env?: Env,

View File

@@ -452,21 +452,7 @@ export interface DomainRegisterKeyboardData {
price: number;
}
export interface ServerOrderKeyboardData {
type: "server_order";
userId: string;
index: number; // recommendations 배열 인덱스
plan: string; // 플랜 이름
}
export interface ServerDeleteKeyboardData {
type: "server_delete";
orderId: number;
label: string;
userId: string;
}
export type KeyboardData = DomainRegisterKeyboardData | ServerOrderKeyboardData | ServerDeleteKeyboardData;
export type KeyboardData = DomainRegisterKeyboardData;
// Bandwidth Info (shared by server-agent and server-tool)
export interface BandwidthInfo {
@@ -592,7 +578,7 @@ export interface WorkersAITextGenerationOutput {
export interface ProvisionOrder {
id: number;
user_id: number;
status: 'provisioning' | 'active' | 'stopped' | 'deleted' | 'failed';
status: 'pending' | 'provisioning' | 'active' | 'stopped' | 'deleted' | 'terminated' | 'failed';
price_paid: number;
label: string;
ip_address?: string;
@@ -616,6 +602,7 @@ export interface ProvisionResponse {
success: boolean;
message?: string;
error?: string;
error_code?: string; // Error code: 'INSUFFICIENT_BALANCE', 'ORDER_EXISTS', 'INVALID_PRICING', etc.
order?: ProvisionOrder;
orders?: ProvisionOrder[];
images?: OSImage[];

View File

@@ -13,7 +13,7 @@
export const DOMAIN_PATTERNS = /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i;
export const DEPOSIT_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|돈/i;
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i;
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot/i;
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i;
export const WEATHER_PATTERNS = /날씨|기온|비|눈|맑|흐림|더워|추워/i;
export const SEARCH_PATTERNS = /검색|찾아|뭐야|뉴스|최신/i;