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:
kappa
2026-02-05 10:23:20 +09:00
parent cb2bd48ad6
commit f5c38ad909
5 changed files with 1026 additions and 429 deletions

View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
import { executeDepositFunction, type DepositContext } from '../deposit-agent';
import { executeDepositFunction, type DepositContext } from '../agents/deposit-agent';
import type {
Env,
DepositFunctionResult,

View File

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