feat: 예치금 시스템 추가 (은행 SMS 자동 매칭)
- manage_deposit Function Calling 추가 (잔액조회, 입금신고, 거래내역, 취소) - Email Worker로 은행 SMS 파싱 (하나/KB/신한 지원) - 양방향 자동 매칭: 사용자 신고 ↔ 은행 알림 - D1 테이블: user_deposits, deposit_transactions, bank_notifications - 관리자 전용: 대기목록 조회, 입금 확인/거절 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
193
src/index.ts
193
src/index.ts
@@ -1,4 +1,4 @@
|
||||
import { Env, TelegramUpdate } from './types';
|
||||
import { Env, TelegramUpdate, EmailMessage, BankNotification } from './types';
|
||||
import { validateWebhookRequest, checkRateLimit } from './security';
|
||||
import { sendMessage, sendMessageWithKeyboard, setWebhook, getWebhookInfo, sendChatAction } from './telegram';
|
||||
import {
|
||||
@@ -207,4 +207,195 @@ Documentation: https://github.com/your-repo
|
||||
|
||||
return new Response('Not Found', { status: 404 });
|
||||
},
|
||||
|
||||
// Email 핸들러 (SMS → 메일 → 파싱)
|
||||
async email(message: EmailMessage, env: Env): Promise<void> {
|
||||
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 파싱 실패:', rawEmail.slice(0, 200));
|
||||
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 (env.BOT_TOKEN && env.DEPOSIT_ADMIN_ID) {
|
||||
const statusMsg = matched
|
||||
? `✅ 자동 매칭 완료! (거래 #${matched.transactionId})`
|
||||
: '⏳ 매칭 대기 중 (사용자 입금 신고 필요)';
|
||||
|
||||
await sendMessage(
|
||||
env.BOT_TOKEN,
|
||||
parseInt(env.DEPOSIT_ADMIN_ID),
|
||||
`🏦 <b>입금 알림</b>\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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 은행 SMS 파싱 함수
|
||||
function parseBankSMS(content: string): BankNotification | null {
|
||||
// 이메일에서 SMS 본문 추출 (여러 줄에 걸쳐 있을 수 있음)
|
||||
const text = content.replace(/\r\n/g, '\n').replace(/=\n/g, '');
|
||||
|
||||
// 하나은행 패턴: [하나은행] 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 } | null> {
|
||||
// 매칭 조건: 입금자명 + 금액이 일치하는 pending 거래
|
||||
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 dt.depositor_name = ?
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user