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:
432
src/deposit-agent.ts
Normal file
432
src/deposit-agent.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Deposit Agent - 예치금 관리 에이전트 (OpenAI Assistants API)
|
||||
*
|
||||
* 기능:
|
||||
* - 잔액 조회
|
||||
* - 입금 신고 (자동 매칭)
|
||||
* - 거래 내역 조회
|
||||
* - 입금 취소
|
||||
* - [관리자] 대기 목록, 입금 확인/거절
|
||||
*/
|
||||
|
||||
interface DepositContext {
|
||||
userId: number;
|
||||
telegramUserId: string;
|
||||
isAdmin: boolean;
|
||||
db: D1Database;
|
||||
}
|
||||
|
||||
// 예치금 API 함수 실행
|
||||
async function executeDepositFunction(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
context: DepositContext
|
||||
): Promise<any> {
|
||||
const { userId, isAdmin, db } = context;
|
||||
|
||||
// 예치금 계정 조회 또는 생성
|
||||
let deposit = await db.prepare(
|
||||
'SELECT id, balance FROM user_deposits WHERE user_id = ?'
|
||||
).bind(userId).first<{ id: number; balance: number }>();
|
||||
|
||||
if (!deposit) {
|
||||
await db.prepare(
|
||||
'INSERT INTO user_deposits (user_id, balance) VALUES (?, 0)'
|
||||
).bind(userId).run();
|
||||
deposit = { id: 0, balance: 0 };
|
||||
}
|
||||
|
||||
switch (funcName) {
|
||||
case 'get_balance': {
|
||||
return {
|
||||
balance: deposit.balance,
|
||||
formatted: `${deposit.balance.toLocaleString()}원`,
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_account_info': {
|
||||
return {
|
||||
bank: '하나은행',
|
||||
account: '427-910018-27104',
|
||||
holder: '주식회사 아이언클래드',
|
||||
instruction: '입금 후 입금자명과 금액을 알려주세요.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'request_deposit': {
|
||||
const { depositor_name, amount } = funcArgs;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return { error: '충전 금액을 입력해주세요.' };
|
||||
}
|
||||
if (!depositor_name) {
|
||||
return { error: '입금자명을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 먼저 매칭되는 은행 알림이 있는지 확인
|
||||
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(depositor_name, 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(userId, amount, depositor_name).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, userId),
|
||||
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(userId).first<{ balance: number }>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
auto_matched: true,
|
||||
transaction_id: txId,
|
||||
amount: amount,
|
||||
depositor_name: depositor_name,
|
||||
new_balance: newDeposit?.balance || 0,
|
||||
message: '은행 알림과 자동 매칭되어 즉시 충전되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// 은행 알림이 없으면 pending 거래 생성
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, description)
|
||||
VALUES (?, 'deposit', ?, 'pending', ?, '사용자 입금 요청')`
|
||||
).bind(userId, amount, depositor_name).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
auto_matched: false,
|
||||
transaction_id: result.meta.last_row_id,
|
||||
amount: amount,
|
||||
depositor_name: depositor_name,
|
||||
status: 'pending',
|
||||
message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.',
|
||||
account_info: {
|
||||
bank: '하나은행',
|
||||
account: '427-910018-27104',
|
||||
holder: '주식회사 아이언클래드',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_transactions': {
|
||||
const limit = funcArgs.limit || 10;
|
||||
|
||||
const transactions = await db.prepare(
|
||||
`SELECT id, type, amount, status, depositor_name, created_at, confirmed_at
|
||||
FROM deposit_transactions
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`
|
||||
).bind(userId, limit).all<{
|
||||
id: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
depositor_name: string;
|
||||
created_at: string;
|
||||
confirmed_at: string | null;
|
||||
}>();
|
||||
|
||||
if (!transactions.results?.length) {
|
||||
return { transactions: [], message: '거래 내역이 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
transactions: transactions.results.map(tx => ({
|
||||
id: tx.id,
|
||||
type: tx.type,
|
||||
amount: tx.amount,
|
||||
status: tx.status,
|
||||
depositor_name: tx.depositor_name,
|
||||
created_at: tx.created_at,
|
||||
confirmed_at: tx.confirmed_at,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case 'cancel_transaction': {
|
||||
const { transaction_id } = funcArgs;
|
||||
|
||||
if (!transaction_id) {
|
||||
return { error: '취소할 거래 번호를 입력해주세요.' };
|
||||
}
|
||||
|
||||
const tx = await db.prepare(
|
||||
'SELECT id, status, user_id FROM deposit_transactions WHERE id = ?'
|
||||
).bind(transaction_id).first<{ id: number; status: string; user_id: number }>();
|
||||
|
||||
if (!tx) {
|
||||
return { error: '거래를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (tx.user_id !== userId && !isAdmin) {
|
||||
return { error: '본인의 거래만 취소할 수 있습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
return { error: '대기 중인 거래만 취소할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?"
|
||||
).bind(transaction_id).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
message: '거래가 취소되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// 관리자 전용 기능
|
||||
case 'get_pending_list': {
|
||||
if (!isAdmin) {
|
||||
return { error: '관리자 권한이 필요합니다.' };
|
||||
}
|
||||
|
||||
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 { pending: [], message: '대기 중인 입금 요청이 없습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
pending: pending.results.map(p => ({
|
||||
id: p.id,
|
||||
amount: p.amount,
|
||||
depositor_name: p.depositor_name,
|
||||
created_at: p.created_at,
|
||||
user: p.username || p.telegram_id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
case 'confirm_deposit': {
|
||||
if (!isAdmin) {
|
||||
return { error: '관리자 권한이 필요합니다.' };
|
||||
}
|
||||
|
||||
const { transaction_id } = funcArgs;
|
||||
if (!transaction_id) {
|
||||
return { error: '확인할 거래 번호를 입력해주세요.' };
|
||||
}
|
||||
|
||||
const tx = await db.prepare(
|
||||
'SELECT id, user_id, amount, status FROM deposit_transactions WHERE id = ?'
|
||||
).bind(transaction_id).first<{ id: number; user_id: number; amount: number; status: string }>();
|
||||
|
||||
if (!tx) {
|
||||
return { error: '거래를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
return { error: '대기 중인 거래만 확인할 수 있습니다.' };
|
||||
}
|
||||
|
||||
// 트랜잭션: 상태 변경 + 잔액 증가
|
||||
await db.batch([
|
||||
db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
).bind(transaction_id),
|
||||
db.prepare(
|
||||
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
||||
).bind(tx.amount, tx.user_id),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
amount: tx.amount,
|
||||
message: '입금이 확인되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'reject_deposit': {
|
||||
if (!isAdmin) {
|
||||
return { error: '관리자 권한이 필요합니다.' };
|
||||
}
|
||||
|
||||
const { transaction_id } = funcArgs;
|
||||
if (!transaction_id) {
|
||||
return { error: '거절할 거래 번호를 입력해주세요.' };
|
||||
}
|
||||
|
||||
const tx = await db.prepare(
|
||||
'SELECT id, status FROM deposit_transactions WHERE id = ?'
|
||||
).bind(transaction_id).first<{ id: number; status: string }>();
|
||||
|
||||
if (!tx) {
|
||||
return { error: '거래를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
return { error: '대기 중인 거래만 거절할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?"
|
||||
).bind(transaction_id).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
message: '입금 요청이 거절되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `알 수 없는 함수: ${funcName}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Deposit Agent 호출 (Assistants API)
|
||||
export async function callDepositAgent(
|
||||
apiKey: string,
|
||||
assistantId: string,
|
||||
query: string,
|
||||
context: DepositContext
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 1. Thread 생성
|
||||
const threadRes = await fetch('https://api.openai.com/v1/threads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!threadRes.ok) return `Thread 생성 실패 (${threadRes.status})`;
|
||||
const thread = await threadRes.json() as { id: string };
|
||||
|
||||
// 2. 메시지 추가 (권한 정보 포함)
|
||||
const adminInfo = context.isAdmin ? '관리자 권한이 있습니다.' : '일반 사용자입니다.';
|
||||
const instructions = `[시스템 정보]
|
||||
- ${adminInfo}
|
||||
- 사용자 ID: ${context.telegramUserId}
|
||||
|
||||
[사용자 요청]
|
||||
${query}`;
|
||||
|
||||
await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
role: 'user',
|
||||
content: instructions,
|
||||
}),
|
||||
});
|
||||
|
||||
// 3. Run 생성
|
||||
const runRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({ assistant_id: assistantId }),
|
||||
});
|
||||
if (!runRes.ok) return `Run 생성 실패 (${runRes.status})`;
|
||||
let run = await runRes.json() as { id: string; status: string; required_action?: any };
|
||||
|
||||
// 4. 완료까지 폴링 및 Function Calling 처리
|
||||
let maxPolls = 30; // 최대 15초
|
||||
while ((run.status === 'queued' || run.status === 'in_progress' || run.status === 'requires_action') && maxPolls > 0) {
|
||||
if (run.status === 'requires_action') {
|
||||
const toolCalls = run.required_action?.submit_tool_outputs?.tool_calls || [];
|
||||
const toolOutputs = [];
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const funcName = toolCall.function.name;
|
||||
const funcArgs = JSON.parse(toolCall.function.arguments);
|
||||
console.log(`[DepositAgent] Function call: ${funcName}`, funcArgs);
|
||||
|
||||
const result = await executeDepositFunction(funcName, funcArgs, context);
|
||||
toolOutputs.push({
|
||||
tool_call_id: toolCall.id,
|
||||
output: JSON.stringify(result),
|
||||
});
|
||||
}
|
||||
|
||||
// Tool outputs 제출
|
||||
const submitRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}/submit_tool_outputs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
body: JSON.stringify({ tool_outputs: toolOutputs }),
|
||||
});
|
||||
run = await submitRes.json() as { id: string; status: string; required_action?: any };
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
maxPolls--;
|
||||
|
||||
const statusRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/runs/${run.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
run = await statusRes.json() as { id: string; status: string; required_action?: any };
|
||||
}
|
||||
|
||||
if (run.status === 'failed') return '예치금 에이전트 실행 실패';
|
||||
if (maxPolls === 0) return '응답 시간 초과. 다시 시도해주세요.';
|
||||
|
||||
// 5. 메시지 조회
|
||||
const messagesRes = await fetch(`https://api.openai.com/v1/threads/${thread.id}/messages`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'OpenAI-Beta': 'assistants=v2',
|
||||
},
|
||||
});
|
||||
const messages = await messagesRes.json() as { data: Array<{ role: string; content: Array<{ type: string; text?: { value: string } }> }> };
|
||||
const lastMessage = messages.data[0];
|
||||
|
||||
if (lastMessage?.content?.[0]?.type === 'text') {
|
||||
return lastMessage.content[0].text?.value || '응답 없음';
|
||||
}
|
||||
|
||||
return '예치금 에이전트 응답 없음';
|
||||
} catch (error) {
|
||||
console.error('[DepositAgent] Error:', error);
|
||||
return `예치금 에이전트 오류: ${String(error)}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user