1. API Key Middleware (api.ts) - Create apiKeyAuth Hono middleware with timing-safe comparison - Apply to /deposit/balance and /deposit/deduct routes - Remove duplicate requireApiKey() calls from handlers - Reduce ~15 lines of duplicated code 2. Logger Standardization (6 files, 27 replacements) - webhook.ts: 2 console.error → logger - message-handler.ts: 7 console → logger - deposit-matcher.ts: 4 console → logger - n8n-service.ts: 3 console.error → logger - circuit-breaker.ts: 8 console → logger - retry.ts: 3 console → logger Benefits: - Single point of auth change - Structured logging with context - Better observability in production Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
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<MatchResult | null> {
|
|
// 매칭 조건: 입금자명(앞 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;
|
|
}
|
|
}
|