From 3a671a57073cf0be9e710989e47dbb6074b28be8 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 29 Jan 2026 10:34:12 +0900 Subject: [PATCH] refactor: add pattern utils and split api.ts into modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Pattern Detection Utility (src/utils/patterns.ts) - Centralize tool category patterns (domain, deposit, server, etc.) - Add memory category patterns (company, tech, role) - Add region detection (korea, japan, singapore, us) - Add tech stack detection (postgres, redis, nodejs, etc.) - Export detectToolCategories(), detectRegion(), detectTechStack() 2. API Route Modularization (src/routes/api/) - deposit.ts: /balance, /deduct with apiKeyAuth middleware - chat.ts: /test, /chat with session handling - contact.ts: Contact form with CORS middleware - metrics.ts: Circuit Breaker status endpoint 3. Updates - tools/index.ts: Use detectToolCategories from patterns.ts - api.ts: Compose sub-routers (899 → 53 lines, 94% reduction) Benefits: - Single source of truth for patterns - Better code organization - Easier maintenance and testing Co-Authored-By: Claude Opus 4.5 --- src/routes/api.ts | 875 +------------------------------------- src/routes/api/chat.ts | 473 +++++++++++++++++++++ src/routes/api/contact.ts | 132 ++++++ src/routes/api/deposit.ts | 215 ++++++++++ src/routes/api/metrics.ts | 77 ++++ src/tools/index.ts | 20 +- src/utils/patterns.ts | 195 +++++++++ 7 files changed, 1111 insertions(+), 876 deletions(-) create mode 100644 src/routes/api/chat.ts create mode 100644 src/routes/api/contact.ts create mode 100644 src/routes/api/deposit.ts create mode 100644 src/routes/api/metrics.ts create mode 100644 src/utils/patterns.ts diff --git a/src/routes/api.ts b/src/routes/api.ts index dd5ed03..5aac01d 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -1,829 +1,19 @@ -import { z } from 'zod'; import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { createMiddleware } from 'hono/factory'; import { Env } from '../types'; -import { sendMessage } from '../telegram'; -import { - addToBuffer, - processAndSummarize, - generateAIResponse, -} from '../summary-service'; -import { handleCommand } from '../commands'; -import { openaiCircuitBreaker } from '../openai-service'; -import { createLogger } from '../utils/logger'; -import { toError } from '../utils/error'; -import { timingSafeEqual } from '../security'; - -const logger = createLogger('api'); - -// Zod schemas for API request validation -const DepositDeductBodySchema = z.object({ - telegram_id: z.string(), - amount: z.number().positive(), - reason: z.string(), - reference_id: z.string().optional(), -}); - -const TestApiBodySchema = z.object({ - text: z.string(), - user_id: z.string().optional(), - secret: z.string().optional(), -}); - -const ContactFormBodySchema = z.object({ - email: z.string().email(), - message: z.string(), - name: z.string().optional(), -}); - -const ChatApiBodySchema = z.object({ - message: z.string(), - chat_id: z.number().optional(), - user_id: z.number().optional(), - username: z.string().optional(), -}); - -/** - * API Key 인증 미들웨어 (Timing-safe comparison으로 타이밍 공격 방지) - * X-API-Key 헤더로 DEPOSIT_API_SECRET 검증 - */ -const apiKeyAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => { - const apiSecret = c.env.DEPOSIT_API_SECRET; - const authHeader = c.req.header('X-API-Key'); - - if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) { - logger.warn('API Key 인증 실패', { hasApiKey: !!authHeader }); - return c.json({ error: 'Unauthorized' }, 401); - } - - return await next(); -}); - -/** - * CORS 헤더 생성 - */ -function getCorsHeaders(env: Env): Record { - const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; - return { - 'Access-Control-Allow-Origin': allowedOrigin, - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }; -} - -// 사용자 조회/생성 -async function getOrCreateUser( - db: D1Database, - telegramId: string, - firstName: string, - username?: string -): Promise { - const existing = await db - .prepare('SELECT id FROM users WHERE telegram_id = ?') - .bind(telegramId) - .first<{ id: number }>(); - - if (existing) { - // 마지막 활동 시간 업데이트 - await db - .prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?') - .bind(existing.id) - .run(); - return existing.id; - } - - // 새 사용자 생성 - const result = await db - .prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)') - .bind(telegramId, firstName, username || null) - .run(); - - return result.meta.last_row_id as number; -} - -/** - * GET /api/deposit/balance - 잔액 조회 (namecheap-api 전용) - * - * @param env - Environment bindings - * @param url - Parsed URL - * @returns JSON response with balance - */ -async function handleDepositBalance(env: Env, url: URL): Promise { - try { - const telegramId = url.searchParams.get('telegram_id'); - if (!telegramId) { - return Response.json({ error: 'telegram_id required' }, { status: 400 }); - } - - // 사용자 조회 - const user = await env.DB.prepare( - 'SELECT id FROM users WHERE telegram_id = ?' - ).bind(telegramId).first<{ id: number }>(); - - if (!user) { - return Response.json({ error: 'User not found' }, { status: 404 }); - } - - // 잔액 조회 - const deposit = await env.DB.prepare( - 'SELECT balance FROM user_deposits WHERE user_id = ?' - ).bind(user.id).first<{ balance: number }>(); - - return Response.json({ - telegram_id: telegramId, - balance: deposit?.balance || 0, - }); - } catch (error) { - logger.error('Deposit balance error', toError(error)); - return Response.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/deposit/deduct - 잔액 차감 (namecheap-api 전용) - * - * @param request - HTTP Request with body - * @param env - Environment bindings - * @returns JSON response with transaction result - */ -async function handleDepositDeduct(request: Request, env: Env): Promise { - try { - // JSON 파싱 (별도 에러 핸들링) - let jsonData: unknown; - try { - jsonData = await request.json(); - } catch { - return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); - } - - const parseResult = DepositDeductBodySchema.safeParse(jsonData); - - if (!parseResult.success) { - logger.warn('Deposit deduct - Invalid request body', { errors: parseResult.error.issues }); - return Response.json({ - error: 'Invalid request body', - details: parseResult.error.issues - }, { status: 400 }); - } - - const body = parseResult.data; - - // 사용자 조회 - const user = await env.DB.prepare( - 'SELECT id FROM users WHERE telegram_id = ?' - ).bind(body.telegram_id).first<{ id: number }>(); - - if (!user) { - return Response.json({ error: 'User not found' }, { status: 404 }); - } - - // 현재 잔액과 version 확인 (Optimistic Locking) - const current = await env.DB.prepare( - 'SELECT balance, version FROM user_deposits WHERE user_id = ?' - ).bind(user.id).first<{ balance: number; version: number }>(); - - if (!current) { - return Response.json({ error: 'User deposit account not found' }, { status: 404 }); - } - - if (current.balance < body.amount) { - return Response.json({ - error: 'Insufficient balance', - current_balance: current.balance, - required: body.amount, - }, { status: 400 }); - } - - // Optimistic Locking: version 조건으로 잔액 차감 - const balanceUpdate = await env.DB.prepare( - 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' - ).bind(body.amount, user.id, current.version).run(); - - if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { - logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', { - userId: user.id, - telegram_id: body.telegram_id, - amount: body.amount, - expectedVersion: current.version, - context: 'api_deposit_deduct' - }); - return Response.json({ - error: 'Concurrent modification detected', - message: '동시 요청 감지 - 다시 시도해주세요' - }, { status: 409 }); - } - - // 거래 기록 INSERT - const transactionInsert = await env.DB.prepare( - `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) - VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` - ).bind(user.id, body.amount, body.reason).run(); - - if (!transactionInsert.success) { - // 잔액 복구 시도 (rollback) - try { - await env.DB.prepare( - 'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' - ).bind(body.amount, user.id).run(); - - logger.error('거래 기록 INSERT 실패 - 잔액 복구 완료', undefined, { - userId: user.id, - telegram_id: body.telegram_id, - amount: body.amount, - reason: body.reason, - context: 'api_deposit_deduct_rollback' - }); - } catch (rollbackError) { - logger.error('잔액 복구 실패 - 수동 확인 필요', toError(rollbackError), { - userId: user.id, - telegram_id: body.telegram_id, - amount: body.amount, - context: 'api_deposit_deduct_rollback_failed' - }); - } - - return Response.json({ - error: 'Transaction processing failed', - message: '거래 처리 실패 - 관리자에게 문의하세요' - }, { status: 500 }); - } - - const newBalance = current.balance - body.amount; - - logger.info('Deposit deducted', { - telegram_id: body.telegram_id, - amount: body.amount, - reason: body.reason, - new_balance: newBalance - }); - - return Response.json({ - success: true, - telegram_id: body.telegram_id, - deducted: body.amount, - previous_balance: current.balance, - new_balance: newBalance, - }); - } catch (error) { - logger.error('Deposit deduct error', toError(error)); - return Response.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/test - 테스트 API (메시지 처리 후 응답 직접 반환) - * - * ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화 - * - * @param request - HTTP Request with body - * @param env - Environment bindings - * @returns JSON response with AI response - */ -async function handleTestApi(request: Request, env: Env): Promise { - // 개발/테스트 환경에서만 활성화 (명시적 설정 필수) - if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') { - return new Response('Not Found', { status: 404 }); - } - - try { - // JSON 파싱 (별도 에러 핸들링) - let jsonData: unknown; - try { - jsonData = await request.json(); - } catch { - return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); - } - - const parseResult = TestApiBodySchema.safeParse(jsonData); - - if (!parseResult.success) { - logger.warn('Test API - Invalid request body', { errors: parseResult.error.issues }); - return Response.json({ - error: 'Invalid request body', - details: parseResult.error.issues - }, { status: 400 }); - } - - const body = parseResult.data; - - // 인증 (Timing-safe comparison 사용) - if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (!body.text) { - return Response.json({ error: 'text required' }, { status: 400 }); - } - - const telegramUserId = body.user_id || '821596605'; - const chatIdStr = telegramUserId; - - // 사용자 조회/생성 - const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser'); - - let responseText: string; - - // 명령어 처리 - if (body.text.startsWith('/')) { - const [command, ...argParts] = body.text.split(' '); - const args = argParts.join(' '); - responseText = await handleCommand(env, userId, chatIdStr, command, args); - } else { - // 1. 사용자 메시지 버퍼에 추가 - await addToBuffer(env.DB, userId, chatIdStr, 'user', body.text); - - // 2. AI 응답 생성 - responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId); - - // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) - if (responseText.includes('__DIRECT__')) { - const directIndex = responseText.indexOf('__DIRECT__'); - responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); - } - - // 3. 봇 응답 버퍼에 추가 - await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); - - // 4. 임계값 도달시 프로필 업데이트 - const { summarized } = await processAndSummarize(env, userId, chatIdStr); - if (summarized) { - responseText += '\n\n👤 프로필이 업데이트되었습니다.'; - } - } - - // HTML 태그 제거 (CLI 출력용) - const plainText = responseText.replace(/<[^>]*>/g, ''); - - return Response.json({ - input: body.text, - response: plainText, - user_id: telegramUserId, - }); - } catch (error) { - logger.error('Test API error', toError(error)); - return Response.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/chat - 인증된 채팅 API (프로덕션 활성화) - * - * @param request - HTTP Request with body - * @param env - Environment bindings - * @returns JSON response with AI response - */ -async function handleChatApi(request: Request, env: Env): Promise { - const startTime = Date.now(); - - try { - // Bearer Token 인증 (Timing-safe comparison으로 타이밍 공격 방지) - const authHeader = request.headers.get('Authorization'); - const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`; - if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) { - logger.warn('Chat API - Unauthorized access attempt', { hasAuthHeader: !!authHeader }); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // JSON 파싱 (별도 에러 핸들링) - let jsonData: unknown; - try { - jsonData = await request.json(); - } catch { - return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); - } - - const parseResult = ChatApiBodySchema.safeParse(jsonData); - - if (!parseResult.success) { - logger.warn('Chat API - Invalid request body', { errors: parseResult.error.issues }); - return Response.json({ - error: 'Invalid request body', - details: parseResult.error.issues - }, { status: 400 }); - } - - const body = parseResult.data; - - if (!body.message) { - return Response.json({ error: 'message required' }, { status: 400 }); - } - - // 기본값 설정 - const telegramUserId = body.user_id?.toString() || '821596605'; - const chatId = body.chat_id || 821596605; - const chatIdStr = chatId.toString(); - const username = body.username || 'web-tester'; - - // 사용자 조회/생성 - const userId = await getOrCreateUser(env.DB, telegramUserId, 'WebUser', username); - - let responseText: string; - - // 서버 삭제 확인 처리 (텍스트 기반) - if (body.message.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); - - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: result.message, - processing_time_ms: processingTimeMs, - }); - } catch (error) { - logger.error('Chat API - 서버 삭제 처리 오류', toError(error)); - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.', - processing_time_ms: processingTimeMs, - }); - } - } - } - - // 서버 삭제 취소 처리 (다른 메시지 입력 시) - const deleteSessionKey = `delete_confirm:${telegramUserId}`; - const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey); - - if (deleteSessionData && body.message.trim() !== '삭제') { - try { - const { label } = JSON.parse(deleteSessionData); - await env.SESSION_KV.delete(deleteSessionKey); - - // Don't show cancellation message if it's a command (let command handler process it) - if (!body.message.startsWith('/')) { - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`, - processing_time_ms: processingTimeMs, - }); - } - } catch (error) { - logger.error('Chat API - 삭제 세션 취소 오류', toError(error)); - } - } - - // 서버 신청 확인 처리 (텍스트 기반) - Queue 기반 - if (body.message.trim() === '신청') { - const orderSessionKey = `server_order_confirm:${telegramUserId}`; - logger.info('신청 세션 확인', { orderSessionKey, telegramUserId }); - const orderSessionData = await env.SESSION_KV.get(orderSessionKey); - logger.info('신청 세션 데이터', { found: !!orderSessionData, data: orderSessionData?.slice(0, 100) }); - - if (orderSessionData) { - try { - const orderData = JSON.parse(orderSessionData); - - // 1. 서버 세션에서 가격 정보 가져오기 - const { getServerSession, deleteServerSession } = await import('../server-agent'); - const session = await getServerSession(env.DB, telegramUserId); - - if (!session || !session.lastRecommendation) { - await env.SESSION_KV.delete(orderSessionKey); - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.', - processing_time_ms: processingTimeMs, - }); - } - - const selected = session.lastRecommendation.recommendations[orderData.index]; - if (!selected) { - await env.SESSION_KV.delete(orderSessionKey); - await deleteServerSession(env.DB, telegramUserId); - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: '❌ 선택한 서버를 찾을 수 없습니다.', - processing_time_ms: processingTimeMs, - }); - } - - const price = selected.price?.monthly_krw || 0; - - // 2. 잔액 확인 - const deposit = await env.DB.prepare( - 'SELECT balance FROM user_deposits WHERE user_id = ?' - ).bind(userId).first<{ balance: number }>(); - - if (!deposit || deposit.balance < price) { - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: - `❌ 잔액이 부족합니다.\n\n` + - `• 서버 가격: ${price.toLocaleString()}원/월\n` + - `• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` + - `• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` + - `잔액을 충전 후 다시 시도해주세요.`, - processing_time_ms: processingTimeMs, - }); - } - - // 3. Queue 확인 - if (!env.SERVER_PROVISION_QUEUE) { - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.', - processing_time_ms: processingTimeMs, - }); - } - - // 4. 주문 생성 및 Queue 전송 - const { createServerOrder, sendProvisionMessage } = await import('../server-provision'); - - const orderId = await createServerOrder( - env.DB, - userId, - telegramUserId, - selected.pricing_id, - selected.region.code, - 'anvil', - price, - `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` - ); - - await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); - - // 5. 세션 정리 - await env.SESSION_KV.delete(orderSessionKey); - await deleteServerSession(env.DB, telegramUserId); - - // 6. 즉시 응답 - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: - `📋 서버 주문 접수 완료! (주문 #${orderId})\n\n` + - `• 서버: ${selected.plan_name}\n` + - `• 리전: ${selected.region.name} (${selected.region.code})\n` + - `• 가격: ${price.toLocaleString()}원/월\n\n` + - `⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` + - `완료되면 메시지로 알려드릴게요.`, - processing_time_ms: processingTimeMs, - }); - } catch (error) { - logger.error('Chat API - 서버 신청 처리 오류', toError(error)); - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.', - processing_time_ms: processingTimeMs, - }); - } - } - } - - // 서버 신청 취소 처리 (다른 메시지 입력 시) - const orderSessionKey = `server_order_confirm:${telegramUserId}`; - const orderSessionData = await env.SESSION_KV.get(orderSessionKey); - - if (orderSessionData && body.message.trim() !== '신청') { - try { - const { plan } = JSON.parse(orderSessionData); - await env.SESSION_KV.delete(orderSessionKey); - - // Don't show cancellation message if it's a command - if (!body.message.startsWith('/')) { - const processingTimeMs = Date.now() - startTime; - - return Response.json({ - success: true, - response: `⏹️ 서버 신청이 취소되었습니다.\n\n신청하려던 서버: ${plan}`, - processing_time_ms: processingTimeMs, - }); - } - } catch (error) { - logger.error('Chat API - 신청 세션 취소 오류', toError(error)); - } - } - - // 명령어 처리 - if (body.message.startsWith('/')) { - const [command, ...argParts] = body.message.split(' '); - const args = argParts.join(' '); - responseText = await handleCommand(env, userId, chatIdStr, command, args); - } else { - // 1. 사용자 메시지 버퍼에 추가 - await addToBuffer(env.DB, userId, chatIdStr, 'user', body.message); - - // 2. AI 응답 생성 - responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId); - - // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) - if (responseText.includes('__DIRECT__')) { - const directIndex = responseText.indexOf('__DIRECT__'); - responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); - } - - // 3. 봇 응답 버퍼에 추가 - await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); - - // 4. 임계값 도달시 프로필 업데이트 - const { summarized } = await processAndSummarize(env, userId, chatIdStr); - if (summarized) { - responseText += '\n\n👤 프로필이 업데이트되었습니다.'; - } - } - - const processingTimeMs = Date.now() - startTime; - - logger.info('Chat API request processed', { - user_id: telegramUserId, - username, - message_length: body.message.length, - processing_time_ms: processingTimeMs, - }); - - return Response.json({ - success: true, - response: responseText, - processing_time_ms: processingTimeMs, - }); - } catch (error) { - const processingTimeMs = Date.now() - startTime; - logger.error('Chat API error', toError(error), { processing_time_ms: processingTimeMs }); - return Response.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -/** - * POST /api/contact - 문의 폼 API (웹사이트용) - * - * @param request - HTTP Request with body - * @param env - Environment bindings - * @returns JSON response with success status - */ -async function handleContactForm(request: Request, env: Env): Promise { - // CORS 헤더 생성 - const corsHeaders = getCorsHeaders(env); - const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; - - // Origin 헤더 검증 (curl 우회 방지) - const origin = request.headers.get('Origin'); - - if (!origin || origin !== allowedOrigin) { - logger.warn('Contact API - 허용되지 않은 Origin', { origin }); - return Response.json( - { error: 'Forbidden' }, - { status: 403 } - ); - } - - try { - // JSON 파싱 (별도 에러 핸들링) - let jsonData: unknown; - try { - jsonData = await request.json(); - } catch { - return Response.json( - { error: 'Invalid JSON format' }, - { status: 400, headers: corsHeaders } - ); - } - - const parseResult = ContactFormBodySchema.safeParse(jsonData); - - if (!parseResult.success) { - logger.warn('Contact form - Invalid request body', { errors: parseResult.error.issues }); - return Response.json( - { - error: '올바르지 않은 요청 형식입니다.', - details: parseResult.error.issues - }, - { status: 400, headers: corsHeaders } - ); - } - - const body = parseResult.data; - - // 메시지 길이 제한 - if (body.message.length > 2000) { - return Response.json( - { error: '메시지는 2000자 이내로 작성해주세요.' }, - { status: 400, headers: corsHeaders } - ); - } - - // 관리자에게 텔레그램 알림 - const adminId = env.DEPOSIT_ADMIN_ID || env.DOMAIN_OWNER_ID; - if (env.BOT_TOKEN && adminId) { - const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); - await sendMessage( - env.BOT_TOKEN, - parseInt(adminId), - `📬 웹사이트 문의\n\n` + - `📧 이메일: ${body.email}\n` + - `🕐 시간: ${timestamp}\n\n` + - `💬 내용:\n${body.message}` - ); - } - - logger.info('문의 수신', { email: body.email, hasName: !!body.name }); - - return Response.json( - { success: true, message: '문의가 성공적으로 전송되었습니다.' }, - { headers: corsHeaders } - ); - } catch (error) { - logger.error('Contact form error', toError(error)); - return Response.json( - { error: '문의 전송 중 오류가 발생했습니다.' }, - { status: 500, headers: corsHeaders } - ); - } -} - -/** - * GET /api/metrics - Circuit Breaker 상태 조회 (관리자 전용) - * - * @param request - HTTP Request - * @param env - Environment bindings - * @returns JSON response with metrics - */ -async function handleMetrics(request: Request, env: Env): Promise { - try { - // WEBHOOK_SECRET 인증 (Timing-safe comparison으로 타이밍 공격 방지) - const authHeader = request.headers.get('Authorization'); - const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`; - if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) { - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Circuit Breaker 상태 수집 - const openaiStats = openaiCircuitBreaker.getStats(); - - // 메트릭 응답 생성 - const metrics = { - timestamp: new Date().toISOString(), - circuitBreakers: { - openai: { - state: openaiStats.state, - failures: openaiStats.failures, - lastFailureTime: openaiStats.lastFailureTime?.toISOString(), - stats: openaiStats.stats, - config: openaiStats.config, - }, - }, - // 추후 확장 가능: API 호출 통계, 캐시 hit rate 등 - metrics: { - api_calls: { - // 추후 구현: 실제 API 호출 통계 - openai: { count: openaiStats.stats.totalRequests, avg_duration: 0 }, - }, - errors: { - // 추후 구현: 에러 통계 - retry_exhausted: 0, - circuit_breaker_open: openaiStats.state === 'OPEN' ? 1 : 0, - }, - cache: { - // 추후 구현: 캐시 hit rate - hit_rate: 0, - }, - }, - }; - - logger.info('Metrics retrieved', { - state: openaiStats.state, - failures: openaiStats.failures, - requests: openaiStats.stats.totalRequests - }); - - return Response.json(metrics); - } catch (error) { - logger.error('Metrics API error', toError(error)); - return Response.json({ error: 'Internal server error' }, { status: 500 }); - } -} +import { depositRouter } from './api/deposit'; +import { chatRouter } from './api/chat'; +import { contactRouter } from './api/contact'; +import { metricsRouter } from './api/metrics'; /** * API Router (Hono) * + * Organized into sub-modules for maintainability: + * - /deposit/* - Deposit balance & deduction (deposit.ts) + * - /test, /chat - Test & Chat APIs (chat.ts) + * - /contact - Contact form (contact.ts) + * - /metrics - Circuit Breaker metrics (metrics.ts) + * * Manual Test: * 1. wrangler dev * 2. Test deposit balance: @@ -854,45 +44,10 @@ async function handleMetrics(request: Request, env: Env): Promise { */ const api = new Hono<{ Bindings: Env }>(); -// CORS middleware for /contact endpoint -api.use('/contact', cors({ - origin: (origin, c) => { - const allowedOrigin = c.env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; - return origin === allowedOrigin ? origin : null; - }, - allowMethods: ['POST', 'OPTIONS'], - allowHeaders: ['Content-Type'], -})); - -// GET /deposit/balance - 잔액 조회 (namecheap-api 전용, API Key 필요) -api.get('/deposit/balance', apiKeyAuth, async (c) => { - const url = new URL(c.req.url); - return await handleDepositBalance(c.env, url); -}); - -// POST /deposit/deduct - 잔액 차감 (namecheap-api 전용, API Key 필요) -api.post('/deposit/deduct', apiKeyAuth, async (c) => { - return await handleDepositDeduct(c.req.raw, c.env); -}); - -// POST /test - 테스트 API (개발 환경 전용) -api.post('/test', async (c) => { - return await handleTestApi(c.req.raw, c.env); -}); - -// POST /chat - 인증된 채팅 API (프로덕션) -api.post('/chat', async (c) => { - return await handleChatApi(c.req.raw, c.env); -}); - -// POST /contact - 문의 폼 API (웹사이트용) -api.post('/contact', async (c) => { - return await handleContactForm(c.req.raw, c.env); -}); - -// GET /metrics - Circuit Breaker 상태 조회 (관리자 전용) -api.get('/metrics', async (c) => { - return await handleMetrics(c.req.raw, c.env); -}); +// Mount sub-routers +api.route('/deposit', depositRouter); +api.route('/', chatRouter); // /test, /chat +api.route('/contact', contactRouter); +api.route('/metrics', metricsRouter); export { api as apiRouter }; diff --git a/src/routes/api/chat.ts b/src/routes/api/chat.ts new file mode 100644 index 0000000..c3ae59b --- /dev/null +++ b/src/routes/api/chat.ts @@ -0,0 +1,473 @@ +import { z } from 'zod'; +import { Hono } from 'hono'; +import { Env } from '../../types'; +import { + addToBuffer, + processAndSummarize, + generateAIResponse, +} from '../../summary-service'; +import { handleCommand } from '../../commands'; +import { timingSafeEqual } from '../../security'; +import { createLogger } from '../../utils/logger'; +import { toError } from '../../utils/error'; + +const logger = createLogger('api-chat'); + +// Zod schemas for API request validation +const TestApiBodySchema = z.object({ + text: z.string(), + user_id: z.string().optional(), + secret: z.string().optional(), +}); + +const ChatApiBodySchema = z.object({ + message: z.string(), + chat_id: z.number().optional(), + user_id: z.number().optional(), + username: z.string().optional(), +}); + +/** + * 사용자 조회/생성 헬퍼 함수 + */ +async function getOrCreateUser( + db: D1Database, + telegramId: string, + firstName: string, + username?: string +): Promise { + const existing = await db + .prepare('SELECT id FROM users WHERE telegram_id = ?') + .bind(telegramId) + .first<{ id: number }>(); + + if (existing) { + // 마지막 활동 시간 업데이트 + await db + .prepare('UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .bind(existing.id) + .run(); + return existing.id; + } + + // 새 사용자 생성 + const result = await db + .prepare('INSERT INTO users (telegram_id, first_name, username) VALUES (?, ?, ?)') + .bind(telegramId, firstName, username || null) + .run(); + + return result.meta.last_row_id as number; +} + +/** + * POST /test - 테스트 API (메시지 처리 후 응답 직접 반환) + * + * ⚠️ 개발 환경 전용 - 프로덕션에서는 비활성화 + * + * @param request - HTTP Request with body + * @param env - Environment bindings + * @returns JSON response with AI response + */ +async function handleTestApi(request: Request, env: Env): Promise { + // 개발/테스트 환경에서만 활성화 (명시적 설정 필수) + if (env.ENVIRONMENT !== 'development' && env.ENVIRONMENT !== 'test') { + return new Response('Not Found', { status: 404 }); + } + + try { + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); + } + + const parseResult = TestApiBodySchema.safeParse(jsonData); + + if (!parseResult.success) { + logger.warn('Test API - Invalid request body', { errors: parseResult.error.issues }); + return Response.json({ + error: 'Invalid request body', + details: parseResult.error.issues + }, { status: 400 }); + } + + const body = parseResult.data; + + // 인증 (Timing-safe comparison 사용) + if (!timingSafeEqual(body.secret || '', env.WEBHOOK_SECRET || '')) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!body.text) { + return Response.json({ error: 'text required' }, { status: 400 }); + } + + const telegramUserId = body.user_id || '821596605'; + const chatIdStr = telegramUserId; + + // 사용자 조회/생성 + const userId = await getOrCreateUser(env.DB, telegramUserId, 'TestUser', 'testuser'); + + let responseText: string; + + // 명령어 처리 + if (body.text.startsWith('/')) { + const [command, ...argParts] = body.text.split(' '); + const args = argParts.join(' '); + responseText = await handleCommand(env, userId, chatIdStr, command, args); + } else { + // 1. 사용자 메시지 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'user', body.text); + + // 2. AI 응답 생성 + responseText = await generateAIResponse(env, userId, chatIdStr, body.text, telegramUserId); + + // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + + // 3. 봇 응답 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); + + // 4. 임계값 도달시 프로필 업데이트 + const { summarized } = await processAndSummarize(env, userId, chatIdStr); + if (summarized) { + responseText += '\n\n👤 프로필이 업데이트되었습니다.'; + } + } + + // HTML 태그 제거 (CLI 출력용) + const plainText = responseText.replace(/<[^>]*>/g, ''); + + return Response.json({ + input: body.text, + response: plainText, + user_id: telegramUserId, + }); + } catch (error) { + logger.error('Test API error', toError(error)); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * POST /chat - 인증된 채팅 API (프로덕션 활성화) + * + * @param request - HTTP Request with body + * @param env - Environment bindings + * @returns JSON response with AI response + */ +async function handleChatApi(request: Request, env: Env): Promise { + const startTime = Date.now(); + + try { + // Bearer Token 인증 (Timing-safe comparison으로 타이밍 공격 방지) + const authHeader = request.headers.get('Authorization'); + const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`; + if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) { + logger.warn('Chat API - Unauthorized access attempt', { hasAuthHeader: !!authHeader }); + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); + } + + const parseResult = ChatApiBodySchema.safeParse(jsonData); + + if (!parseResult.success) { + logger.warn('Chat API - Invalid request body', { errors: parseResult.error.issues }); + return Response.json({ + error: 'Invalid request body', + details: parseResult.error.issues + }, { status: 400 }); + } + + const body = parseResult.data; + + if (!body.message) { + return Response.json({ error: 'message required' }, { status: 400 }); + } + + // 기본값 설정 + const telegramUserId = body.user_id?.toString() || '821596605'; + const chatId = body.chat_id || 821596605; + const chatIdStr = chatId.toString(); + const username = body.username || 'web-tester'; + + // 사용자 조회/생성 + const userId = await getOrCreateUser(env.DB, telegramUserId, 'WebUser', username); + + let responseText: string; + + // 서버 삭제 확인 처리 (텍스트 기반) + if (body.message.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); + + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: result.message, + processing_time_ms: processingTimeMs, + }); + } catch (error) { + logger.error('Chat API - 서버 삭제 처리 오류', toError(error)); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '🚫 서버 삭제 중 오류가 발생했습니다. 다시 시도해주세요.', + processing_time_ms: processingTimeMs, + }); + } + } + } + + // 서버 삭제 취소 처리 (다른 메시지 입력 시) + const deleteSessionKey = `delete_confirm:${telegramUserId}`; + const deleteSessionData = await env.SESSION_KV.get(deleteSessionKey); + + if (deleteSessionData && body.message.trim() !== '삭제') { + try { + const { label } = JSON.parse(deleteSessionData); + await env.SESSION_KV.delete(deleteSessionKey); + + // Don't show cancellation message if it's a command (let command handler process it) + if (!body.message.startsWith('/')) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: `⏹️ 서버 삭제가 취소되었습니다.\n\n삭제하려던 서버: ${label}`, + processing_time_ms: processingTimeMs, + }); + } + } catch (error) { + logger.error('Chat API - 삭제 세션 취소 오류', toError(error)); + } + } + + // 서버 신청 확인 처리 (텍스트 기반) - Queue 기반 + if (body.message.trim() === '신청') { + const orderSessionKey = `server_order_confirm:${telegramUserId}`; + logger.info('신청 세션 확인', { orderSessionKey, telegramUserId }); + const orderSessionData = await env.SESSION_KV.get(orderSessionKey); + logger.info('신청 세션 데이터', { found: !!orderSessionData, data: orderSessionData?.slice(0, 100) }); + + if (orderSessionData) { + try { + const orderData = JSON.parse(orderSessionData); + + // 1. 서버 세션에서 가격 정보 가져오기 + const { getServerSession, deleteServerSession } = await import('../../server-agent'); + const session = await getServerSession(env.DB, telegramUserId); + + if (!session || !session.lastRecommendation) { + await env.SESSION_KV.delete(orderSessionKey); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 세션이 만료되었습니다.\n다시 "서버 추천"을 시작해주세요.', + processing_time_ms: processingTimeMs, + }); + } + + const selected = session.lastRecommendation.recommendations[orderData.index]; + if (!selected) { + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 선택한 서버를 찾을 수 없습니다.', + processing_time_ms: processingTimeMs, + }); + } + + const price = selected.price?.monthly_krw || 0; + + // 2. 잔액 확인 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(userId).first<{ balance: number }>(); + + if (!deposit || deposit.balance < price) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: + `❌ 잔액이 부족합니다.\n\n` + + `• 서버 가격: ${price.toLocaleString()}원/월\n` + + `• 현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n` + + `• 부족 금액: ${(price - (deposit?.balance || 0)).toLocaleString()}원\n\n` + + `잔액을 충전 후 다시 시도해주세요.`, + processing_time_ms: processingTimeMs, + }); + } + + // 3. Queue 확인 + if (!env.SERVER_PROVISION_QUEUE) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '❌ 서버 프로비저닝 시스템이 준비되지 않았습니다.', + processing_time_ms: processingTimeMs, + }); + } + + // 4. 주문 생성 및 Queue 전송 + const { createServerOrder, sendProvisionMessage } = await import('../../server-provision'); + + const orderId = await createServerOrder( + env.DB, + userId, + telegramUserId, + selected.pricing_id, + selected.region.code, + 'anvil', + price, + `${selected.plan_name} - ${orderData.label || session.collectedInfo?.useCase || 'server'}` + ); + + await sendProvisionMessage(env.SERVER_PROVISION_QUEUE, orderId, userId, telegramUserId); + + // 5. 세션 정리 + await env.SESSION_KV.delete(orderSessionKey); + await deleteServerSession(env.DB, telegramUserId); + + // 6. 즉시 응답 + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: + `📋 서버 주문 접수 완료! (주문 #${orderId})\n\n` + + `• 서버: ${selected.plan_name}\n` + + `• 리전: ${selected.region.name} (${selected.region.code})\n` + + `• 가격: ${price.toLocaleString()}원/월\n\n` + + `⏳ 서버를 생성하고 있습니다... (1-2분 소요)\n` + + `완료되면 메시지로 알려드릴게요.`, + processing_time_ms: processingTimeMs, + }); + } catch (error) { + logger.error('Chat API - 서버 신청 처리 오류', toError(error)); + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: '🚫 서버 신청 중 오류가 발생했습니다. 다시 시도해주세요.', + processing_time_ms: processingTimeMs, + }); + } + } + } + + // 서버 신청 취소 처리 (다른 메시지 입력 시) + const orderSessionKey = `server_order_confirm:${telegramUserId}`; + const orderSessionData = await env.SESSION_KV.get(orderSessionKey); + + if (orderSessionData && body.message.trim() !== '신청') { + try { + const { plan } = JSON.parse(orderSessionData); + await env.SESSION_KV.delete(orderSessionKey); + + // Don't show cancellation message if it's a command + if (!body.message.startsWith('/')) { + const processingTimeMs = Date.now() - startTime; + + return Response.json({ + success: true, + response: `⏹️ 서버 신청이 취소되었습니다.\n\n신청하려던 서버: ${plan}`, + processing_time_ms: processingTimeMs, + }); + } + } catch (error) { + logger.error('Chat API - 신청 세션 취소 오류', toError(error)); + } + } + + // 명령어 처리 + if (body.message.startsWith('/')) { + const [command, ...argParts] = body.message.split(' '); + const args = argParts.join(' '); + responseText = await handleCommand(env, userId, chatIdStr, command, args); + } else { + // 1. 사용자 메시지 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'user', body.message); + + // 2. AI 응답 생성 + responseText = await generateAIResponse(env, userId, chatIdStr, body.message, telegramUserId); + + // __DIRECT__ 마커 제거 (AI가 그대로 전달한 경우 대비) + if (responseText.includes('__DIRECT__')) { + const directIndex = responseText.indexOf('__DIRECT__'); + responseText = responseText.slice(directIndex + '__DIRECT__'.length).trim(); + } + + // 3. 봇 응답 버퍼에 추가 + await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); + + // 4. 임계값 도달시 프로필 업데이트 + const { summarized } = await processAndSummarize(env, userId, chatIdStr); + if (summarized) { + responseText += '\n\n👤 프로필이 업데이트되었습니다.'; + } + } + + const processingTimeMs = Date.now() - startTime; + + logger.info('Chat API request processed', { + user_id: telegramUserId, + username, + message_length: body.message.length, + processing_time_ms: processingTimeMs, + }); + + return Response.json({ + success: true, + response: responseText, + processing_time_ms: processingTimeMs, + }); + } catch (error) { + const processingTimeMs = Date.now() - startTime; + logger.error('Chat API error', toError(error), { processing_time_ms: processingTimeMs }); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const chatRouter = new Hono<{ Bindings: Env }>(); + +// POST /test - 테스트 API (개발 환경 전용) +chatRouter.post('/test', async (c) => { + return await handleTestApi(c.req.raw, c.env); +}); + +// POST /chat - 인증된 채팅 API (프로덕션) +chatRouter.post('/chat', async (c) => { + return await handleChatApi(c.req.raw, c.env); +}); diff --git a/src/routes/api/contact.ts b/src/routes/api/contact.ts new file mode 100644 index 0000000..b9eafee --- /dev/null +++ b/src/routes/api/contact.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { Env } from '../../types'; +import { sendMessage } from '../../telegram'; +import { createLogger } from '../../utils/logger'; +import { toError } from '../../utils/error'; + +const logger = createLogger('api-contact'); + +// Zod schema for contact form validation +const ContactFormBodySchema = z.object({ + email: z.string().email(), + message: z.string(), + name: z.string().optional(), +}); + +/** + * CORS 헤더 생성 + */ +function getCorsHeaders(env: Env): Record { + const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }; +} + +/** + * POST /contact - 문의 폼 API (웹사이트용) + * + * @param request - HTTP Request with body + * @param env - Environment bindings + * @returns JSON response with success status + */ +async function handleContactForm(request: Request, env: Env): Promise { + // CORS 헤더 생성 + const corsHeaders = getCorsHeaders(env); + const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; + + // Origin 헤더 검증 (curl 우회 방지) + const origin = request.headers.get('Origin'); + + if (!origin || origin !== allowedOrigin) { + logger.warn('Contact API - 허용되지 않은 Origin', { origin }); + return Response.json( + { error: 'Forbidden' }, + { status: 403 } + ); + } + + try { + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json( + { error: 'Invalid JSON format' }, + { status: 400, headers: corsHeaders } + ); + } + + const parseResult = ContactFormBodySchema.safeParse(jsonData); + + if (!parseResult.success) { + logger.warn('Contact form - Invalid request body', { errors: parseResult.error.issues }); + return Response.json( + { + error: '올바르지 않은 요청 형식입니다.', + details: parseResult.error.issues + }, + { status: 400, headers: corsHeaders } + ); + } + + const body = parseResult.data; + + // 메시지 길이 제한 + if (body.message.length > 2000) { + return Response.json( + { error: '메시지는 2000자 이내로 작성해주세요.' }, + { status: 400, headers: corsHeaders } + ); + } + + // 관리자에게 텔레그램 알림 + const adminId = env.DEPOSIT_ADMIN_ID || env.DOMAIN_OWNER_ID; + if (env.BOT_TOKEN && adminId) { + const timestamp = new Date().toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); + await sendMessage( + env.BOT_TOKEN, + parseInt(adminId), + `📬 웹사이트 문의\n\n` + + `📧 이메일: ${body.email}\n` + + `🕐 시간: ${timestamp}\n\n` + + `💬 내용:\n${body.message}` + ); + } + + logger.info('문의 수신', { email: body.email, hasName: !!body.name }); + + return Response.json( + { success: true, message: '문의가 성공적으로 전송되었습니다.' }, + { headers: corsHeaders } + ); + } catch (error) { + logger.error('Contact form error', toError(error)); + return Response.json( + { error: '문의 전송 중 오류가 발생했습니다.' }, + { status: 500, headers: corsHeaders } + ); + } +} + +export const contactRouter = new Hono<{ Bindings: Env }>(); + +// CORS middleware for /contact endpoint +contactRouter.use('/*', cors({ + origin: (origin, c) => { + const allowedOrigin = c.env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com'; + return origin === allowedOrigin ? origin : null; + }, + allowMethods: ['POST', 'OPTIONS'], + allowHeaders: ['Content-Type'], +})); + +// POST / - 문의 폼 API (웹사이트용) +contactRouter.post('/', async (c) => { + return await handleContactForm(c.req.raw, c.env); +}); diff --git a/src/routes/api/deposit.ts b/src/routes/api/deposit.ts new file mode 100644 index 0000000..532eb69 --- /dev/null +++ b/src/routes/api/deposit.ts @@ -0,0 +1,215 @@ +import { z } from 'zod'; +import { Hono } from 'hono'; +import { createMiddleware } from 'hono/factory'; +import { Env } from '../../types'; +import { timingSafeEqual } from '../../security'; +import { createLogger } from '../../utils/logger'; +import { toError } from '../../utils/error'; + +const logger = createLogger('api-deposit'); + +// Zod schema for deposit deduct validation +const DepositDeductBodySchema = z.object({ + telegram_id: z.string(), + amount: z.number().positive(), + reason: z.string(), + reference_id: z.string().optional(), +}); + +/** + * API Key 인증 미들웨어 (Timing-safe comparison으로 타이밍 공격 방지) + * X-API-Key 헤더로 DEPOSIT_API_SECRET 검증 + */ +const apiKeyAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => { + const apiSecret = c.env.DEPOSIT_API_SECRET; + const authHeader = c.req.header('X-API-Key'); + + if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) { + logger.warn('API Key 인증 실패', { hasApiKey: !!authHeader }); + return c.json({ error: 'Unauthorized' }, 401); + } + + return await next(); +}); + +/** + * GET /balance - 잔액 조회 (namecheap-api 전용) + * + * @param env - Environment bindings + * @param url - Parsed URL + * @returns JSON response with balance + */ +async function handleDepositBalance(env: Env, url: URL): Promise { + try { + const telegramId = url.searchParams.get('telegram_id'); + if (!telegramId) { + return Response.json({ error: 'telegram_id required' }, { status: 400 }); + } + + // 사용자 조회 + const user = await env.DB.prepare( + 'SELECT id FROM users WHERE telegram_id = ?' + ).bind(telegramId).first<{ id: number }>(); + + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // 잔액 조회 + const deposit = await env.DB.prepare( + 'SELECT balance FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ balance: number }>(); + + return Response.json({ + telegram_id: telegramId, + balance: deposit?.balance || 0, + }); + } catch (error) { + logger.error('Deposit balance error', toError(error)); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +/** + * POST /deduct - 잔액 차감 (namecheap-api 전용) + * + * @param request - HTTP Request with body + * @param env - Environment bindings + * @returns JSON response with transaction result + */ +async function handleDepositDeduct(request: Request, env: Env): Promise { + try { + // JSON 파싱 (별도 에러 핸들링) + let jsonData: unknown; + try { + jsonData = await request.json(); + } catch { + return Response.json({ error: 'Invalid JSON format' }, { status: 400 }); + } + + const parseResult = DepositDeductBodySchema.safeParse(jsonData); + + if (!parseResult.success) { + logger.warn('Deposit deduct - Invalid request body', { errors: parseResult.error.issues }); + return Response.json({ + error: 'Invalid request body', + details: parseResult.error.issues + }, { status: 400 }); + } + + const body = parseResult.data; + + // 사용자 조회 + const user = await env.DB.prepare( + 'SELECT id FROM users WHERE telegram_id = ?' + ).bind(body.telegram_id).first<{ id: number }>(); + + if (!user) { + return Response.json({ error: 'User not found' }, { status: 404 }); + } + + // 현재 잔액과 version 확인 (Optimistic Locking) + const current = await env.DB.prepare( + 'SELECT balance, version FROM user_deposits WHERE user_id = ?' + ).bind(user.id).first<{ balance: number; version: number }>(); + + if (!current) { + return Response.json({ error: 'User deposit account not found' }, { status: 404 }); + } + + if (current.balance < body.amount) { + return Response.json({ + error: 'Insufficient balance', + current_balance: current.balance, + required: body.amount, + }, { status: 400 }); + } + + // Optimistic Locking: version 조건으로 잔액 차감 + const balanceUpdate = await env.DB.prepare( + 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' + ).bind(body.amount, user.id, current.version).run(); + + if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { + logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', { + userId: user.id, + telegram_id: body.telegram_id, + amount: body.amount, + expectedVersion: current.version, + context: 'api_deposit_deduct' + }); + return Response.json({ + error: 'Concurrent modification detected', + message: '동시 요청 감지 - 다시 시도해주세요' + }, { status: 409 }); + } + + // 거래 기록 INSERT + const transactionInsert = await env.DB.prepare( + `INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at) + VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)` + ).bind(user.id, body.amount, body.reason).run(); + + if (!transactionInsert.success) { + // 잔액 복구 시도 (rollback) + try { + await env.DB.prepare( + 'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' + ).bind(body.amount, user.id).run(); + + logger.error('거래 기록 INSERT 실패 - 잔액 복구 완료', undefined, { + userId: user.id, + telegram_id: body.telegram_id, + amount: body.amount, + reason: body.reason, + context: 'api_deposit_deduct_rollback' + }); + } catch (rollbackError) { + logger.error('잔액 복구 실패 - 수동 확인 필요', toError(rollbackError), { + userId: user.id, + telegram_id: body.telegram_id, + amount: body.amount, + context: 'api_deposit_deduct_rollback_failed' + }); + } + + return Response.json({ + error: 'Transaction processing failed', + message: '거래 처리 실패 - 관리자에게 문의하세요' + }, { status: 500 }); + } + + const newBalance = current.balance - body.amount; + + logger.info('Deposit deducted', { + telegram_id: body.telegram_id, + amount: body.amount, + reason: body.reason, + new_balance: newBalance + }); + + return Response.json({ + success: true, + telegram_id: body.telegram_id, + deducted: body.amount, + previous_balance: current.balance, + new_balance: newBalance, + }); + } catch (error) { + logger.error('Deposit deduct error', toError(error)); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const depositRouter = new Hono<{ Bindings: Env }>(); + +// GET /balance - 잔액 조회 (namecheap-api 전용, API Key 필요) +depositRouter.get('/balance', apiKeyAuth, async (c) => { + const url = new URL(c.req.url); + return await handleDepositBalance(c.env, url); +}); + +// POST /deduct - 잔액 차감 (namecheap-api 전용, API Key 필요) +depositRouter.post('/deduct', apiKeyAuth, async (c) => { + return await handleDepositDeduct(c.req.raw, c.env); +}); diff --git a/src/routes/api/metrics.ts b/src/routes/api/metrics.ts new file mode 100644 index 0000000..243a673 --- /dev/null +++ b/src/routes/api/metrics.ts @@ -0,0 +1,77 @@ +import { Hono } from 'hono'; +import { Env } from '../../types'; +import { openaiCircuitBreaker } from '../../openai-service'; +import { timingSafeEqual } from '../../security'; +import { createLogger } from '../../utils/logger'; +import { toError } from '../../utils/error'; + +const logger = createLogger('api-metrics'); + +/** + * GET /metrics - Circuit Breaker 상태 조회 (관리자 전용) + * + * @param request - HTTP Request + * @param env - Environment bindings + * @returns JSON response with metrics + */ +async function handleMetrics(request: Request, env: Env): Promise { + try { + // WEBHOOK_SECRET 인증 (Timing-safe comparison으로 타이밍 공격 방지) + const authHeader = request.headers.get('Authorization'); + const expectedToken = `Bearer ${env.WEBHOOK_SECRET}`; + if (!env.WEBHOOK_SECRET || !timingSafeEqual(authHeader || '', expectedToken)) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Circuit Breaker 상태 수집 + const openaiStats = openaiCircuitBreaker.getStats(); + + // 메트릭 응답 생성 + const metrics = { + timestamp: new Date().toISOString(), + circuitBreakers: { + openai: { + state: openaiStats.state, + failures: openaiStats.failures, + lastFailureTime: openaiStats.lastFailureTime?.toISOString(), + stats: openaiStats.stats, + config: openaiStats.config, + }, + }, + // 추후 확장 가능: API 호출 통계, 캐시 hit rate 등 + metrics: { + api_calls: { + // 추후 구현: 실제 API 호출 통계 + openai: { count: openaiStats.stats.totalRequests, avg_duration: 0 }, + }, + errors: { + // 추후 구현: 에러 통계 + retry_exhausted: 0, + circuit_breaker_open: openaiStats.state === 'OPEN' ? 1 : 0, + }, + cache: { + // 추후 구현: 캐시 hit rate + hit_rate: 0, + }, + }, + }; + + logger.info('Metrics retrieved', { + state: openaiStats.state, + failures: openaiStats.failures, + requests: openaiStats.stats.totalRequests + }); + + return Response.json(metrics); + } catch (error) { + logger.error('Metrics API error', toError(error)); + return Response.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export const metricsRouter = new Hono<{ Bindings: Env }>(); + +// GET / - Circuit Breaker 상태 조회 (관리자 전용) +metricsRouter.get('/', async (c) => { + return await handleMetrics(c.req.raw, c.env); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index a2f1d86..c323312 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ // Tool Registry - All tools exported from here import { z } from 'zod'; import { createLogger } from '../utils/logger'; +import { detectToolCategories } from '../utils/patterns'; const logger = createLogger('tools'); @@ -113,26 +114,13 @@ export const TOOL_CATEGORIES: Record = { utility: [getCurrentTimeTool.function.name, calculateTool.function.name], }; -// Category detection patterns -export const CATEGORY_PATTERNS: Record = { - domain: /도메인|네임서버|whois|dns|tld|등록|\.com|\.net|\.io|\.kr|\.org/i, - deposit: /입금|충전|잔액|계좌|예치금|송금|돈/i, - server: /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr/i, - troubleshoot: /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i, - weather: /날씨|기온|비|눈|맑|흐림|더워|추워/i, - search: /검색|찾아|뭐야|뉴스|최신/i, - reddit: /레딧|reddit|서브레딧|subreddit/i, -}; - // Message-based tool selection export function selectToolsForMessage(message: string): typeof tools { const selectedCategories = new Set(['utility']); // 항상 포함 - for (const [category, pattern] of Object.entries(CATEGORY_PATTERNS)) { - if (pattern.test(message)) { - selectedCategories.add(category); - } - } + // Use centralized pattern detection + const detectedCategories = detectToolCategories(message); + detectedCategories.forEach(cat => selectedCategories.add(cat)); // 패턴 매칭 없으면 유틸리티 도구만 사용 (토큰 절약) if (selectedCategories.size === 1) { // utility만 있으면 diff --git a/src/utils/patterns.ts b/src/utils/patterns.ts new file mode 100644 index 0000000..ddd42ee --- /dev/null +++ b/src/utils/patterns.ts @@ -0,0 +1,195 @@ +/** + * Centralized pattern detection for keyword matching + * + * Purpose: Unified keyword matching patterns used across multiple services: + * - Tool category detection (tools/index.ts) + * - Memory category detection (openai-service.ts) + * - Region/tech stack detection (server-agent.ts) + */ + +// ============================================================================ +// Tool Category Patterns +// ============================================================================ + +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 TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨/i; +export const WEATHER_PATTERNS = /날씨|기온|비|눈|맑|흐림|더워|추워/i; +export const SEARCH_PATTERNS = /검색|찾아|뭐야|뉴스|최신/i; +export const REDDIT_PATTERNS = /레딧|reddit|서브레딧|subreddit/i; + +// ============================================================================ +// Memory Category Patterns +// ============================================================================ + +// Company/workplace patterns +export const COMPANY_PATTERNS = /(?:에서|에)\s*(?:일해|일하고|근무|다녀)/; + +// Tech/learning patterns +export const TECH_LEARNING_PATTERNS = /(?:공부|개발|작업|배우)/; + +// Role patterns +export const ROLE_PATTERNS = /(?:개발자|엔지니어|디자이너|기획자)/; + +// Location patterns +export const LOCATION_PATTERNS = /(?:에서|에)\s*(?:살아|거주|있어)/; + +// Server/infrastructure patterns (for memory) +export const SERVER_INFRA_PATTERNS = /(?:AWS|GCP|Azure|Vultr|Linode|DigitalOcean|클라우드|가비아|카페24|서버\s*\d|트래픽|DAU|MAU|동시접속|쿠버네티스|k8s|도커|docker|컨테이너)/i; + +// ============================================================================ +// Region Patterns (Korean/English/Japanese) +// ============================================================================ + +export const REGION_PATTERNS = { + korea: /한국|서울|korea|seoul|kr\b/i, + japan: /일본|도쿄|tokyo|japan|jp\b/i, + osaka: /오사카|osaka/i, + singapore: /싱가포르|singapore|sg\b/i, + us: /미국|us|america|달라스|프리몬트/i, +} as const; + +// ============================================================================ +// Tech Stack Patterns +// ============================================================================ + +export const TECH_PATTERNS = { + // Databases + postgresql: /postgresql|postgres|postgis/i, + mysql: /mysql|mariadb/i, + mongodb: /mongodb|mongo/i, + + // Cache/Messaging + redis: /redis/i, + memcached: /memcached/i, + messaging: /kafka|rabbitmq/i, + + // Runtimes + nodejs: /node\.?js|nodejs|express/i, + python: /python|django|flask|fastapi/i, + java: /java|spring/i, + go: /golang|go\s/i, + + // Platforms + wordpress: /wordpress/i, + php: /laravel|php/i, + + // Service Types + saas: /saas|b2b|enterprise/i, + ecommerce: /ecommerce|쇼핑몰|이커머스/i, + game: /게임|game|minecraft|팰월드|palworld/i, + streaming: /streaming|스트리밍|video/i, +} as const; + +// ============================================================================ +// Pattern Matching Functions +// ============================================================================ + +/** + * Check if text matches a given pattern + */ +export function matchesPattern(text: string, pattern: RegExp): boolean { + return pattern.test(text); +} + +/** + * Detect tool categories from message text + * @returns Array of category names that match + */ +export function detectToolCategories(text: string): string[] { + const categories: string[] = []; + + if (DOMAIN_PATTERNS.test(text)) categories.push('domain'); + if (DEPOSIT_PATTERNS.test(text)) categories.push('deposit'); + if (SERVER_PATTERNS.test(text)) categories.push('server'); + if (TROUBLESHOOT_PATTERNS.test(text)) categories.push('troubleshoot'); + if (WEATHER_PATTERNS.test(text)) categories.push('weather'); + if (SEARCH_PATTERNS.test(text)) categories.push('search'); + if (REDDIT_PATTERNS.test(text)) categories.push('reddit'); + + return categories; +} + +/** + * Detect memory category from message content + * @returns Category name or null if no match + */ +export type MemoryCategory = 'company' | 'tech' | 'role' | 'location' | 'server' | null; + +export function detectMemoryCategory(content: string): MemoryCategory { + // Company/workplace + if (COMPANY_PATTERNS.test(content)) { + return 'company'; + } + // Tech/learning + if (TECH_LEARNING_PATTERNS.test(content)) { + return 'tech'; + } + // Role + if (ROLE_PATTERNS.test(content)) { + return 'role'; + } + // Location + if (LOCATION_PATTERNS.test(content)) { + return 'location'; + } + // Server/infrastructure + if (SERVER_INFRA_PATTERNS.test(content)) { + return 'server'; + } + return null; +} + +/** + * Detect region preference from text + * @returns Array of region codes or undefined + */ +export function detectRegion(text: string): string[] | undefined { + const lower = text.toLowerCase(); + const regions: string[] = []; + + if (REGION_PATTERNS.korea.test(lower)) regions.push('seoul'); + if (REGION_PATTERNS.japan.test(lower)) regions.push('tokyo'); + if (REGION_PATTERNS.osaka.test(lower)) regions.push('osaka'); + if (REGION_PATTERNS.singapore.test(lower)) regions.push('singapore'); + + return regions.length > 0 ? regions : undefined; +} + +/** + * Detect tech stack from text + * @returns Array of tech stack names + */ +export function detectTechStack(text: string): string[] { + const lower = text.toLowerCase(); + const stack: string[] = []; + + // Databases + if (TECH_PATTERNS.postgresql.test(lower)) stack.push('postgresql'); + if (TECH_PATTERNS.mysql.test(lower)) stack.push('mysql'); + if (TECH_PATTERNS.mongodb.test(lower)) stack.push('mongodb'); + + // Cache/Messaging + if (TECH_PATTERNS.redis.test(lower)) stack.push('redis'); + if (TECH_PATTERNS.memcached.test(lower)) stack.push('memcached'); + if (TECH_PATTERNS.messaging.test(lower)) stack.push('messaging'); + + // Runtimes + if (TECH_PATTERNS.nodejs.test(lower)) stack.push('nodejs'); + if (TECH_PATTERNS.python.test(lower)) stack.push('python'); + if (TECH_PATTERNS.java.test(lower)) stack.push('java'); + if (TECH_PATTERNS.go.test(lower)) stack.push('go'); + + // Platforms + if (TECH_PATTERNS.wordpress.test(lower)) stack.push('wordpress'); + if (TECH_PATTERNS.php.test(lower)) stack.push('php'); + + // Service Types + if (TECH_PATTERNS.saas.test(lower)) stack.push('saas'); + if (TECH_PATTERNS.ecommerce.test(lower)) stack.push('ecommerce'); + if (TECH_PATTERNS.game.test(lower)) stack.push('game'); + if (TECH_PATTERNS.streaming.test(lower)) stack.push('streaming'); + + return stack; +}