refactor: add pattern utils and split api.ts into modules

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 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 10:34:12 +09:00
parent 40447952a9
commit 3a671a5707
7 changed files with 1111 additions and 876 deletions

View File

@@ -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<string, string> {
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<number> {
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<Response> {
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<Response> {
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<Response> {
// 개발/테스트 환경에서만 활성화 (명시적 설정 필수)
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<Response> {
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:
`📋 <b>서버 주문 접수 완료!</b> (주문 #${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<Response> {
// 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),
`📬 <b>웹사이트 문의</b>\n\n` +
`📧 이메일: <code>${body.email}</code>\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<Response> {
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<Response> {
*/
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 };