refactor: improve code quality for 9.0 score
- Split handleApiRequest (380 lines) into focused handler functions: - handleDepositBalance, handleDepositDeduct, handleTestApi - handleContactForm, handleContactPreflight, handleMetrics - Clean router pattern with JSDoc documentation - Unify logging: Replace all console.log/error with structured logger - 8 console statements converted to logger calls - Add structured metadata for better debugging - Remove duplicate email validation (Zod already validates) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,418 @@ async function getOrCreateUser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API 엔드포인트 처리
|
* 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<Response> {
|
||||||
|
try {
|
||||||
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
||||||
|
const authHeader = request.headers.get('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiSecret || authHeader !== apiSecret) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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', error as 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 {
|
||||||
|
const apiSecret = env.DEPOSIT_API_SECRET;
|
||||||
|
const authHeader = request.headers.get('X-API-Key');
|
||||||
|
|
||||||
|
if (!apiSecret || authHeader !== apiSecret) {
|
||||||
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonData = await request.json();
|
||||||
|
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('잔액 복구 실패 - 수동 확인 필요', rollbackError as Error, {
|
||||||
|
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', error as 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> {
|
||||||
|
try {
|
||||||
|
const jsonData = await request.json();
|
||||||
|
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', error as Error);
|
||||||
|
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: hosting.anvil.it.com만 허용
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
|
const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
const jsonData = await request.json();
|
||||||
|
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', error as 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<Response> {
|
||||||
|
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': allowedOrigin,
|
||||||
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 인증
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!env.WEBHOOK_SECRET || authHeader !== `Bearer ${env.WEBHOOK_SECRET}`) {
|
||||||
|
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', error as Error);
|
||||||
|
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 엔드포인트 처리 (라우터)
|
||||||
*
|
*
|
||||||
* Manual Test:
|
* Manual Test:
|
||||||
* 1. wrangler dev
|
* 1. wrangler dev
|
||||||
@@ -91,381 +502,32 @@ async function getOrCreateUser(
|
|||||||
export async function handleApiRequest(request: Request, env: Env, url: URL): Promise<Response> {
|
export async function handleApiRequest(request: Request, env: Env, url: URL): Promise<Response> {
|
||||||
// Deposit API - 잔액 조회 (namecheap-api 전용)
|
// Deposit API - 잔액 조회 (namecheap-api 전용)
|
||||||
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
|
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
|
||||||
try {
|
return handleDepositBalance(request, env, url);
|
||||||
const apiSecret = env.DEPOSIT_API_SECRET;
|
|
||||||
const authHeader = request.headers.get('X-API-Key');
|
|
||||||
|
|
||||||
if (!apiSecret || authHeader !== apiSecret) {
|
|
||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
console.error('[API] Deposit balance error:', error);
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deposit API - 잔액 차감 (namecheap-api 전용)
|
// Deposit API - 잔액 차감 (namecheap-api 전용)
|
||||||
if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') {
|
if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') {
|
||||||
try {
|
return handleDepositDeduct(request, env);
|
||||||
const apiSecret = env.DEPOSIT_API_SECRET;
|
|
||||||
const authHeader = request.headers.get('X-API-Key');
|
|
||||||
|
|
||||||
if (!apiSecret || authHeader !== apiSecret) {
|
|
||||||
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonData = await request.json();
|
|
||||||
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('잔액 복구 실패 - 수동 확인 필요', rollbackError as Error, {
|
|
||||||
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;
|
|
||||||
|
|
||||||
console.log(`[API] Deposit deducted: user=${body.telegram_id}, amount=${body.amount}, reason=${body.reason}`);
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
success: true,
|
|
||||||
telegram_id: body.telegram_id,
|
|
||||||
deducted: body.amount,
|
|
||||||
previous_balance: current.balance,
|
|
||||||
new_balance: newBalance,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[API] Deposit deduct error:', error);
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테스트 API - 메시지 처리 후 응답 직접 반환
|
// 테스트 API - 메시지 처리 후 응답 직접 반환
|
||||||
if (url.pathname === '/api/test' && request.method === 'POST') {
|
if (url.pathname === '/api/test' && request.method === 'POST') {
|
||||||
try {
|
return handleTestApi(request, env);
|
||||||
const jsonData = await request.json();
|
|
||||||
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) {
|
|
||||||
console.error('[Test API] Error:', error);
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 문의 폼 API (웹사이트용)
|
// 문의 폼 API (웹사이트용)
|
||||||
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
if (url.pathname === '/api/contact' && request.method === 'POST') {
|
||||||
// CORS: hosting.anvil.it.com만 허용
|
return handleContactForm(request, env);
|
||||||
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
|
||||||
const corsHeaders = {
|
|
||||||
'Access-Control-Allow-Origin': allowedOrigin,
|
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
const jsonData = await request.json();
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 이메일 형식 검증 (Zod로 이미 검증됨, 추가 체크)
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailRegex.test(body.email)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: '올바른 이메일 형식이 아닙니다.' },
|
|
||||||
{ status: 400, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메시지 길이 제한
|
|
||||||
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}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Contact] 문의 수신: ${body.email}`);
|
|
||||||
|
|
||||||
return Response.json(
|
|
||||||
{ success: true, message: '문의가 성공적으로 전송되었습니다.' },
|
|
||||||
{ headers: corsHeaders }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Contact] Internal error:', error);
|
|
||||||
return Response.json(
|
|
||||||
{ error: '문의 전송 중 오류가 발생했습니다.' },
|
|
||||||
{ status: 500, headers: corsHeaders }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORS preflight for contact API
|
// CORS preflight for contact API
|
||||||
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
|
||||||
const allowedOrigin = env.HOSTING_SITE_URL || 'https://hosting.anvil.it.com';
|
return handleContactPreflight(env);
|
||||||
return new Response(null, {
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': allowedOrigin,
|
|
||||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Metrics API - Circuit Breaker 상태 조회 (관리자 전용)
|
// Metrics API - Circuit Breaker 상태 조회 (관리자 전용)
|
||||||
if (url.pathname === '/api/metrics' && request.method === 'GET') {
|
if (url.pathname === '/api/metrics' && request.method === 'GET') {
|
||||||
try {
|
return handleMetrics(request, env);
|
||||||
// WEBHOOK_SECRET 인증
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
if (!env.WEBHOOK_SECRET || authHeader !== `Bearer ${env.WEBHOOK_SECRET}`) {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[Metrics API] Circuit breaker stats retrieved:', {
|
|
||||||
state: openaiStats.state,
|
|
||||||
failures: openaiStats.failures,
|
|
||||||
requests: openaiStats.stats.totalRequests,
|
|
||||||
});
|
|
||||||
|
|
||||||
return Response.json(metrics);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Metrics API] Internal error:', error);
|
|
||||||
return Response.json({ error: 'Internal server error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response('Not Found', { status: 404 });
|
return new Response('Not Found', { status: 404 });
|
||||||
|
|||||||
Reference in New Issue
Block a user