feat: add server ordering system with session-based flow

- Add server recommendation integration (SERVER_RECOMMEND worker)
- Implement KV-based session management for multi-step ordering
- Add Linode/Vultr API clients for server provisioning
- Add server-tool for Function Calling support

refactor: major code reorganization (Phase 1-3)

- Remove 443 lines of deprecated callback handlers
- Extract handlers to separate files (message-handler, callback-handler)
- Extract cloud-spec-service, server-recommend-service
- Centralize constants (OS_IMAGES, REGION_FLAGS, NUM_EMOJIS)
- webhook.ts reduced from 1,951 to 30 lines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-24 21:01:38 +09:00
parent dab279c765
commit 6563ee0650
14 changed files with 4019 additions and 241 deletions

View File

@@ -0,0 +1,758 @@
import { answerCallbackQuery, editMessageText, sendMessage, sendMessageWithKeyboard } from '../../telegram';
import { UserService } from '../../services/user-service';
import { executeDomainRegister } from '../../domain-register';
import { executeServerProvision } from '../../server-provision';
import {
getSessionForUser,
updateSession,
deleteSession,
createSession,
ServerOrderSessionData,
} from '../../utils/session';
import { getServerSpec } from '../../services/cloud-spec-service';
import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server';
import type { Env, TelegramUpdate } from '../../types';
/**
* Safely parse integer with range validation
* @param value - String to parse
* @param min - Minimum allowed value (inclusive)
* @param max - Maximum allowed value (inclusive)
* @returns Parsed integer or null if invalid/out of range
*/
function parseIntSafe(value: string, min: number, max: number): number | null {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < min || parsed > max) {
return null;
}
return parsed;
}
/**
* 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 price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW
if (price === null) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' });
return;
}
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('srv:')) {
const parts = data.split(':');
const sessionId = parts[1];
const action = parts[2];
// 세션 조회 + 권한 검증
const session = await getSessionForUser<ServerOrderSessionData>(
env.SESSION_KV,
sessionId,
user.id
);
if (!session) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, {
text: '세션이 만료되었습니다. 다시 시작해주세요.'
});
await editMessageText(env.BOT_TOKEN, chatId, messageId,
'⏰ 세션이 만료되었습니다.\n\n💡 "서버 추천해줘"라고 다시 말씀해주세요.'
);
return;
}
// select: 사양 선택 (추천 목록에서)
if (action === 'select') {
const index = parseInt(parts[3], 10);
const recs = session.data.recommendations;
if (!recs || index < 0 || index >= recs.length) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 선택입니다.' });
return;
}
const selected = recs[index];
// 세션 업데이트
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'spec_confirm',
plan: selected.plan,
region: selected.region,
provider: selected.provider,
recommendations: undefined // 선택 후 목록 삭제
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 조회 중...' });
// CLOUD_DB에서 상세 조회
const spec = await getServerSpec(
env.CLOUD_DB,
selected.plan,
selected.region,
selected.provider
);
if (!spec) {
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
return;
}
// 가격 정보 세션에 저장
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
priceKrw: spec.monthly_price_krw
});
const ramGB = (spec.memory_mb / 1024).toFixed(1);
const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유';
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`📦 <b>서버 사양 확인</b>
<b>컴퓨팅</b>
• vCPU: ${spec.vcpu}
• RAM: ${ramGB}GB
• 스토리지: ${spec.storage_gb}GB SSD
<b>네트워크</b>
• 트래픽: ${spec.transfer_tb}TB/월
• 대역폭: ${networkSpeed}
<b>요금</b>
• 월 ${spec.monthly_price_krw.toLocaleString()}
이 사양으로 진행하시겠습니까?`,
{
reply_markup: {
inline_keyboard: [
[{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }],
[
{ text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` },
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
]
]
}
}
);
return;
}
// os_list: OS 선택 화면
if (action === 'os_list') {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택' });
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'os_select'
});
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`🖥️ <b>OS 선택</b>\n\n서버에 설치할 운영체제를 선택하세요:`,
{
reply_markup: {
inline_keyboard: [
[{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }],
[{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }],
[{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }],
[{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }],
[
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` },
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
]
]
}
}
);
return;
}
// os: OS 선택 완료 → 주문 생성 + 최종 확인
if (action === 'os') {
const osImage = parts[3];
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'final_confirm',
image: osImage
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 생성 중...' });
const { plan, region, provider } = session.data;
// DB에서 사양 조회
const spec = await getServerSpec(
env.CLOUD_DB,
plan!,
region!,
provider!
);
if (!spec) {
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
return;
}
// 잔액 확인
const deposit = await env.DB.prepare(
"SELECT balance FROM user_deposits WHERE user_id = ?"
).bind(user.id).first<{ balance: number }>();
const balance = deposit?.balance || 0;
if (balance < spec.monthly_price_krw) {
const shortage = spec.monthly_price_krw - balance;
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>잔액 부족</b>
• 필요 금액: ${spec.monthly_price_krw.toLocaleString()}
• 현재 잔액: ${balance.toLocaleString()}
• 부족 금액: ${shortage.toLocaleString()}
💳 <b>입금 계좌</b>
하나은행 427-910018-27104 (주식회사 아이언클래드)
입금 후 다시 시도해주세요.`
);
return;
}
// 주문 생성
const label = `server-${Date.now()}`;
const orderResult = await env.DB.prepare(`
INSERT INTO server_orders (user_id, spec_id, status, label, region, image, price_paid, billing_type, created_at)
VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly', datetime('now'))
`).bind(user.id, spec.pricing_id, label, region, osImage, spec.monthly_price_krw).run();
const orderId = orderResult.meta?.last_row_id;
if (!orderId) {
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 주문 생성에 실패했습니다.');
return;
}
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
orderId: orderId
});
// OS 이름 변환
const ramGB = (spec.memory_mb / 1024).toFixed(1);
const specStr = `${spec.vcpu} vCPU / ${ramGB}GB RAM / ${spec.storage_gb}GB SSD`;
// 최종 확인 화면
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`✅ <b>최종 확인</b>
• 사양: <b>${specStr}</b>
• OS: ${getOSDisplayName(osImage)}
• 가격: ${spec.monthly_price_krw.toLocaleString()}원/월
• 현재 잔액: ${balance.toLocaleString()}
💡 <b>요금 안내</b>
• 월 선불제이며, 중도 해지 시 시간당 요금으로 정산 후 환불됩니다.
• 예: 10일 사용 후 해지 → (시간당 요금 × 사용 시간) 차감 후 잔액 환불`,
{
reply_markup: {
inline_keyboard: [
[{ text: '✅ 서버 생성', callback_data: `srv:${sessionId}:confirm` }],
[
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_os` },
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
]
]
}
}
);
return;
}
// confirm: 서버 생성 실행
if (action === 'confirm') {
const { orderId } = session.data;
if (!orderId) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 정보가 없습니다.' });
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
await editMessageText(env.BOT_TOKEN, chatId, messageId,
'⏳ 서버를 생성하고 있습니다... (1-3분 소요)'
);
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
// 세션 삭제
await deleteSession(env.SESSION_KV, sessionId);
if (result.success) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`✅ <b>서버 생성 완료!</b>
• 사양: <code>${result.plan_label}</code>
• 리전: ${result.region}
• IP 주소: <code>${result.ip_address}</code>
• Root 비밀번호: <code>${result.root_password}</code>
📌 <b>접속 방법</b>
<code>ssh root@${result.ip_address}</code>
⚠️ <b>보안 권고</b>
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
2. SSH 키 인증 설정을 권장합니다.
3. 방화벽(UFW)을 활성화하세요.
🎉 서버가 성공적으로 생성되었습니다!`
);
} else {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>서버 생성 실패</b>
${result.error}
다시 시도하시려면 서버 주문을 요청해주세요.`
);
}
return;
}
// cancel: 취소
if (action === 'cancel') {
const { orderId } = session.data;
// pending 주문 있으면 삭제
if (orderId) {
await env.DB.prepare(
"UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'"
).bind(orderId, user.id).run();
}
// 세션 삭제
await deleteSession(env.SESSION_KV, sessionId);
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
await editMessageText(env.BOT_TOKEN, chatId, messageId,
'❌ 서버 선택이 취소되었습니다.\n\n다시 추천받으시려면 "서버 추천해줘"라고 말씀해주세요.'
);
return;
}
// reselect: 다른 사양 선택 (다시 추천 API 호출)
if (action === 'reselect') {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '다시 추천받는 중...' });
await editMessageText(env.BOT_TOKEN, chatId, messageId, '🔄 다시 추천받는 중...');
// 기존 세션 삭제
await deleteSession(env.SESSION_KV, sessionId);
try {
// SERVER_RECOMMEND 서비스로 기본 추천 요청
const requestBody = {
tech_stack: ['nginx'],
expected_users: 100,
use_case: 'general purpose server',
lang: 'ko'
};
const response = env.SERVER_RECOMMEND
? await env.SERVER_RECOMMEND.fetch('https://internal/api/recommend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
: await fetch(env.SERVER_RECOMMEND_API_URL || 'https://server-recommend.kappa-d8e.workers.dev/api/recommend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const apiResult = await response.json() as {
recommendations?: Array<{
server: {
instance_id: string;
region_code: string;
provider_name: string;
vcpu: number;
memory_mb: number;
storage_gb: number;
monthly_price: number;
};
score: number;
}>;
};
if (!apiResult.recommendations || apiResult.recommendations.length === 0) {
throw new Error('No recommendations');
}
// 상위 5개 추천
const topRecs = apiResult.recommendations.slice(0, 5);
let responseText = `🎯 <b>범용</b> 서버 추천\n\n`;
topRecs.forEach((rec, index) => {
const server = rec.server;
const ramGB = (server.memory_mb / 1024).toFixed(1);
const priceKrw = Math.round(server.monthly_price);
const regionDisplay = getRegionDisplay(server.region_code);
responseText += `${NUM_EMOJIS[index]} <b>${server.vcpu} vCPU / ${ramGB}GB RAM / ${server.storage_gb}GB SSD</b>\n`;
responseText += ` ${regionDisplay} | 💰 ${priceKrw.toLocaleString()}원/월 | 🎯 AI 점수: ${rec.score}/100\n\n`;
});
responseText += `👆 <b>서버를 신청하려면 아래 버튼을 선택하세요</b>\n\n💡 다른 조건을 원하시면 "웹서버용 서버 추천" 형식으로 말씀해주세요.`;
// 새 세션 생성
const newSessionId = await createSession<ServerOrderSessionData>(
env.SESSION_KV,
user.id,
'server_order',
{
recommendations: topRecs.map(rec => ({
plan: rec.server.instance_id,
region: rec.server.region_code,
provider: rec.server.provider_name.toLowerCase()
}))
},
'recommend'
);
// 버튼 생성
const buttons = topRecs.map((_, index) => ({
text: `${index + 1}번 선택`,
callback_data: `srv:${newSessionId}:select:${index}`
}));
const keyboard = [];
if (buttons.length > 0) keyboard.push(buttons.slice(0, 3));
if (buttons.length > 3) keyboard.push(buttons.slice(3));
await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, keyboard);
} catch (error) {
console.error('[srv:reselect] 추천 API 오류:', error);
await sendMessage(
env.BOT_TOKEN,
chatId,
'❌ 추천을 다시 받는 중 오류가 발생했습니다.\n\n💡 "서버 추천해줘"라고 말씀해주세요.'
);
}
return;
}
// back_spec: 사양 확인으로 뒤로
if (action === 'back_spec') {
const { plan, region, provider } = session.data;
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'spec_confirm',
image: undefined
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사양 확인으로 이동' });
// 사양 상세 다시 조회 및 표시
const spec = await getServerSpec(
env.CLOUD_DB,
plan!,
region!,
provider!
);
if (!spec) {
await editMessageText(env.BOT_TOKEN, chatId, messageId, '❌ 사양을 찾을 수 없습니다.');
return;
}
const ramGB = (spec.memory_mb / 1024).toFixed(1);
const networkSpeed = spec.network_speed_gbps ? `${spec.network_speed_gbps} Gbps` : '공유';
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`📦 <b>서버 사양 확인</b>
<b>컴퓨팅</b>
• vCPU: ${spec.vcpu}
• RAM: ${ramGB}GB
• 스토리지: ${spec.storage_gb}GB SSD
<b>네트워크</b>
• 트래픽: ${spec.transfer_tb}TB/월
• 대역폭: ${networkSpeed}
<b>요금</b>
• 월 ${spec.monthly_price_krw.toLocaleString()}
이 사양으로 진행하시겠습니까?`,
{
reply_markup: {
inline_keyboard: [
[{ text: '✅ 다음 단계 (OS 선택)', callback_data: `srv:${sessionId}:os_list` }],
[
{ text: '◀️ 다른 사양 선택', callback_data: `srv:${sessionId}:reselect` },
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
]
]
}
}
);
return;
}
// back_os: OS 선택으로 뒤로
if (action === 'back_os') {
const { orderId } = session.data;
// pending 주문 삭제
if (orderId) {
await env.DB.prepare(
"DELETE FROM server_orders WHERE id = ? AND user_id = ? AND status = 'pending'"
).bind(orderId, user.id).run();
}
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'os_select',
image: undefined,
orderId: undefined
});
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: 'OS 선택으로 이동' });
// OS 선택 화면 표시
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`🖥️ <b>OS 선택</b>\n\n서버에 설치할 운영체제를 선택하세요:`,
{
reply_markup: {
inline_keyboard: [
[{ text: '🐧 Ubuntu 22.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-22.04` }],
[{ text: '🐧 Ubuntu 24.04 LTS', callback_data: `srv:${sessionId}:os:ubuntu-24.04` }],
[{ text: '🎩 Debian 12', callback_data: `srv:${sessionId}:os:debian-12` }],
[{ text: '🎯 CentOS Stream 9', callback_data: `srv:${sessionId}:os:centos-stream-9` }],
[
{ text: '◀️ 뒤로', callback_data: `srv:${sessionId}:back_spec` },
{ text: '❌ 취소', callback_data: `srv:${sessionId}:cancel` }
]
]
}
}
);
return;
}
// 알 수 없는 action
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' });
return;
}
// 서버 주문 확인
if (data.startsWith('server_order:')) {
const parts = data.split(':');
if (parts.length !== 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const orderId = parseIntSafe(parts[1], 1, 2147483647); // Max INT
if (orderId === null) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' });
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'⏳ 서버를 생성하고 있습니다... (1-3분 소요)'
);
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
if (result.success) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`✅ <b>서버 생성 완료!</b>
• 사양: <code>${result.plan_label}</code>
• 리전: ${result.region}
• IP 주소: <code>${result.ip_address}</code>
• Root 비밀번호: <code>${result.root_password}</code>
📌 <b>접속 방법</b>
<code>ssh root@${result.ip_address}</code>
⚠️ <b>보안 권고</b>
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
2. SSH 키 인증 설정을 권장합니다.
3. 방화벽(UFW)을 활성화하세요.
🎉 서버가 성공적으로 생성되었습니다!`
);
} else {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>서버 생성 실패</b>
${result.error}
다시 시도하시려면 서버 주문을 요청해주세요.`
);
}
return;
}
// 서버 주문 취소
if (data.startsWith('server_cancel:')) {
const parts = data.split(':');
if (parts.length !== 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
return;
}
const orderId = parseIntSafe(parts[1], 1, 2147483647);
if (orderId === null) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 주문 ID입니다.' });
return;
}
// 주문 취소 처리 (DB에서 status를 cancelled로 변경)
const cancelResult = await env.DB.prepare(
"UPDATE server_orders SET status = 'cancelled', terminated_at = datetime('now') WHERE id = ? AND user_id = ? AND status = 'pending'"
).bind(orderId, user.id).run();
if (cancelResult.success && cancelResult.meta?.changes && cancelResult.meta.changes > 0) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'❌ 서버 주문이 취소되었습니다.'
);
} else {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소 실패 (이미 처리됨)' });
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'⚠️ 주문 취소에 실패했습니다. (이미 처리되었거나 권한이 없습니다.)'
);
}
return;
}
// ===== 이하 기존 핸들러는 레거시 주문 전용 (새로운 세션 기반 플로우는 srv:로 시작) =====
await answerCallbackQuery(env.BOT_TOKEN, queryId);
}