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:
kappa
2026-01-16 12:29:57 +09:00
parent 8b2ccf05b5
commit e98bfd3a68
7 changed files with 702 additions and 11 deletions

View File

@@ -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 };
}

View File

@@ -131,6 +131,36 @@ const tools = [
},
},
},
{
type: 'function',
function: {
name: 'manage_deposit',
description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.',
parameters: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['balance', 'request_deposit', 'transactions', 'cancel', 'pending_list', 'confirm', 'reject', 'account_info'],
description: '수행할 작업: balance(잔액조회), request_deposit(입금신고/충전요청-입금했다고 말할때도 사용), account_info(입금계좌안내), transactions(거래내역), cancel(입금취소), pending_list(대기목록-관리자), confirm(입금확인-관리자), reject(입금거절-관리자)',
},
amount: {
type: 'number',
description: '금액 (충전 요청 시 필수)',
},
depositor_name: {
type: 'string',
description: '입금자명 (충전 요청 시 필수)',
},
transaction_id: {
type: 'number',
description: '거래 ID (확인/거절/취소 시 필수)',
},
},
required: ['action'],
},
},
},
];
// Namecheap API 호출 (allowedDomains로 필터링)
@@ -438,6 +468,291 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
}
}
case 'manage_deposit': {
const action = args.action;
const amount = args.amount ? parseInt(args.amount) : 0;
const depositorName = args.depositor_name;
const transactionId = args.transaction_id ? parseInt(args.transaction_id) : 0;
if (!telegramUserId || !db) {
return '🚫 예치금 기능을 사용할 수 없습니다.';
}
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
// 사용자 조회 또는 생성
let user = await db.prepare(
'SELECT id FROM users WHERE telegram_id = ?'
).bind(telegramUserId).first<{ id: number }>();
if (!user) {
return '🚫 사용자 정보를 찾을 수 없습니다.';
}
// 예치금 계정 조회 또는 생성
let deposit = await db.prepare(
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ id: number; balance: number }>();
if (!deposit) {
await db.prepare(
'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)'
).bind(user.id).run();
deposit = { id: 0, balance: 0 };
}
switch (action) {
case 'balance': {
return `💰 현재 예치금 잔액: ${deposit.balance.toLocaleString()}`;
}
case 'account_info': {
return `🏦 <b>입금 계좌 안내</b>
은행: 하나은행
계좌: 427-910018-27104
예금주: 주식회사 아이언클래드
입금 후 입금자명과 금액을 알려주세요.
예: "홍길동 10000원 입금했어"`;
}
case 'request_deposit': {
if (!amount || amount <= 0) {
return '❌ 충전 금액을 입력해주세요. (예: 10000원 충전)';
}
if (!depositorName) {
return '❌ 입금자명을 입력해주세요. (예: 홍길동 10000원 입금)';
}
// 먼저 매칭되는 은행 알림이 있는지 확인
const bankNotification = await db.prepare(
`SELECT id, amount FROM bank_notifications
WHERE depositor_name = ? AND amount = ? AND matched_transaction_id IS NULL
ORDER BY created_at DESC LIMIT 1`
).bind(depositorName, amount).first<{ id: number; amount: number }>();
if (bankNotification) {
// 은행 알림이 이미 있으면 바로 확정 처리
const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description, confirmed_at)
VALUES (?, 'deposit', ?, 'confirmed', ?, '자동 매칭 확정', CURRENT_TIMESTAMP)`
).bind(user.id, amount, depositorName).run();
const txId = result.meta.last_row_id;
// 잔액 증가 + 알림 매칭 업데이트
await db.batch([
db.prepare(
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
).bind(amount, user.id),
db.prepare(
'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?'
).bind(txId, bankNotification.id),
]);
// 업데이트된 잔액 조회
const newDeposit = await db.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number }>();
return `✅ 입금이 확인되었습니다!
📋 <b>거래 번호:</b> #${txId}
💵 <b>입금액:</b> ${amount.toLocaleString()}
👤 <b>입금자:</b> ${depositorName}
💰 <b>현재 잔액:</b> ${newDeposit?.balance.toLocaleString() || 0}
🎉 은행 알림과 자동 매칭되어 즉시 충전되었습니다.`;
}
// 은행 알림이 없으면 pending 거래 생성
const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description)
VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')`
).bind(user.id, amount, depositorName).run();
const txId = result.meta.last_row_id;
return `✅ 입금 요청이 등록되었습니다.
📋 <b>요청 번호:</b> #${txId}
💵 <b>금액:</b> ${amount.toLocaleString()}
👤 <b>입금자명:</b> ${depositorName}
🏦 <b>입금 계좌 안내</b>
은행: 하나은행
계좌: 427-910018-27104
예금주: 주식회사 아이언클래드
⏳ 은행 입금 확인 후 자동으로 충전됩니다.`;
}
case 'transactions': {
const transactions = await db.prepare(
`SELECT id, type, amount, status, depositor_name, created_at
FROM deposit_transactions
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 10`
).bind(user.id).all<{
id: number;
type: string;
amount: number;
status: string;
depositor_name: string;
created_at: string;
}>();
if (!transactions.results?.length) {
return '📜 거래 내역이 없습니다.';
}
const statusEmoji: Record<string, string> = {
pending: '⏳',
confirmed: '✅',
rejected: '❌',
cancelled: '🚫',
};
const typeLabel: Record<string, string> = {
deposit: '입금',
withdrawal: '출금',
refund: '환불',
};
const lines = transactions.results.map(tx => {
const emoji = statusEmoji[tx.status] || '❓';
const type = typeLabel[tx.type] || tx.type;
const date = new Date(tx.created_at).toLocaleDateString('ko-KR');
return `${emoji} #${tx.id} | ${type} ${tx.amount.toLocaleString()}원 | ${date}`;
});
return `📜 <b>최근 거래 내역</b>\n\n${lines.join('\n')}`;
}
case 'cancel': {
if (!transactionId) {
return '❌ 취소할 거래 번호를 입력해주세요.';
}
const tx = await db.prepare(
'SELECT id, status, user_id FROM deposit_transactions WHERE id = ?'
).bind(transactionId).first<{ id: number; status: string; user_id: number }>();
if (!tx) {
return '❌ 거래를 찾을 수 없습니다.';
}
if (tx.user_id !== user.id && !isAdmin) {
return '🚫 본인의 거래만 취소할 수 있습니다.';
}
if (tx.status !== 'pending') {
return '❌ 대기 중인 거래만 취소할 수 있습니다.';
}
await db.prepare(
"UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?"
).bind(transactionId).run();
return `✅ 거래 #${transactionId}이 취소되었습니다.`;
}
// 관리자 전용 기능
case 'pending_list': {
if (!isAdmin) {
return '🚫 관리자 권한이 필요합니다.';
}
const pending = await db.prepare(
`SELECT dt.id, dt.amount, dt.depositor_name, dt.created_at, u.telegram_id, u.username
FROM deposit_transactions dt
JOIN users u ON dt.user_id = u.id
WHERE dt.status = 'pending' AND dt.type = 'deposit'
ORDER BY dt.created_at ASC`
).all<{
id: number;
amount: number;
depositor_name: string;
created_at: string;
telegram_id: string;
username: string;
}>();
if (!pending.results?.length) {
return '✅ 대기 중인 입금 요청이 없습니다.';
}
const lines = pending.results.map(p => {
const date = new Date(p.created_at).toLocaleString('ko-KR');
const user = p.username || p.telegram_id;
return `#${p.id} | ${p.depositor_name} | ${p.amount.toLocaleString()}원 | @${user} | ${date}`;
});
return `⏳ <b>대기 중인 입금 요청</b>\n\n${lines.join('\n')}\n\n입금 확인: "입금 확인 #번호"\n입금 거절: "입금 거절 #번호"`;
}
case 'confirm': {
if (!isAdmin) {
return '🚫 관리자 권한이 필요합니다.';
}
if (!transactionId) {
return '❌ 확인할 거래 번호를 입력해주세요.';
}
const tx = await db.prepare(
'SELECT id, user_id, amount, status FROM deposit_transactions WHERE id = ?'
).bind(transactionId).first<{ id: number; user_id: number; amount: number; status: string }>();
if (!tx) {
return '❌ 거래를 찾을 수 없습니다.';
}
if (tx.status !== 'pending') {
return '❌ 대기 중인 거래만 확인할 수 있습니다.';
}
// 트랜잭션: 상태 변경 + 잔액 증가
await db.batch([
db.prepare(
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?"
).bind(transactionId),
db.prepare(
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
).bind(tx.amount, tx.user_id),
]);
return `✅ 입금 확인 완료!\n\n거래 #${transactionId}\n금액: ${tx.amount.toLocaleString()}`;
}
case 'reject': {
if (!isAdmin) {
return '🚫 관리자 권한이 필요합니다.';
}
if (!transactionId) {
return '❌ 거절할 거래 번호를 입력해주세요.';
}
const tx = await db.prepare(
'SELECT id, status FROM deposit_transactions WHERE id = ?'
).bind(transactionId).first<{ id: number; status: string }>();
if (!tx) {
return '❌ 거래를 찾을 수 없습니다.';
}
if (tx.status !== 'pending') {
return '❌ 대기 중인 거래만 거절할 수 있습니다.';
}
await db.prepare(
"UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?"
).bind(transactionId).run();
return `❌ 입금 거절 완료 (거래 #${transactionId})`;
}
default:
return '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.';
}
}
case 'manage_domain': {
const query = args.query;
console.log('[manage_domain] 시작:', { query, telegramUserId, hasDb: !!db });

View File

@@ -10,6 +10,7 @@ export interface Env {
DOMAIN_AGENT_ID?: string;
NAMECHEAP_API_KEY?: string;
DOMAIN_OWNER_ID?: string;
DEPOSIT_ADMIN_ID?: string;
}
export interface IntentAnalysis {
@@ -69,3 +70,24 @@ export interface ConversationContext {
recentMessages: BufferedMessage[];
totalMessages: number;
}
// Cloudflare Email Workers 타입
export interface EmailMessage {
from: string;
to: string;
headers: Headers;
raw: ReadableStream;
rawSize: number;
setReject(reason: string): void;
forward(to: string): Promise<void>;
}
// 은행 알림 파싱 결과
export interface BankNotification {
bankName: string;
depositorName: string;
amount: number;
balanceAfter?: number;
transactionTime?: Date;
rawMessage: string;
}