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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user