- 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>
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
/**
|
|
* 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)}`;
|
|
}
|
|
}
|