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

@@ -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,