refactor: 파일 분리 리팩토링 (routes, services, tools, utils)

아키텍처 개선:
- index.ts: 921줄 → 205줄 (77% 감소)
- openai-service.ts: 1,356줄 → 148줄 (89% 감소)

새로운 디렉토리 구조:
- src/routes/ - Webhook, API, Health check 핸들러
  - webhook.ts (287줄)
  - api.ts (318줄)
  - health.ts (14줄)

- src/services/ - 비즈니스 로직
  - bank-sms-parser.ts (143줄)
  - deposit-matcher.ts (88줄)

- src/tools/ - Function Calling 도구 모듈화
  - weather-tool.ts (37줄)
  - search-tool.ts (156줄)
  - domain-tool.ts (725줄)
  - deposit-tool.ts (183줄)
  - utility-tools.ts (60줄)
  - index.ts (104줄) - 도구 레지스트리

- src/utils/ - 유틸리티 함수
  - email-decoder.ts - Quoted-Printable 디코더

타입 에러 수정:
- routes/webhook.ts: text undefined 체크
- summary-service.ts: D1 타입 캐스팅
- summary-service.ts: Workers AI 타입 처리
- n8n-service.ts: Workers AI 타입 + 미사용 변수 제거

빌드 검증:
- TypeScript 타입 체크 통과
- Wrangler dev 로컬 빌드 성공

문서:
- REFACTORING_SUMMARY.md 추가
- ROUTE_ARCHITECTURE.md 추가

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-19 15:36:17 +09:00
parent 3bf42947a7
commit ab6c9a2efa
18 changed files with 2578 additions and 1958 deletions

318
src/routes/api.ts Normal file
View File

@@ -0,0 +1,318 @@
import { Env } from '../types';
import { sendMessage } from '../telegram';
import {
addToBuffer,
processAndSummarize,
generateAIResponse,
} from '../summary-service';
import { handleCommand } from '../commands';
// 사용자 조회/생성
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;
}
/**
* 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:
* curl -X POST http://localhost:8787/api/test \
* -H "Content-Type: application/json" \
* -d '{"text":"hello","secret":"your-secret"}'
* 5. 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"}'
*/
export async function handleApiRequest(request: Request, env: Env, url: URL): Promise<Response> {
// Deposit API - 잔액 조회 (namecheap-api 전용)
if (url.pathname === '/api/deposit/balance' && request.method === 'GET') {
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) {
console.error('[API] Deposit balance error:', error);
return Response.json({ error: String(error) }, { status: 500 });
}
}
// Deposit API - 잔액 차감 (namecheap-api 전용)
if (url.pathname === '/api/deposit/deduct' && request.method === 'POST') {
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 body = await request.json() as {
telegram_id: string;
amount: number;
reason: string;
reference_id?: string;
};
if (!body.telegram_id || !body.amount || !body.reason) {
return Response.json({ error: 'telegram_id, amount, reason required' }, { status: 400 });
}
if (body.amount <= 0) {
return Response.json({ error: 'Amount must be positive' }, { status: 400 });
}
// 사용자 조회
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 });
}
// 현재 잔액 확인
const deposit = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number }>();
const currentBalance = deposit?.balance || 0;
if (currentBalance < body.amount) {
return Response.json({
error: 'Insufficient balance',
current_balance: currentBalance,
required: body.amount,
}, { status: 400 });
}
// 트랜잭션: 잔액 차감 + 거래 기록
await env.DB.batch([
env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
).bind(body.amount, user.id),
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),
]);
const newBalance = currentBalance - 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: currentBalance,
new_balance: newBalance,
});
} catch (error) {
console.error('[API] Deposit deduct error:', error);
return Response.json({ error: String(error) }, { status: 500 });
}
}
// 테스트 API - 메시지 처리 후 응답 직접 반환
if (url.pathname === '/api/test' && request.method === 'POST') {
try {
const body = await request.json() as { text: string; user_id?: string; secret?: string };
// 간단한 인증
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: String(error) }, { status: 500 });
}
}
// 문의 폼 API (웹사이트용)
if (url.pathname === '/api/contact' && request.method === 'POST') {
// CORS: hosting.anvil.it.com만 허용
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
try {
const body = await request.json() as {
email: string;
message: string;
};
// 필수 필드 검증
if (!body.email || !body.message) {
return Response.json(
{ error: '이메일과 메시지는 필수 항목입니다.' },
{ status: 400, headers: corsHeaders }
);
}
// 이메일 형식 검증
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] 오류:', error);
return Response.json(
{ error: '문의 전송 중 오류가 발생했습니다.' },
{ status: 500, headers: corsHeaders }
);
}
}
// CORS preflight for contact API
if (url.pathname === '/api/contact' && request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://hosting.anvil.it.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
return new Response('Not Found', { status: 404 });
}