import { Env, TelegramUpdate, EmailMessage, BankNotification } from './types'; import { validateWebhookRequest, checkRateLimit } from './security'; import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction, answerCallbackQuery, editMessageText } from './telegram'; import { executeDomainRegister } from './domain-register'; import { addToBuffer, processAndSummarize, generateAIResponse, } from './summary-service'; import { handleCommand } from './commands'; // 사용자 조회/생성 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; } // 메시지 처리 async function handleMessage( env: Env, update: TelegramUpdate ): Promise { if (!update.message?.text) return; const { message } = update; const chatId = message.chat.id; const chatIdStr = chatId.toString(); const text = message.text; const telegramUserId = message.from.id.toString(); // Rate Limiting 체크 (KV 기반) if (!(await checkRateLimit(env.RATE_LIMIT_KV, telegramUserId))) { await sendMessage( env.BOT_TOKEN, chatId, '⚠️ 너무 많은 요청입니다. 잠시 후 다시 시도해주세요.' ); return; } // 사용자 처리 (오류 시 사용자에게 알림) let userId: number; try { userId = await getOrCreateUser( env.DB, telegramUserId, message.from.first_name, message.from.username ); } catch (dbError) { console.error('[handleMessage] 사용자 DB 오류:', dbError); await sendMessage( env.BOT_TOKEN, chatId, '⚠️ 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' ); return; } let responseText: string; try { // 명령어 처리 if (text.startsWith('/')) { const [command, ...argParts] = text.split(' '); const args = argParts.join(' '); responseText = await handleCommand(env, userId, chatIdStr, command, args); // /start 명령어는 미니앱 버튼과 함께 전송 if (command === '/start') { await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, responseText, [ [{ text: '🌐 서비스 보기', web_app: { url: 'https://hosting.anvil.it.com' } }], [{ text: '💬 문의하기', url: 'https://t.me/AnvilForgeBot' }], ]); return; } } else { // 타이핑 표시 await sendChatAction(env.BOT_TOKEN, chatId, 'typing'); // 1. 사용자 메시지 버퍼에 추가 await addToBuffer(env.DB, userId, chatIdStr, 'user', text); // 2. AI 응답 생성 responseText = await generateAIResponse(env, userId, chatIdStr, text, telegramUserId); // 3. 봇 응답 버퍼에 추가 await addToBuffer(env.DB, userId, chatIdStr, 'bot', responseText); // 4. 임계값 도달시 프로필 업데이트 const { summarized } = await processAndSummarize(env, userId, chatIdStr); if (summarized) { responseText += '\n\n👤 프로필이 업데이트되었습니다.'; } } } catch (error) { console.error('[handleMessage] 처리 오류:', error); responseText = '⚠️ 메시지 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; } // 버튼 데이터 파싱 const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/); if (keyboardMatch) { const cleanText = responseText.replace(/__KEYBOARD__.+?__END__\n?/, ''); try { const keyboardData = JSON.parse(keyboardMatch[1]); if (keyboardData.type === 'domain_register') { // 도메인 등록 확인 버튼 const callbackData = `domain_reg:${keyboardData.domain}:${keyboardData.price}`; await sendMessageWithKeyboard(env.BOT_TOKEN, chatId, cleanText, [ [ { text: '✅ 등록하기', callback_data: callbackData }, { text: '❌ 취소', callback_data: 'domain_cancel' } ] ]); return; } } catch (e) { console.error('[Keyboard] 파싱 오류:', e); } } await sendMessage(env.BOT_TOKEN, chatId, responseText); } // Callback Query 처리 (인라인 버튼 클릭) async function handleCallbackQuery( env: Env, callbackQuery: TelegramUpdate['callback_query'] ): Promise { if (!callbackQuery) return; const { id: queryId, from, message, data } = callbackQuery; if (!data || !message) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 요청입니다.' }); return; } const chatId = message.chat.id; const messageId = message.message_id; const telegramUserId = from.id.toString(); // 사용자 조회 const user = await env.DB.prepare( 'SELECT id FROM users WHERE telegram_id = ?' ).bind(telegramUserId).first<{ id: number }>(); if (!user) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '사용자를 찾을 수 없습니다.' }); return; } // 도메인 등록 처리 if (data.startsWith('domain_reg:')) { const parts = data.split(':'); if (parts.length !== 3) { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); return; } const domain = parts[1]; const price = parseInt(parts[2]); // 처리 중 표시 await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' }); await editMessageText( env.BOT_TOKEN, chatId, messageId, `⏳ ${domain} 등록 처리 중...` ); // 도메인 등록 실행 const result = await executeDomainRegister(env, user.id, telegramUserId, domain, price); if (result.success) { const expiresInfo = result.expiresAt ? `\n• 만료일: ${result.expiresAt}` : ''; const nsInfo = result.nameservers && result.nameservers.length > 0 ? `\n\n🌐 현재 네임서버:\n${result.nameservers.map(ns => `• ${ns}`).join('\n')}` : ''; await editMessageText( env.BOT_TOKEN, chatId, messageId, `✅ 도메인 등록 완료! • 도메인: ${result.domain} • 결제 금액: ${result.price?.toLocaleString()}원 • 현재 잔액: ${result.newBalance?.toLocaleString()}원${expiresInfo}${nsInfo} 🎉 축하합니다! 도메인이 성공적으로 등록되었습니다. 네임서버 변경이 필요하면 말씀해주세요.` ); } else { await editMessageText( env.BOT_TOKEN, chatId, messageId, `❌ 등록 실패 ${result.error} 다시 시도하시려면 도메인 등록을 요청해주세요.` ); } return; } // 도메인 등록 취소 if (data === 'domain_cancel') { await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); await editMessageText( env.BOT_TOKEN, chatId, messageId, '❌ 도메인 등록이 취소되었습니다.' ); return; } await answerCallbackQuery(env.BOT_TOKEN, queryId); } export default { // HTTP 요청 핸들러 async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); // Webhook 설정 엔드포인트 if (url.pathname === '/setup-webhook') { if (!env.BOT_TOKEN) { return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 }); } if (!env.WEBHOOK_SECRET) { return Response.json({ error: 'WEBHOOK_SECRET not configured' }, { status: 500 }); } const webhookUrl = `${url.origin}/webhook`; const result = await setWebhook(env.BOT_TOKEN, webhookUrl, env.WEBHOOK_SECRET); return Response.json(result); } // Webhook 정보 조회 if (url.pathname === '/webhook-info') { if (!env.BOT_TOKEN) { return Response.json({ error: 'BOT_TOKEN not configured' }, { status: 500 }); } const result = await getWebhookInfo(env.BOT_TOKEN); return Response.json(result); } // 헬스 체크 (공개 - 최소 정보만) if (url.pathname === '/health') { return Response.json({ status: 'ok', timestamp: new Date().toISOString(), }); } // 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), `📬 웹사이트 문의\n\n` + `📧 이메일: ${body.email}\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', }, }); } // Telegram Webhook 처리 if (url.pathname === '/webhook') { // 보안 검증 const validation = await validateWebhookRequest(request, env); if (!validation.valid) { console.error('Webhook validation failed:', validation.error); return new Response(validation.error, { status: 401 }); } try { const update = validation.update!; // Callback Query 처리 (인라인 버튼 클릭) if (update.callback_query) { await handleCallbackQuery(env, update.callback_query); return new Response('OK'); } // 일반 메시지 처리 await handleMessage(env, update); return new Response('OK'); } catch (error) { console.error('Message handling error:', error); return new Response('Error', { status: 500 }); } } // 루트 경로 if (url.pathname === '/') { return new Response(` Telegram Rolling Summary Bot Endpoints: GET /health - Health check GET /webhook-info - Webhook status GET /setup-webhook - Configure webhook POST /webhook - Telegram webhook (authenticated) Documentation: https://github.com/your-repo `.trim(), { headers: { 'Content-Type': 'text/plain' }, }); } return new Response('Not Found', { status: 404 }); }, // Email 핸들러 (SMS → 메일 → 파싱) async email(message: EmailMessage, env: Env): Promise { try { // 이메일 본문 읽기 const rawEmail = await new Response(message.raw).text(); console.log('[Email] 수신:', message.from, 'Size:', message.rawSize); // SMS 내용 파싱 const notification = parseBankSMS(rawEmail); if (!notification) { console.log('[Email] 은행 SMS 파싱 실패'); return; } console.log('[Email] 파싱 결과:', notification); // DB에 저장 const insertResult = await env.DB.prepare( `INSERT INTO bank_notifications (bank_name, depositor_name, amount, balance_after, transaction_time, raw_message) VALUES (?, ?, ?, ?, ?, ?)` ).bind( notification.bankName, notification.depositorName, notification.amount, notification.balanceAfter || null, notification.transactionTime?.toISOString() || null, notification.rawMessage ).run(); const notificationId = insertResult.meta.last_row_id; console.log('[Email] 알림 저장 완료, ID:', notificationId); // 자동 매칭 시도 const matched = await tryAutoMatch(env.DB, notificationId, notification); // 매칭 성공 시 사용자에게 알림 if (matched && env.BOT_TOKEN) { const user = await env.DB.prepare( 'SELECT telegram_id FROM users WHERE id = ?' ).bind(matched.userId).first<{ telegram_id: string }>(); if (user) { // 업데이트된 잔액 조회 const deposit = await env.DB.prepare( 'SELECT balance FROM user_deposits WHERE user_id = ?' ).bind(matched.userId).first<{ balance: number }>(); await sendMessage( env.BOT_TOKEN, parseInt(user.telegram_id), `✅ 입금 확인 완료!\n\n` + `입금액: ${matched.amount.toLocaleString()}원\n` + `현재 잔액: ${(deposit?.balance || 0).toLocaleString()}원\n\n` + `감사합니다! 🎉` ); } } // 관리자에게 알림 if (env.BOT_TOKEN && env.DEPOSIT_ADMIN_ID) { const statusMsg = matched ? `✅ 자동 매칭 완료! (거래 #${matched.transactionId})` : '⏳ 매칭 대기 중 (사용자 입금 신고 필요)'; await sendMessage( env.BOT_TOKEN, parseInt(env.DEPOSIT_ADMIN_ID), `🏦 입금 알림\n\n` + `은행: ${notification.bankName}\n` + `입금자: ${notification.depositorName}\n` + `금액: ${notification.amount.toLocaleString()}원\n` + `${notification.balanceAfter ? `잔액: ${notification.balanceAfter.toLocaleString()}원\n` : ''}` + `\n${statusMsg}` ); } } catch (error) { console.error('[Email] 처리 오류:', error); } }, // Cron Trigger: 만료된 입금 대기 자동 취소 (24시간) async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { console.log('[Cron] 만료된 입금 대기 정리 시작'); try { // 24시간 이상 된 pending 거래 조회 const expiredTxs = await env.DB.prepare( `SELECT dt.id, dt.amount, dt.depositor_name, u.telegram_id FROM deposit_transactions dt JOIN users u ON dt.user_id = u.id WHERE dt.status = 'pending' AND dt.type = 'deposit' AND datetime(dt.created_at) < datetime('now', '-1 day') LIMIT 100` ).all<{ id: number; amount: number; depositor_name: string; telegram_id: string; }>(); if (!expiredTxs.results?.length) { console.log('[Cron] 만료된 거래 없음'); return; } console.log(`[Cron] 만료된 거래 ${expiredTxs.results.length}건 발견`); for (const tx of expiredTxs.results) { // 상태를 cancelled로 변경 await env.DB.prepare( "UPDATE deposit_transactions SET status = 'cancelled', description = '입금 대기 만료 (24시간)' WHERE id = ?" ).bind(tx.id).run(); // 사용자에게 알림 await sendMessage( env.BOT_TOKEN, parseInt(tx.telegram_id), `⏰ 입금 대기 만료\n\n` + `입금자: ${tx.depositor_name}\n` + `금액: ${tx.amount.toLocaleString()}원\n\n` + `24시간 이내에 입금이 확인되지 않아 자동 취소되었습니다.\n` + `다시 입금하시려면 입금 후 알려주세요.` ); console.log(`[Cron] 거래 #${tx.id} 만료 처리 완료`); } console.log('[Cron] 만료된 입금 대기 정리 완료'); } catch (error) { console.error('[Cron] 오류:', error); } }, }; // Quoted-Printable UTF-8 디코딩 function decodeQuotedPrintableUTF8(str: string): string { // 줄 연속 문자 제거 str = str.replace(/=\r?\n/g, ''); // =XX 패턴을 바이트로 변환 const bytes: number[] = []; let i = 0; while (i < str.length) { if (str[i] === '=' && i + 2 < str.length) { const hex = str.slice(i + 1, i + 3); if (/^[0-9A-Fa-f]{2}$/.test(hex)) { bytes.push(parseInt(hex, 16)); i += 3; continue; } } bytes.push(str.charCodeAt(i)); i++; } // UTF-8 바이트를 문자열로 변환 try { return new TextDecoder('utf-8').decode(new Uint8Array(bytes)); } catch { return str; } } // 은행 SMS 파싱 함수 function parseBankSMS(content: string): BankNotification | null { // MIME 이메일 전처리 let text = content; // Quoted-Printable UTF-8 디코딩 text = decodeQuotedPrintableUTF8(text); // HTML
태그를 줄바꿈으로 변환 text = text.replace(//gi, '\n'); // 줄바꿈 정규화 text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); // [Web발신] 또는 은행 키워드가 있는 부분만 추출 const smsStartMatch = text.match(/\[Web발신\]|\[하나은행\]|\[KB\]|\[신한\]|\[우리\]|\[농협\]/); if (smsStartMatch && smsStartMatch.index !== undefined) { // SMS 시작점부터 500자 추출 text = text.slice(smsStartMatch.index, smsStartMatch.index + 500); } // 하나은행 Web발신 패턴 (여러 줄): // [Web발신] // 하나,01/16, 22:12 // 427******27104 // 입금1원 // 황병하 const hanaWebPattern = /\[Web발신\]\s*하나[,\s]*(\d{1,2}\/\d{1,2})[,\s]*(\d{1,2}:\d{2})\s*[\d*]+\s*입금([\d,]+)원\s*(\S+)/; const hanaWebMatch = text.match(hanaWebPattern); if (hanaWebMatch) { const [, date, time, amountStr, depositor] = hanaWebMatch; return { bankName: '하나은행', depositorName: depositor.trim(), amount: parseInt(amountStr.replace(/,/g, '')), transactionTime: parseDateTime(date, time), rawMessage: text.slice(0, 500), }; } // 하나은행 기존 패턴: [하나은행] 01/16 14:30 입금 50,000원 홍길동 잔액 1,234,567원 const hanaPattern = /\[하나은행\]\s*(\d{1,2}\/\d{1,2})\s*(\d{1,2}:\d{2})?\s*입금\s*([\d,]+)원\s*(\S+?)(?:\s+잔액\s*([\d,]+)원)?/; const hanaMatch = text.match(hanaPattern); if (hanaMatch) { const [, date, time, amountStr, depositor, balanceStr] = hanaMatch; return { bankName: '하나은행', depositorName: depositor, amount: parseInt(amountStr.replace(/,/g, '')), balanceAfter: balanceStr ? parseInt(balanceStr.replace(/,/g, '')) : undefined, transactionTime: parseDateTime(date, time), rawMessage: text.slice(0, 500), }; } // KB국민은행 패턴: [KB] 입금 50,000원 01/16 14:30 홍길동 const kbPattern = /\[KB\]\s*입금\s*([\d,]+)원\s*(\d{1,2}\/\d{1,2})?\s*(\d{1,2}:\d{2})?\s*(\S+)/; const kbMatch = text.match(kbPattern); if (kbMatch) { const [, amountStr, date, time, depositor] = kbMatch; return { bankName: 'KB국민은행', depositorName: depositor, amount: parseInt(amountStr.replace(/,/g, '')), transactionTime: date ? parseDateTime(date, time) : undefined, rawMessage: text.slice(0, 500), }; } // 신한은행 패턴: [신한] 01/16 입금 50,000원 홍길동 const shinhanPattern = /\[신한\]\s*(\d{1,2}\/\d{1,2})?\s*입금\s*([\d,]+)원\s*(\S+)/; const shinhanMatch = text.match(shinhanPattern); if (shinhanMatch) { const [, date, amountStr, depositor] = shinhanMatch; return { bankName: '신한은행', depositorName: depositor, amount: parseInt(amountStr.replace(/,/g, '')), transactionTime: date ? parseDateTime(date) : undefined, rawMessage: text.slice(0, 500), }; } // 일반 입금 패턴: 입금 50,000원 홍길동 또는 홍길동 50,000원 입금 const genericPattern1 = /입금\s*([\d,]+)원?\s*(\S{2,10})/; const genericPattern2 = /(\S{2,10})\s*([\d,]+)원?\s*입금/; const genericMatch1 = text.match(genericPattern1); if (genericMatch1) { return { bankName: '알수없음', depositorName: genericMatch1[2], amount: parseInt(genericMatch1[1].replace(/,/g, '')), rawMessage: text.slice(0, 500), }; } const genericMatch2 = text.match(genericPattern2); if (genericMatch2) { return { bankName: '알수없음', depositorName: genericMatch2[1], amount: parseInt(genericMatch2[2].replace(/,/g, '')), rawMessage: text.slice(0, 500), }; } return null; } // 날짜/시간 파싱 function parseDateTime(dateStr: string, timeStr?: string): Date { const now = new Date(); const [month, day] = dateStr.split('/').map(Number); const year = now.getFullYear(); let hours = 0, minutes = 0; if (timeStr) { [hours, minutes] = timeStr.split(':').map(Number); } return new Date(year, month - 1, day, hours, minutes); } // 자동 매칭 시도 async function tryAutoMatch( db: D1Database, notificationId: number, notification: BankNotification ): Promise<{ transactionId: number; userId: number; amount: number } | null> { // 매칭 조건: 입금자명(앞 7글자) + 금액이 일치하는 pending 거래 // 은행 SMS는 입금자명이 7글자까지만 표시됨 const pendingTx = await db.prepare( `SELECT dt.id, dt.user_id, dt.amount FROM deposit_transactions dt WHERE dt.status = 'pending' AND dt.type = 'deposit' AND SUBSTR(dt.depositor_name, 1, 7) = ? AND dt.amount = ? ORDER BY dt.created_at ASC LIMIT 1` ).bind(notification.depositorName, notification.amount).first<{ id: number; user_id: number; amount: number; }>(); if (!pendingTx) { console.log('[AutoMatch] 매칭되는 pending 거래 없음'); return null; } console.log('[AutoMatch] 매칭 발견:', pendingTx); // 트랜잭션: 거래 확정 + 잔액 증가 + 알림 매칭 업데이트 await db.batch([ db.prepare( "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?" ).bind(pendingTx.id), db.prepare( 'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' ).bind(pendingTx.amount, pendingTx.user_id), db.prepare( 'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?' ).bind(pendingTx.id, notificationId), ]); return { transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount }; }