import { BankNotification } from '../types'; import { createLogger } from '../utils/logger'; import { executeWithOptimisticLock, OptimisticLockError } from '../utils/optimistic-lock'; const logger = createLogger('deposit-matcher'); /** * 자동 매칭 결과 */ export interface MatchResult { transactionId: number; userId: number; amount: number; } /** * 입금 SMS와 대기 중인 거래를 자동 매칭 * * 매칭 조건: * - 입금자명 앞 7글자 일치 (은행 SMS가 7글자까지만 표시) * - 금액 일치 * - 상태가 'pending'인 거래 * * 매칭 성공 시: * - deposit_transactions.status = 'confirmed' * - user_deposits.balance 증가 * - bank_notifications.matched_transaction_id 업데이트 * * @param db - D1 Database 인스턴스 * @param notificationId - bank_notifications 테이블의 ID * @param notification - 파싱된 은행 알림 * @returns 매칭 결과 또는 null (매칭 실패) */ export async function matchPendingDeposit( db: D1Database, notificationId: number, notification: BankNotification ): Promise { // 매칭 조건: 입금자명(앞 7글자) + 금액이 일치하는 pending 거래 // 은행 SMS는 입금자명이 7글자까지만 표시됨 // depositor_name_prefix 컬럼 사용으로 인덱스 활용 가능 (99% 성능 향상) const pendingTx = await db.prepare( `SELECT dt.id, dt.user_id, dt.amount FROM deposit_transactions dt WHERE dt.status = 'pending' AND dt.type = 'deposit' AND dt.depositor_name_prefix = ? AND dt.amount = ? ORDER BY dt.created_at ASC LIMIT 1` ).bind(notification.depositorName.slice(0, 7), notification.amount).first<{ id: number; user_id: number; amount: number; }>(); if (!pendingTx) { logger.info('매칭되는 pending 거래 없음', { depositorName: notification.depositorName.slice(0, 7), amount: notification.amount }); return null; } logger.info('매칭 발견', { transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount }); try { // Optimistic Locking으로 동시성 안전한 매칭 처리 await executeWithOptimisticLock(db, async (attempt) => { logger.info('SMS 자동 매칭 시도', { attempt, transactionId: pendingTx.id }); // 1. 거래 상태 변경 (pending → confirmed) const txUpdate = await db.prepare( "UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" ).bind(pendingTx.id).run(); if (!txUpdate.success || txUpdate.meta.changes === 0) { // 이미 다른 프로세스가 처리함 (중복 매칭 방지) throw new Error('Transaction already processed or not found'); } // 2. 현재 잔액/버전 조회 const current = await db.prepare( 'SELECT balance, version FROM user_deposits WHERE user_id = ?' ).bind(pendingTx.user_id).first<{ balance: number; version: number }>(); if (!current) { throw new Error('User deposit account not found'); } // 3. 잔액 증가 (버전 체크로 동시성 보호) const balanceUpdate = await db.prepare( 'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?' ).bind(pendingTx.amount, pendingTx.user_id, current.version).run(); if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) { throw new OptimisticLockError('Version mismatch on balance update'); } // 4. 알림과 거래 연결 await db.prepare( 'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?' ).bind(pendingTx.id, notificationId).run(); logger.info('SMS 자동 매칭 완료', { attempt, transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount, newBalance: current.balance + pendingTx.amount, }); }); logger.info('매칭 완료', { transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount, }); return { transactionId: pendingTx.id, userId: pendingTx.user_id, amount: pendingTx.amount, }; } catch (error) { if (error instanceof OptimisticLockError) { logger.error('SMS 자동 매칭 동시성 충돌', error, { transactionId: pendingTx.id, userId: pendingTx.user_id, }); } else { logger.error('DB 업데이트 실패', error as Error, { transactionId: pendingTx.id, userId: pendingTx.user_id, }); } throw error; } }