From f5c38ad90957073b89c669517ed726b14f9cb8f4 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 5 Feb 2026 10:23:20 +0900 Subject: [PATCH] refactor: move deposit-agent to agents/ and add session support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- migrations/006_add_deposit_sessions.sql | 16 + src/agents/deposit-agent.ts | 1006 +++++++++++++++++++++++ src/deposit-agent.ts | 428 ---------- src/tools/deposit-tool.ts | 2 +- src/types.ts | 3 + 5 files changed, 1026 insertions(+), 429 deletions(-) create mode 100644 migrations/006_add_deposit_sessions.sql create mode 100644 src/agents/deposit-agent.ts delete mode 100644 src/deposit-agent.ts diff --git a/migrations/006_add_deposit_sessions.sql b/migrations/006_add_deposit_sessions.sql new file mode 100644 index 0000000..05a2eb7 --- /dev/null +++ b/migrations/006_add_deposit_sessions.sql @@ -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); diff --git a/src/agents/deposit-agent.ts b/src/agents/deposit-agent.ts new file mode 100644 index 0000000..a1afc2c --- /dev/null +++ b/src/agents/deposit-agent.ts @@ -0,0 +1,1006 @@ +/** + * Deposit Agent - 예치금 관리 (세션 기반 입금 신고) + * + * 변경 이력: + * - 2026-01: Assistants API → 코드 직접 처리로 변경 (지역 제한 우회, 응답 일관성) + * - 2026-02: 세션 기반 입금 신고 흐름 추가 (collecting_amount → collecting_name → confirming → completed) + * + * 기능: + * - [세션] 입금 신고 (스마트 파싱: "홍길동 5만원" → 바로 confirming) + * - [즉시] 잔액 조회 + * - [즉시] 계좌 안내 + * - [즉시] 거래 내역 조회 + * - [즉시] 입금 취소 + * - [즉시/관리자] 대기 목록, 입금 확인/거절 + */ + +import { createLogger } from '../utils/logger'; +import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock'; +import { TRANSACTION_STATUS, TRANSACTION_TYPE } from '../constants'; +import type { Env, ManageDepositArgs, DepositFunctionResult, DepositSession, DepositSessionStatus } 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; + +// D1 Session Management +const DEPOSIT_SESSION_TTL = 30 * 60 * 1000; // 30분 (입금 작업은 빠르게 처리) +const MAX_MESSAGES = 10; // 세션당 최대 메시지 수 + +/** + * D1에서 입금 세션 조회 + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @returns DepositSession 또는 null (세션 없거나 만료) + */ +export async function getDepositSession( + db: D1Database, + userId: string +): Promise { + try { + const now = Date.now(); + const result = await db.prepare( + 'SELECT * FROM deposit_sessions WHERE user_id = ? AND expires_at > ?' + ).bind(userId, now).first<{ + user_id: string; + status: string; + collected_info: string | null; + messages: string | null; + created_at: number; + updated_at: number; + expires_at: number; + }>(); + + if (!result) { + logger.info('입금 세션 없음', { userId }); + return null; + } + + const session: DepositSession = { + user_id: result.user_id, + status: result.status as DepositSessionStatus, + collected_info: result.collected_info ? JSON.parse(result.collected_info) : {}, + messages: result.messages ? JSON.parse(result.messages) : [], + created_at: result.created_at, + updated_at: result.updated_at, + expires_at: result.expires_at, + }; + + logger.info('입금 세션 조회 성공', { userId, status: session.status }); + return session; + } catch (error) { + logger.error('입금 세션 조회 실패', error as Error, { userId }); + return null; + } +} + +/** + * 입금 세션 저장 (생성 또는 업데이트) + * + * @param db - D1 Database + * @param session - DepositSession + */ +export async function saveDepositSession( + db: D1Database, + session: DepositSession +): Promise { + try { + const now = Date.now(); + const expiresAt = now + DEPOSIT_SESSION_TTL; + + await db.prepare(` + INSERT INTO deposit_sessions + (user_id, status, collected_info, messages, created_at, updated_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + status = excluded.status, + collected_info = excluded.collected_info, + messages = excluded.messages, + updated_at = excluded.updated_at, + expires_at = excluded.expires_at + `).bind( + session.user_id, + session.status, + JSON.stringify(session.collected_info || {}), + JSON.stringify(session.messages || []), + session.created_at || now, + now, + expiresAt + ).run(); + + logger.info('입금 세션 저장 성공', { userId: session.user_id, status: session.status }); + } catch (error) { + logger.error('입금 세션 저장 실패', error as Error, { userId: session.user_id }); + throw error; + } +} + +/** + * 입금 세션 삭제 + * + * @param db - D1 Database + * @param userId - Telegram User ID + */ +export async function deleteDepositSession( + db: D1Database, + userId: string +): Promise { + try { + await db.prepare('DELETE FROM deposit_sessions WHERE user_id = ?') + .bind(userId) + .run(); + logger.info('입금 세션 삭제 성공', { userId }); + } catch (error) { + logger.error('입금 세션 삭제 실패', error as Error, { userId }); + throw error; + } +} + +/** + * 새 입금 세션 생성 + * + * @param userId - Telegram User ID + * @param status - 세션 상태 + * @returns 새로운 DepositSession 객체 + */ +export function createDepositSession( + userId: string, + status: DepositSessionStatus = 'collecting_amount' +): DepositSession { + const now = Date.now(); + return { + user_id: userId, + status, + collected_info: {}, + messages: [], + created_at: now, + updated_at: now, + expires_at: now + DEPOSIT_SESSION_TTL, + }; +} + +/** + * 세션 만료 여부 확인 + * + * @param session - DepositSession + * @returns true if expired, false otherwise + */ +export function isSessionExpired(session: DepositSession): boolean { + return session.expires_at < Date.now(); +} + +/** + * 세션에 메시지 추가 + * + * @param session - DepositSession + * @param role - 메시지 역할 ('user' | 'assistant') + * @param content - 메시지 내용 + */ +export function addMessageToSession( + session: DepositSession, + role: 'user' | 'assistant', + content: string +): void { + session.messages.push({ role, content }); + + // 최대 메시지 수 제한 + if (session.messages.length > MAX_MESSAGES) { + session.messages = session.messages.slice(-MAX_MESSAGES); + logger.warn('세션 메시지 최대 개수 초과, 오래된 메시지 제거', { + userId: session.user_id, + maxMessages: MAX_MESSAGES, + }); + } +} + +/** + * 입금 세션 존재 여부 확인 (라우팅용) + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @returns true if active session exists, false otherwise + */ +export async function hasDepositSession(db: D1Database, userId: string): Promise { + const session = await getDepositSession(db, userId); + return session !== null && !isSessionExpired(session); +} + +export interface DepositContext { + userId: number; + telegramUserId: string; + isAdmin: boolean; + db: D1Database; + env?: { + DEPOSIT_BANK_NAME?: string; + DEPOSIT_BANK_ACCOUNT?: string; + DEPOSIT_BANK_HOLDER?: string; + }; +} + +// Deposit Expert System Prompt +const DEPOSIT_EXPERT_PROMPT = `당신은 친절한 금융 상담사입니다. + +전문 분야: +- 예치금 입금 절차 안내 +- 자동 매칭 결과 즉시 알려줌 +- 오입금 방지를 위한 확인 절차 + +행동 지침: +1. 명확하고 간결하게 안내 +2. 입금 전 금액과 입금자명 확인 +3. 자동 매칭 여부 즉시 공지 +4. 계좌번호는 명확히 전달 + +응답 형식: +- 짧고 명확하게 답변 +- 불필요한 인사말이나 서론 없이 바로 본론 +- 금액은 항상 원화(원)로 표시 + +특수 지시: +- 입금 신고와 무관한 메시지가 들어오면 반드시 "__PASSTHROUGH__"만 응답 +- 세션 종료가 필요하면 "__SESSION_END__"를 응답 끝에 추가`; + +// Deposit Tools for Function Calling +const DEPOSIT_TOOLS = [ + { + type: 'function' as const, + function: { + name: 'get_balance', + description: '현재 잔액 조회', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } + }, + { + type: 'function' as const, + function: { + name: 'get_account_info', + description: '입금 계좌 정보 조회', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } + }, + { + type: 'function' as const, + function: { + name: 'request_deposit', + description: '입금 신고 처리 (금액과 입금자명 필수)', + parameters: { + type: 'object', + properties: { + amount: { type: 'number', description: '입금 금액 (원)' }, + depositor_name: { type: 'string', description: '입금자명' } + }, + required: ['amount', 'depositor_name'] + } + } + } +]; + +// OpenAI API response types +interface OpenAIToolCall { + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +interface OpenAIMessage { + role: 'assistant'; + content: string | null; + tool_calls?: OpenAIToolCall[]; +} + +interface OpenAIAPIResponse { + choices: Array<{ + message: OpenAIMessage; + finish_reason: string; + }>; +} + +/** + * Deposit Expert AI 호출 (Function Calling 지원) + * + * @param session - DepositSession + * @param userMessage - 사용자 메시지 + * @param env - Environment + * @returns AI 응답 및 tool_calls (있을 경우) + */ +async function callDepositExpertAI( + session: DepositSession, + userMessage: string, + env: Env +): Promise<{ response: string; toolCalls?: Array<{ name: string; arguments: Record }> }> { + if (!env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY not configured'); + } + + const { getOpenAIUrl } = await import('../utils/api-urls'); + + // Build conversation history + const conversationHistory = session.messages.map(m => ({ + role: m.role === 'user' ? 'user' as const : 'assistant' as const, + content: m.content, + })); + + const systemPrompt = `${DEPOSIT_EXPERT_PROMPT} + +## 현재 수집된 정보 +${JSON.stringify(session.collected_info, null, 2)} + +## 대화 흐름 +1. 금액 파악: "얼마 입금하셨나요?" +2. 입금자명 파악: "입금자명을 알려주세요" +3. 확인: 금액과 입금자명 확인 후 request_deposit 호출 + +## 스마트 파싱 규칙 +- "홍길동 5만원 입금" → 금액(50000) + 입금자명(홍길동) 모두 추출 → 바로 confirming 상태 +- "3만원 입금" → 금액만 추출 → collecting_name 상태 +- "홍길동" (금액 먼저 수집된 경우) → 입금자명만 추출 → confirming 상태 + +## 도구 사용 가이드 +- 잔액 문의 → get_balance +- 계좌번호 문의 → get_account_info +- 금액 + 입금자명 모두 있으면 → request_deposit`; + + try { + const messages: Array<{ + role: string; + content: string | null; + tool_calls?: OpenAIToolCall[]; + tool_call_id?: string; + name?: string + }> = [ + { role: 'system', content: systemPrompt }, + ...conversationHistory, + { role: 'user', content: userMessage }, + ]; + + const MAX_TOOL_CALLS = 3; + let toolCallCount = 0; + + // Loop to handle tool calls + while (toolCallCount < MAX_TOOL_CALLS) { + const requestBody = { + model: 'gpt-4o-mini', + messages, + tools: DEPOSIT_TOOLS, + tool_choice: 'auto', + max_tokens: 500, + temperature: 0.7, + }; + + const response = await fetch(getOpenAIUrl(env), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`OpenAI API error: ${response.status} - ${error}`); + } + + const data = await response.json() as OpenAIAPIResponse; + const assistantMessage = data.choices[0].message; + + // Check if AI wants to call tools + if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) { + logger.info('도구 호출 요청', { + tools: assistantMessage.tool_calls.map(tc => tc.function.name), + }); + + // Return tool calls to be executed by caller + const toolCalls = assistantMessage.tool_calls.map(tc => ({ + name: tc.function.name, + arguments: JSON.parse(tc.function.arguments) as Record, + })); + + return { + response: assistantMessage.content || '', + toolCalls, + }; + } + + // No tool calls - return final response + const aiResponse = assistantMessage.content || ''; + logger.info('AI 응답', { response: aiResponse.slice(0, 200) }); + + // Check for special markers + if (aiResponse.includes('__PASSTHROUGH__')) { + return { response: '__PASSTHROUGH__' }; + } + + // Check for session end marker + const sessionEnd = aiResponse.includes('__SESSION_END__'); + const cleanResponse = aiResponse.replace('__SESSION_END__', '').trim(); + + return { + response: sessionEnd ? `${cleanResponse}\n\n[세션 종료]` : cleanResponse, + }; + } + + // Max tool calls reached + logger.warn('최대 도구 호출 횟수 도달', { toolCallCount }); + return { + response: '입금 정보를 확인했습니다.', + }; + } catch (error) { + logger.error('Deposit Expert AI 호출 실패', error as Error); + throw error; + } +} + +/** + * 입금 도구 실행 디스패처 + * + * Deposit Agent AI가 호출한 tool을 실제 executeDepositFunction으로 매핑 + * + * @param toolName - 도구 이름 + * @param args - 도구 인자 + * @param context - DepositContext + * @returns 도구 실행 결과 (텍스트) + */ +export async function executeDepositToolCall( + toolName: string, + args: Record, + context: DepositContext +): Promise { + logger.info('입금 도구 실행', { toolName, args }); + + const funcArgs: ManageDepositArgs = { + action: toolName === 'get_balance' ? 'balance' : + toolName === 'get_account_info' ? 'account' : + toolName === 'request_deposit' ? 'request' : + 'balance' // fallback + }; + + if (args.amount) funcArgs.amount = Number(args.amount); + if (args.depositor_name) funcArgs.depositor_name = String(args.depositor_name); + + try { + const result = await executeDepositFunction(toolName, funcArgs, context); + + // Format result as text + if ('error' in result) { + return `❌ ${result.error}`; + } + + if ('formatted' in result) { + return `💰 현재 잔액: ${result.formatted}`; + } + + if ('bank' in result && 'account' in result) { + return `💳 입금 계좌\n${result.bank} ${result.account}\n예금주: ${result.holder}`; + } + + if ('auto_matched' in result && 'amount' in result) { + if (result.auto_matched) { + return `✅ 입금 확인!\n${result.amount.toLocaleString()}원이 즉시 충전되었습니다.\n현재 잔액: ${result.new_balance?.toLocaleString()}원`; + } else { + return `📋 입금 요청 등록 완료\n${result.amount.toLocaleString()}원 (${result.depositor_name})\n은행 입금 확인 후 자동으로 충전됩니다.`; + } + } + + return JSON.stringify(result); + } catch (error) { + logger.error('입금 도구 실행 실패', error as Error, { toolName, args }); + return '❌ 처리 중 오류가 발생했습니다.'; + } +} + +/** + * 입금 상담 처리 (메인 함수) + * + * @param db - D1 Database + * @param userId - Telegram User ID + * @param userMessage - 사용자 메시지 + * @param env - Environment + * @returns AI 응답 메시지 + */ +export async function processDepositConsultation( + db: D1Database, + userId: string, + userMessage: string, + env: Env +): Promise { + const startTime = Date.now(); + logger.info('입금 상담 시작', { userId, message: userMessage.substring(0, 100) }); + + try { + // 1. Check for existing session + let session = await getDepositSession(db, userId); + + // 2. Create new session if none exists + if (!session) { + session = createDepositSession(userId, 'collecting_amount'); + } + + // 3. Add user message to session + addMessageToSession(session, 'user', userMessage); + + // 4. Call AI to get response and possible tool calls + const aiResult = await callDepositExpertAI(session, userMessage, env); + + // 5. Handle __PASSTHROUGH__ - not deposit related + if (aiResult.response === '__PASSTHROUGH__' || aiResult.response.includes('__PASSTHROUGH__')) { + logger.info('입금 상담 패스스루', { userId }); + return '__PASSTHROUGH__'; + } + + // 6. Get user context for tool execution + const user = await db.prepare( + 'SELECT id FROM users WHERE telegram_id = ?' + ).bind(userId).first<{ id: number }>(); + + if (!user) { + return '사용자 정보를 찾을 수 없습니다.'; + } + + const isAdmin = userId === env.DEPOSIT_ADMIN_ID; + const context: DepositContext = { + userId: user.id, + telegramUserId: userId, + isAdmin, + db, + env: { + DEPOSIT_BANK_NAME: env.DEPOSIT_BANK_NAME, + DEPOSIT_BANK_ACCOUNT: env.DEPOSIT_BANK_ACCOUNT, + DEPOSIT_BANK_HOLDER: env.DEPOSIT_BANK_HOLDER, + } + }; + + // 7. Execute tool calls if any + let toolResults: string[] = []; + if (aiResult.toolCalls && aiResult.toolCalls.length > 0) { + for (const toolCall of aiResult.toolCalls) { + const result = await executeDepositToolCall( + toolCall.name, + toolCall.arguments, + context + ); + toolResults.push(result); + } + } + + // 8. Build final response + let finalResponse = aiResult.response; + if (toolResults.length > 0) { + finalResponse = toolResults.join('\n\n'); + if (aiResult.response && !aiResult.response.includes('__SESSION_END__')) { + finalResponse = aiResult.response + '\n\n' + finalResponse; + } + } + + // 9. Handle __SESSION_END__ - session complete + if (finalResponse.includes('__SESSION_END__')) { + logger.info('입금 상담 세션 종료', { userId }); + await deleteDepositSession(db, userId); + finalResponse = finalResponse.replace('__SESSION_END__', '').trim(); + return finalResponse; + } + + // 10. Add assistant response to session and save + addMessageToSession(session, 'assistant', finalResponse); + session.updated_at = Date.now(); + await saveDepositSession(db, session); + + logger.info('입금 상담 완료', { + userId, + duration: Date.now() - startTime, + hasToolCalls: aiResult.toolCalls?.length || 0 + }); + + return finalResponse; + + } catch (error) { + logger.error('입금 상담 오류', error as Error, { userId }); + return '죄송합니다. 입금 상담 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'; + } +} + +/** + * 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 + */ +export async function executeDepositFunction( + funcName: string, + funcArgs: ManageDepositArgs, + context: DepositContext +): Promise { + 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}` }; + } +} diff --git a/src/deposit-agent.ts b/src/deposit-agent.ts deleted file mode 100644 index d4fa982..0000000 --- a/src/deposit-agent.ts +++ /dev/null @@ -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 - */ -export async function executeDepositFunction( - funcName: string, - funcArgs: ManageDepositArgs, - context: DepositContext -): Promise { - 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}` }; - } -} diff --git a/src/tools/deposit-tool.ts b/src/tools/deposit-tool.ts index 01624ca..482bce1 100644 --- a/src/tools/deposit-tool.ts +++ b/src/tools/deposit-tool.ts @@ -1,4 +1,4 @@ -import { executeDepositFunction, type DepositContext } from '../deposit-agent'; +import { executeDepositFunction, type DepositContext } from '../agents/deposit-agent'; import type { Env, DepositFunctionResult, diff --git a/src/types.ts b/src/types.ts index ddd08da..749e9e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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;