import { z } from 'zod'; 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으로 타이밍 공격 방지) * @returns 인증 실패 시 Response, 성공 시 null */ function requireApiKey(request: Request, env: Env): Response | null { const apiSecret = env.DEPOSIT_API_SECRET; const authHeader = request.headers.get('X-API-Key'); if (!apiSecret || !timingSafeEqual(authHeader, apiSecret)) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } return null; } /** * 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 request - HTTP Request * @param env - Environment bindings * @param url - Parsed URL * @returns JSON response with balance */ async function handleDepositBalance(request: Request, env: Env, url: URL): Promise { try { // API Key 인증 const authError = requireApiKey(request, env); if (authError) return authError; 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 { // API Key 인증 const authError = requireApiKey(request, env); if (authError) return authError; // 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 === 'production') { 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; // 간단한 인증 if (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); // 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.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); // 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 } ); } } /** * OPTIONS /api/contact - CORS preflight for contact API * * @param env - Environment bindings * @returns Response with CORS headers */ async function handleContactPreflight(env: Env): Promise { return new Response(null, { headers: getCorsHeaders(env), }); } /** * 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 }); } } /** * API 엔드포인트 처리 (라우터) * * Manual Test: * 1. wrangler dev * 2. Test deposit balance: * curl http://localhost:8787/api/deposit/balance?telegram_id=123 \ * -H "X-API-Key: your-secret" * 3. Test deposit deduct: * curl -X POST http://localhost:8787/api/deposit/deduct \ * -H "X-API-Key: your-secret" \ * -H "Content-Type: application/json" \ * -d '{"telegram_id":"123","amount":1000,"reason":"test"}' * 4. Test API (dev only): * curl -X POST http://localhost:8787/api/test \ * -H "Content-Type: application/json" \ * -d '{"text":"hello","secret":"your-secret"}' * 5. Chat API (production-ready): * curl -X POST http://localhost:8787/api/chat \ * -H "Authorization: Bearer your-webhook-secret" \ * -H "Content-Type: application/json" \ * -d '{"message":"서버 추천해줘","chat_id":821596605,"user_id":821596605,"username":"web-tester"}' * 6. Test contact (from allowed origin): * curl -X POST http://localhost:8787/api/contact \ * -H "Origin: https://hosting.anvil.it.com" \ * -H "Content-Type: application/json" \ * -d '{"email":"test@example.com","message":"test message"}' * 7. Test metrics (Circuit Breaker status): * curl http://localhost:8787/api/metrics \ * -H "Authorization: Bearer your-webhook-secret" */ export async function handleApiRequest(request: Request, env: Env, url: URL): Promise { // Deposit API - 잔액 조회 (namecheap-api 전용) if (url.pathname === '/api/deposit/balance' && request.method === 'GET') { return handleDepositBalance(request, env, url); } // Deposit API - 잔액 차감 (namecheap-api 전용) if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') { return handleDepositDeduct(request, env); } // 테스트 API - 메시지 처리 후 응답 직접 반환 if (url.pathname === '/api/test' && request.method === 'POST') { return handleTestApi(request, env); } // Chat API - 인증된 채팅 API (프로덕션 활성화) if (url.pathname === '/api/chat' && request.method === 'POST') { return handleChatApi(request, env); } // 문의 폼 API (웹사이트용) if (url.pathname === '/api/contact' && request.method === 'POST') { return handleContactForm(request, env); } // CORS preflight for contact API if (url.pathname === '/api/contact' && request.method === 'OPTIONS') { return handleContactPreflight(env); } // Metrics API - Circuit Breaker 상태 조회 (관리자 전용) if (url.pathname === '/api/metrics' && request.method === 'GET') { return handleMetrics(request, env); } return new Response('Not Found', { status: 404 }); }