refactor: move deposit-agent to agents/ and add session support
- Move deposit-agent.ts to src/agents/ - Add D1 session CRUD functions (getDepositSession, saveDepositSession, etc.) - Add Deposit Expert AI with function calling - Add processDepositConsultation handler for session-based flow - Add deposit_sessions table migration (006_add_deposit_sessions.sql) - Update import paths in deposit-tool.ts - Add DEPOSIT_BANK_* environment variables to Env interface Session flow: collecting_amount → collecting_name → confirming → completed Smart parsing: "홍길동 5만원" → Go directly to confirming Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
16
migrations/006_add_deposit_sessions.sql
Normal file
16
migrations/006_add_deposit_sessions.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Migration: Add deposit_sessions table for session-based deposit requests
|
||||
-- Date: 2026-02-05
|
||||
-- Description: 입금 신고 세션 관리 (collecting_amount → collecting_name → confirming → completed)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deposit_sessions (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL CHECK(status IN ('collecting_amount', 'collecting_name', 'confirming', 'completed')),
|
||||
collected_info TEXT, -- JSON: { amount?: number, depositor_name?: string }
|
||||
messages TEXT, -- JSON: [{ role: 'user' | 'assistant', content: string }]
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Index for cleanup queries (expired sessions)
|
||||
CREATE INDEX IF NOT EXISTS idx_deposit_sessions_expires_at ON deposit_sessions(expires_at);
|
||||
1006
src/agents/deposit-agent.ts
Normal file
1006
src/agents/deposit-agent.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,428 +0,0 @@
|
||||
/**
|
||||
* Deposit Agent - 예치금 관리 (코드 직접 처리)
|
||||
*
|
||||
* 변경 이력:
|
||||
* - 2026-01: Assistants API → 코드 직접 처리로 변경 (지역 제한 우회, 응답 일관성)
|
||||
*
|
||||
* 기능:
|
||||
* - 잔액 조회
|
||||
* - 입금 신고 (자동 매칭)
|
||||
* - 거래 내역 조회
|
||||
* - 입금 취소
|
||||
* - [관리자] 대기 목록, 입금 확인/거절
|
||||
*/
|
||||
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
|
||||
import { TRANSACTION_STATUS, TRANSACTION_TYPE } from './constants';
|
||||
import type { ManageDepositArgs, DepositFunctionResult } from './types';
|
||||
|
||||
const logger = createLogger('deposit-agent');
|
||||
|
||||
const MIN_DEPOSIT_AMOUNT = 1000; // 1,000원
|
||||
const MAX_DEPOSIT_AMOUNT = 100_000_000; // 1억원
|
||||
const DEFAULT_HISTORY_LIMIT = 10;
|
||||
|
||||
export interface DepositContext {
|
||||
userId: number;
|
||||
telegramUserId: string;
|
||||
isAdmin: boolean;
|
||||
db: D1Database;
|
||||
env?: {
|
||||
DEPOSIT_BANK_NAME?: string;
|
||||
DEPOSIT_BANK_ACCOUNT?: string;
|
||||
DEPOSIT_BANK_HOLDER?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute deposit-related functions
|
||||
* @param funcName - Function name (get_balance, request_deposit, etc.)
|
||||
* @param funcArgs - Function arguments validated by Zod schema
|
||||
* @param context - User context including userId, isAdmin, db, env
|
||||
* @returns Promise<DepositFunctionResult>
|
||||
*/
|
||||
export async function executeDepositFunction(
|
||||
funcName: string,
|
||||
funcArgs: ManageDepositArgs,
|
||||
context: DepositContext
|
||||
): Promise<DepositFunctionResult> {
|
||||
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: context.env?.DEPOSIT_BANK_NAME || '하나은행',
|
||||
account: context.env?.DEPOSIT_BANK_ACCOUNT || '427-910018-27104',
|
||||
holder: context.env?.DEPOSIT_BANK_HOLDER || '주식회사 아이언클래드',
|
||||
instruction: '입금 후 입금자명과 금액을 알려주세요.',
|
||||
};
|
||||
}
|
||||
|
||||
case 'request_deposit': {
|
||||
const { depositor_name, amount } = funcArgs;
|
||||
|
||||
if (!amount || amount <= 0) {
|
||||
return { error: '충전 금액을 입력해주세요.' };
|
||||
}
|
||||
if (amount < MIN_DEPOSIT_AMOUNT) {
|
||||
return { error: `최소 충전 금액은 ${MIN_DEPOSIT_AMOUNT.toLocaleString()}원입니다.` };
|
||||
}
|
||||
if (amount > MAX_DEPOSIT_AMOUNT) {
|
||||
return { error: `최대 충전 금액은 ${MAX_DEPOSIT_AMOUNT.toLocaleString()}원입니다.` };
|
||||
}
|
||||
if (!depositor_name) {
|
||||
return { error: '입금자명을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 먼저 매칭되는 은행 알림이 있는지 확인 (은행 SMS는 7글자 제한)
|
||||
// depositor_name_prefix 컬럼 사용으로 인덱스 활용 가능 (99% 성능 향상)
|
||||
const bankNotification = await db.prepare(
|
||||
`SELECT id, amount FROM bank_notifications
|
||||
WHERE depositor_name_prefix = ? AND amount = ? AND matched_transaction_id IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`
|
||||
).bind(depositor_name.slice(0, 7), amount).first<{ id: number; amount: number }>();
|
||||
|
||||
if (bankNotification) {
|
||||
// 은행 알림이 이미 있으면 바로 확정 처리 (Optimistic Locking 적용)
|
||||
try {
|
||||
const txId = await executeWithOptimisticLock(db, async (attempt) => {
|
||||
// 1. Insert transaction record
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
|
||||
).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.CONFIRMED, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
|
||||
const txId = result.meta.last_row_id;
|
||||
|
||||
// 2. Get current version
|
||||
const current = await db.prepare(
|
||||
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
|
||||
).bind(userId).first<{ balance: number; version: number }>();
|
||||
|
||||
if (!current) {
|
||||
throw new Error('User deposit account not found');
|
||||
}
|
||||
|
||||
// 3. Update balance with version check
|
||||
const balanceUpdate = await db.prepare(
|
||||
'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
|
||||
).bind(amount, userId, current.version).run();
|
||||
|
||||
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
|
||||
throw new OptimisticLockError('Version mismatch on balance update');
|
||||
}
|
||||
|
||||
// 4. Update bank notification matching
|
||||
const notificationUpdate = await db.prepare(
|
||||
'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?'
|
||||
).bind(txId, bankNotification.id).run();
|
||||
|
||||
if (!notificationUpdate.success) {
|
||||
logger.error('Bank notification update failed', undefined, {
|
||||
txId,
|
||||
bankNotificationId: bankNotification.id,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
return txId;
|
||||
});
|
||||
|
||||
// 업데이트된 잔액 조회
|
||||
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: '은행 알림과 자동 매칭되어 즉시 충전되었습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof OptimisticLockError) {
|
||||
logger.warn('동시성 충돌 감지 (입금 자동 매칭)', { userId, amount, depositor_name });
|
||||
return {
|
||||
error: '⚠️ 동시 요청으로 처리가 지연되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
}
|
||||
logger.error('입금 자동 매칭 실패', error as Error, { userId, amount, depositor_name });
|
||||
return {
|
||||
error: '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 은행 알림이 없으면 pending 거래 생성
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, '입금 대기')`
|
||||
).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.PENDING, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
auto_matched: false,
|
||||
transaction_id: result.meta.last_row_id,
|
||||
amount: amount,
|
||||
depositor_name: depositor_name,
|
||||
status: TRANSACTION_STATUS.PENDING,
|
||||
message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.',
|
||||
account_info: {
|
||||
bank: context.env?.DEPOSIT_BANK_NAME || '하나은행',
|
||||
account: context.env?.DEPOSIT_BANK_ACCOUNT || '427-910018-27104',
|
||||
holder: context.env?.DEPOSIT_BANK_HOLDER || '주식회사 아이언클래드',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'get_transactions': {
|
||||
// LIMIT 값 검증: 1-100 범위
|
||||
const limit = Math.min(Math.max(parseInt(String(funcArgs.limit)) || DEFAULT_HISTORY_LIMIT, 1), 100);
|
||||
|
||||
const transactions = await db.prepare(
|
||||
`SELECT id, type, amount, status, depositor_name, description, 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;
|
||||
description: string | null;
|
||||
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,
|
||||
description: tx.description,
|
||||
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 !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 취소할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = ? WHERE id = ?"
|
||||
).bind(TRANSACTION_STATUS.CANCELLED, 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 = ? AND dt.type = ?
|
||||
ORDER BY dt.created_at ASC`
|
||||
).bind(TRANSACTION_STATUS.PENDING, TRANSACTION_TYPE.DEPOSIT).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 !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 확인할 수 있습니다.' };
|
||||
}
|
||||
|
||||
// 트랜잭션: 상태 변경 + 잔액 증가 (Optimistic Locking 적용)
|
||||
try {
|
||||
await executeWithOptimisticLock(db, async (attempt) => {
|
||||
// 1. Update transaction status
|
||||
const txUpdate = await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ?"
|
||||
).bind(TRANSACTION_STATUS.CONFIRMED, transaction_id, TRANSACTION_STATUS.PENDING).run();
|
||||
|
||||
if (!txUpdate.success || txUpdate.meta.changes === 0) {
|
||||
throw new Error('Transaction already processed or not found');
|
||||
}
|
||||
|
||||
// 2. Get current version
|
||||
const current = await db.prepare(
|
||||
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
|
||||
).bind(tx.user_id).first<{ balance: number; version: number }>();
|
||||
|
||||
if (!current) {
|
||||
throw new Error('User deposit account not found');
|
||||
}
|
||||
|
||||
// 3. Update balance with version check
|
||||
const balanceUpdate = await db.prepare(
|
||||
'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
|
||||
).bind(tx.amount, tx.user_id, current.version).run();
|
||||
|
||||
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
|
||||
throw new OptimisticLockError('Version mismatch on balance update');
|
||||
}
|
||||
|
||||
logger.info('Deposit confirmed with optimistic locking', {
|
||||
transaction_id,
|
||||
user_id: tx.user_id,
|
||||
amount: tx.amount,
|
||||
attempt,
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
amount: tx.amount,
|
||||
message: '입금이 확인되었습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof OptimisticLockError) {
|
||||
logger.warn('동시성 충돌 감지 (관리자 입금 확인)', {
|
||||
transaction_id,
|
||||
user_id: tx.user_id,
|
||||
amount: tx.amount,
|
||||
});
|
||||
return {
|
||||
error: '⚠️ 동시 요청으로 처리가 지연되었습니다. 잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
}
|
||||
logger.error('관리자 입금 확인 실패', error as Error, { transaction_id, user_id: tx.user_id, amount: tx.amount });
|
||||
return {
|
||||
error: '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 거절할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = ? WHERE id = ?"
|
||||
).bind(TRANSACTION_STATUS.REJECTED, transaction_id).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
message: '입금 요청이 거절되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return { error: `알 수 없는 기능: ${funcName}` };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { executeDepositFunction, type DepositContext } from '../deposit-agent';
|
||||
import { executeDepositFunction, type DepositContext } from '../agents/deposit-agent';
|
||||
import type {
|
||||
Env,
|
||||
DepositFunctionResult,
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface Env {
|
||||
NAMECHEAP_API_KEY_INTERNAL?: string;
|
||||
DOMAIN_OWNER_ID?: string;
|
||||
DEPOSIT_ADMIN_ID?: string;
|
||||
DEPOSIT_BANK_NAME?: string;
|
||||
DEPOSIT_BANK_ACCOUNT?: string;
|
||||
DEPOSIT_BANK_HOLDER?: string;
|
||||
BRAVE_API_KEY?: string;
|
||||
DEPOSIT_API_SECRET?: string;
|
||||
PROVISION_API_KEY?: string;
|
||||
|
||||
Reference in New Issue
Block a user