feat: Gmail → Apps Script → Worker 입금 알림 연동
- POST /api/bank-notification 엔드포인트 추가 - 하나은행 Web발신 SMS 패턴 파싱 지원 - Gmail message_id 기반 중복 방지 - BANK_API_SECRET 인증 추가 - deposit_transactions 자동 매칭 구현 - 문서 업데이트 (CLAUDE.md, README.md) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Env } from './types';
|
||||
import { callDepositAgent } from './deposit-agent';
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
@@ -135,29 +136,16 @@ const tools = [
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'manage_deposit',
|
||||
description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.',
|
||||
description: '예치금을 관리합니다. 잔액 조회, 입금 신고(충전 요청), 거래 내역 조회, 입금 확인(관리자) 등을 수행할 수 있습니다. 사용자가 "입금했어", "송금했어", "충전하고 싶어", "잔액", "10000원 입금" 등의 말을 하면 이 도구를 사용합니다.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
query: {
|
||||
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 (확인/거절/취소 시 필수)',
|
||||
description: '예치금 관련 요청 (예: 잔액 확인, 홍길동 10000원 입금, 거래 내역, 입금 취소 #123)',
|
||||
},
|
||||
},
|
||||
required: ['action'],
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -561,19 +549,15 @@ 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;
|
||||
const query = args.query;
|
||||
console.log('[manage_deposit] 시작:', { query, telegramUserId, hasDb: !!db });
|
||||
|
||||
if (!telegramUserId || !db) {
|
||||
return '🚫 예치금 기능을 사용할 수 없습니다.';
|
||||
}
|
||||
|
||||
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
|
||||
|
||||
// 사용자 조회 또는 생성
|
||||
let user = await db.prepare(
|
||||
// 사용자 조회
|
||||
const user = await db.prepare(
|
||||
'SELECT id FROM users WHERE telegram_id = ?'
|
||||
).bind(telegramUserId).first<{ id: number }>();
|
||||
|
||||
@@ -581,267 +565,37 @@ async function executeTool(name: string, args: Record<string, string>, env?: Env
|
||||
return '🚫 사용자 정보를 찾을 수 없습니다.';
|
||||
}
|
||||
|
||||
// 예치금 계정 조회 또는 생성
|
||||
let deposit = await db.prepare(
|
||||
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
||||
).bind(user.id).first<{ id: number; balance: number }>();
|
||||
const isAdmin = telegramUserId === env?.DEPOSIT_ADMIN_ID;
|
||||
|
||||
if (!deposit) {
|
||||
await db.prepare(
|
||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
||||
).bind(user.id).run();
|
||||
deposit = { id: 0, balance: 0 };
|
||||
if (!env?.OPENAI_API_KEY || !env?.DEPOSIT_AGENT_ID) {
|
||||
console.log('[manage_deposit] DEPOSIT_AGENT_ID 미설정, 기본 응답');
|
||||
return '💰 예치금 에이전트가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
}
|
||||
|
||||
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원 입금)';
|
||||
try {
|
||||
console.log('[manage_deposit] callDepositAgent 호출');
|
||||
const result = await callDepositAgent(
|
||||
env.OPENAI_API_KEY,
|
||||
env.DEPOSIT_AGENT_ID,
|
||||
query,
|
||||
{
|
||||
userId: user.id,
|
||||
telegramUserId,
|
||||
isAdmin,
|
||||
db,
|
||||
}
|
||||
);
|
||||
console.log('[manage_deposit] callDepositAgent 완료:', result?.slice(0, 100));
|
||||
|
||||
// 먼저 매칭되는 은행 알림이 있는지 확인
|
||||
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 '❓ 알 수 없는 작업입니다. 잔액 조회, 충전, 거래 내역 등을 요청해주세요.';
|
||||
// Markdown → HTML 변환
|
||||
const htmlResult = result
|
||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/\*(.+?)\*/g, '<i>$1</i>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||
return `💰 ${htmlResult}`;
|
||||
} catch (error) {
|
||||
console.error('[manage_deposit] 오류:', error);
|
||||
return `💰 예치금 처리 오류: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user