feat: add optimistic locking and improve type safety
- Implement optimistic locking for deposit balance updates - Prevent race conditions in concurrent deposit requests - Add automatic retry with exponential backoff (max 3 attempts) - Add version column to user_deposits table - Improve type safety across codebase - Add explicit types for Namecheap API responses - Add typed function arguments (ManageDepositArgs, etc.) - Remove `any` types from deposit-agent and tool files - Add reconciliation job for balance integrity verification - Compare user_deposits.balance vs SUM(confirmed transactions) - Alert admin on discrepancy detection - Set up test environment with Vitest + Miniflare - Add 50+ test cases for deposit system - Add helper functions for test data creation - Update documentation - Add migration guide for version columns - Document optimistic locking patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
|
||||
import type { ManageDepositArgs, DepositFunctionResult } from './types';
|
||||
|
||||
const logger = createLogger('deposit-agent');
|
||||
|
||||
@@ -26,9 +28,9 @@ export interface DepositContext {
|
||||
// 예치금 API 함수 실행 (export for direct use without Agent)
|
||||
export async function executeDepositFunction(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
funcArgs: ManageDepositArgs,
|
||||
context: DepositContext
|
||||
): Promise<any> {
|
||||
): Promise<DepositFunctionResult> {
|
||||
const { userId, isAdmin, db } = context;
|
||||
|
||||
// 예치금 계정 조회 또는 생성
|
||||
@@ -79,52 +81,72 @@ export async function executeDepositFunction(
|
||||
).bind(depositor_name.slice(0, 7), amount).first<{ id: number; amount: number }>();
|
||||
|
||||
if (bankNotification) {
|
||||
// 은행 알림이 이미 있으면 바로 확정 처리
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at)
|
||||
VALUES (?, 'deposit', ?, 'confirmed', ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
|
||||
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
// 은행 알림이 이미 있으면 바로 확정 처리 (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 (?, 'deposit', ?, 'confirmed', ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
|
||||
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
|
||||
const txId = result.meta.last_row_id;
|
||||
const txId = result.meta.last_row_id;
|
||||
|
||||
// 잔액 증가 + 알림 매칭 업데이트
|
||||
const results = await db.batch([
|
||||
db.prepare(
|
||||
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
||||
).bind(amount, userId),
|
||||
db.prepare(
|
||||
'UPDATE bank_notifications SET matched_transaction_id = ? WHERE id = ?'
|
||||
).bind(txId, bankNotification.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 }>();
|
||||
|
||||
// Batch 결과 검증 (D1 batch는 트랜잭션이 아니므로 부분 실패 가능)
|
||||
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0);
|
||||
if (!allSuccessful) {
|
||||
logger.error('Batch 부분 실패 (입금 자동 매칭)', undefined, {
|
||||
results,
|
||||
userId,
|
||||
amount,
|
||||
depositor_name,
|
||||
txId,
|
||||
context: 'request_deposit_auto_match'
|
||||
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;
|
||||
});
|
||||
throw new Error('거래 처리 실패 - 관리자에게 문의하세요');
|
||||
|
||||
// 업데이트된 잔액 조회
|
||||
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 });
|
||||
throw new Error('처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 업데이트된 잔액 조회
|
||||
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: '은행 알림과 자동 매칭되어 즉시 충전되었습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// 은행 알림이 없으면 pending 거래 생성
|
||||
@@ -277,35 +299,63 @@ export async function executeDepositFunction(
|
||||
return { error: '대기 중인 거래만 확인할 수 있습니다.' };
|
||||
}
|
||||
|
||||
// 트랜잭션: 상태 변경 + 잔액 증가
|
||||
const results = await db.batch([
|
||||
db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?"
|
||||
).bind(transaction_id),
|
||||
db.prepare(
|
||||
'UPDATE user_deposits SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
|
||||
).bind(tx.amount, tx.user_id),
|
||||
]);
|
||||
// 트랜잭션: 상태 변경 + 잔액 증가 (Optimistic Locking 적용)
|
||||
try {
|
||||
await executeWithOptimisticLock(db, async (attempt) => {
|
||||
// 1. Update transaction status
|
||||
const txUpdate = await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'"
|
||||
).bind(transaction_id).run();
|
||||
|
||||
// Batch 결과 검증
|
||||
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0);
|
||||
if (!allSuccessful) {
|
||||
logger.error('Batch 부분 실패 (관리자 입금 확인)', undefined, {
|
||||
results,
|
||||
userId: tx.user_id,
|
||||
transaction_id,
|
||||
amount: tx.amount,
|
||||
context: 'confirm_deposit'
|
||||
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;
|
||||
});
|
||||
throw new Error('거래 처리 실패 - 관리자에게 문의하세요');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transaction_id: transaction_id,
|
||||
amount: tx.amount,
|
||||
message: '입금이 확인되었습니다.',
|
||||
};
|
||||
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,
|
||||
});
|
||||
throw new Error('처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case 'reject_deposit': {
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -5,6 +5,7 @@ import { handleApiRequest } from './routes/api';
|
||||
import { handleHealthCheck } from './routes/health';
|
||||
import { parseBankSMS } from './services/bank-sms-parser';
|
||||
import { matchPendingDeposit } from './services/deposit-matcher';
|
||||
import { reconcileDeposits, formatReconciliationReport } from './utils/reconciliation';
|
||||
|
||||
export default {
|
||||
// HTTP 요청 핸들러
|
||||
@@ -229,5 +230,29 @@ Documentation: https://github.com/your-repo
|
||||
} catch (error) {
|
||||
console.error('[Cron] 오류:', error);
|
||||
}
|
||||
|
||||
// 예치금 정합성 검증 (Reconciliation)
|
||||
console.log('[Cron] 예치금 정합성 검증 시작');
|
||||
try {
|
||||
const report = await reconcileDeposits(env.DB);
|
||||
|
||||
if (report.inconsistencies > 0) {
|
||||
// 관리자 알림 전송
|
||||
const adminId = env.DEPOSIT_ADMIN_ID;
|
||||
if (adminId) {
|
||||
const message = formatReconciliationReport(report);
|
||||
await sendMessage(env.BOT_TOKEN, parseInt(adminId), message).catch(err => {
|
||||
console.error('[Cron] 정합성 검증 알림 전송 실패:', err);
|
||||
});
|
||||
} else {
|
||||
console.warn('[Cron] DEPOSIT_ADMIN_ID 미설정 - 알림 전송 불가');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Cron] 정합성 검증 완료: ${report.totalUsers}명 검증, ${report.inconsistencies}건 불일치`);
|
||||
} catch (error) {
|
||||
console.error('[Cron] 정합성 검증 실패:', error);
|
||||
// 정합성 검증 실패가 전체 Cron을 중단시키지 않도록 에러를 catch만 하고 계속 진행
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env } from '../types';
|
||||
import type { Env, KeyboardData } from '../types';
|
||||
import {
|
||||
addToBuffer,
|
||||
processAndSummarize,
|
||||
@@ -9,7 +9,7 @@ import { sendChatAction } from '../telegram';
|
||||
export interface ConversationResult {
|
||||
responseText: string;
|
||||
isProfileUpdated: boolean;
|
||||
keyboardData?: any;
|
||||
keyboardData?: KeyboardData | null;
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
@@ -53,13 +53,13 @@ export class ConversationService {
|
||||
);
|
||||
|
||||
// 키보드 데이터 파싱
|
||||
let keyboardData: any = null;
|
||||
let keyboardData: KeyboardData | null = null;
|
||||
const keyboardMatch = responseText.match(/__KEYBOARD__(.+?)__END__\n?/);
|
||||
|
||||
|
||||
if (keyboardMatch) {
|
||||
responseText = responseText.replace(/__KEYBOARD__.+?__END__\n?/, '');
|
||||
try {
|
||||
keyboardData = JSON.parse(keyboardMatch[1]);
|
||||
keyboardData = JSON.parse(keyboardMatch[1]) as KeyboardData;
|
||||
} catch (e) {
|
||||
console.error('[ConversationService] Keyboard parsing error:', e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { executeDepositFunction, type DepositContext } from '../deposit-agent';
|
||||
import type { Env } from '../types';
|
||||
import type {
|
||||
Env,
|
||||
DepositFunctionResult,
|
||||
DepositTransaction,
|
||||
DepositPendingItem,
|
||||
ManageDepositArgs
|
||||
} from '../types';
|
||||
import { createLogger, maskUserId } from '../utils/logger';
|
||||
|
||||
const logger = createLogger('deposit-tool');
|
||||
@@ -40,35 +46,42 @@ export const manageDepositTool = {
|
||||
};
|
||||
|
||||
// 예치금 결과 포맷팅 (고정 형식)
|
||||
function formatDepositResult(action: string, result: any): string {
|
||||
if (result.error) {
|
||||
function formatDepositResult(action: string, result: DepositFunctionResult): string {
|
||||
if ('error' in result) {
|
||||
return `🚫 ${result.error}`;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'balance':
|
||||
return `💰 현재 잔액: ${result.formatted}`;
|
||||
if ('formatted' in result) {
|
||||
return `💰 현재 잔액: ${result.formatted}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'account':
|
||||
return `💳 입금 계좌 안내
|
||||
if ('bank' in result && 'account' in result && 'holder' in result && 'instruction' in result) {
|
||||
return `💳 입금 계좌 안내
|
||||
|
||||
• 은행: ${result.bank}
|
||||
• 계좌번호: ${result.account}
|
||||
• 예금주: ${result.holder}
|
||||
|
||||
📌 ${result.instruction}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'request':
|
||||
if (result.auto_matched) {
|
||||
return `✅ 입금 확인 완료!
|
||||
if ('auto_matched' in result && 'amount' in result && 'depositor_name' in result) {
|
||||
if (result.auto_matched && 'new_balance' in result && result.new_balance !== undefined) {
|
||||
return `✅ 입금 확인 완료!
|
||||
|
||||
• 입금액: ${result.amount.toLocaleString()}원
|
||||
• 입금자: ${result.depositor_name}
|
||||
• 현재 잔액: ${result.new_balance.toLocaleString()}원
|
||||
|
||||
${result.message}`;
|
||||
} else {
|
||||
return `📋 입금 요청 등록 (#${result.transaction_id})
|
||||
} else if ('account_info' in result && result.account_info) {
|
||||
return `📋 입금 요청 등록 (#${result.transaction_id})
|
||||
|
||||
• 입금액: ${result.amount.toLocaleString()}원
|
||||
• 입금자: ${result.depositor_name}
|
||||
@@ -78,45 +91,61 @@ ${result.account_info.bank} ${result.account_info.account}
|
||||
(${result.account_info.holder})
|
||||
|
||||
📌 ${result.message}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'history': {
|
||||
if (result.message && !result.transactions?.length) {
|
||||
return `📋 ${result.message}`;
|
||||
if ('transactions' in result) {
|
||||
if (result.message && !result.transactions?.length) {
|
||||
return `📋 ${result.message}`;
|
||||
}
|
||||
const statusIcon = (s: string) => s === 'confirmed' ? '✓' : s === 'pending' ? '⏳' : '✗';
|
||||
const typeLabel = (t: string) => t === 'deposit' ? '입금' : t === 'withdrawal' ? '출금' : t === 'refund' ? '환불' : t;
|
||||
const txList = result.transactions.map((tx: DepositTransaction) => {
|
||||
const date = tx.confirmed_at || tx.created_at;
|
||||
const dateStr = date ? new Date(date).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }) : '';
|
||||
const desc = tx.description ? ` - ${tx.description}` : '';
|
||||
return `#${tx.id}: ${typeLabel(tx.type)} ${tx.amount.toLocaleString()}원 ${statusIcon(tx.status)} (${dateStr})${desc}`;
|
||||
}).join('\n');
|
||||
return `📋 거래 내역\n\n${txList}`;
|
||||
}
|
||||
const statusIcon = (s: string) => s === 'confirmed' ? '✓' : s === 'pending' ? '⏳' : '✗';
|
||||
const typeLabel = (t: string) => t === 'deposit' ? '입금' : t === 'withdrawal' ? '출금' : t === 'refund' ? '환불' : t;
|
||||
const txList = result.transactions.map((tx: any) => {
|
||||
const date = tx.confirmed_at || tx.created_at;
|
||||
const dateStr = date ? new Date(date).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }) : '';
|
||||
const desc = tx.description ? ` - ${tx.description}` : '';
|
||||
return `#${tx.id}: ${typeLabel(tx.type)} ${tx.amount.toLocaleString()}원 ${statusIcon(tx.status)} (${dateStr})${desc}`;
|
||||
}).join('\n');
|
||||
return `📋 거래 내역\n\n${txList}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel':
|
||||
return `✅ 거래 #${result.transaction_id} 취소 완료`;
|
||||
if ('transaction_id' in result && 'success' in result) {
|
||||
return `✅ 거래 #${result.transaction_id} 취소 완료`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pending': {
|
||||
if (result.message && !result.pending?.length) {
|
||||
return `📋 ${result.message}`;
|
||||
if ('pending' in result) {
|
||||
if (result.message && !result.pending?.length) {
|
||||
return `📋 ${result.message}`;
|
||||
}
|
||||
const pendingList = result.pending.map((p: DepositPendingItem) =>
|
||||
`#${p.id}: ${p.depositor_name} ${p.amount.toLocaleString()}원 (${p.user})`
|
||||
).join('\n');
|
||||
return `📋 대기 중인 입금 요청\n\n${pendingList}`;
|
||||
}
|
||||
const pendingList = result.pending.map((p: any) =>
|
||||
`#${p.id}: ${p.depositor_name} ${p.amount.toLocaleString()}원 (${p.user})`
|
||||
).join('\n');
|
||||
return `📋 대기 중인 입금 요청\n\n${pendingList}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'confirm':
|
||||
return `✅ 입금 확인 완료 (#${result.transaction_id}, ${result.amount.toLocaleString()}원)`;
|
||||
if ('transaction_id' in result && 'amount' in result) {
|
||||
return `✅ 입금 확인 완료 (#${result.transaction_id}, ${result.amount.toLocaleString()}원)`;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'reject':
|
||||
return `❌ 입금 거절 완료 (#${result.transaction_id})`;
|
||||
|
||||
default:
|
||||
return `💰 ${JSON.stringify(result)}`;
|
||||
if ('transaction_id' in result) {
|
||||
return `❌ 입금 거절 완료 (#${result.transaction_id})`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return `💰 ${JSON.stringify(result)}`;
|
||||
}
|
||||
|
||||
export async function executeManageDeposit(
|
||||
@@ -167,7 +196,9 @@ export async function executeManageDeposit(
|
||||
}
|
||||
|
||||
try {
|
||||
const funcArgs: Record<string, any> = {};
|
||||
const funcArgs: ManageDepositArgs = {
|
||||
action: action as ManageDepositArgs['action']
|
||||
};
|
||||
if (depositor_name) funcArgs.depositor_name = depositor_name;
|
||||
if (amount) funcArgs.amount = Number(amount);
|
||||
if (transaction_id) funcArgs.transaction_id = Number(transaction_id);
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
import type { Env } from '../types';
|
||||
import type {
|
||||
Env,
|
||||
NamecheapPriceResponse,
|
||||
NamecheapDomainListItem,
|
||||
NamecheapCheckResult,
|
||||
OpenAIResponse
|
||||
} from '../types';
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger, maskUserId } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
|
||||
const logger = createLogger('domain-tool');
|
||||
|
||||
// Helper to safely get string value from Record<string, unknown>
|
||||
function getStringValue(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
// Helper to safely get number value from Record<string, unknown>
|
||||
function getNumberValue(obj: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = obj[key];
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
// Helper to safely get array value from Record<string, unknown>
|
||||
function getArrayValue<T>(obj: Record<string, unknown>, key: string): T[] | undefined {
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : undefined;
|
||||
}
|
||||
|
||||
// Type guard to check if result is an error
|
||||
function isErrorResult(result: unknown): result is { error: string } {
|
||||
return typeof result === 'object' && result !== null && 'error' in result;
|
||||
}
|
||||
|
||||
// Type guard to check if result is NamecheapPriceResponse
|
||||
function isNamecheapPriceResponse(result: unknown): result is NamecheapPriceResponse {
|
||||
return typeof result === 'object' && result !== null && 'krw' in result;
|
||||
}
|
||||
|
||||
// KV 캐싱 인터페이스
|
||||
interface CachedTLDPrice {
|
||||
tld: string;
|
||||
@@ -37,7 +71,7 @@ async function getCachedTLDPrice(
|
||||
async function setCachedTLDPrice(
|
||||
kv: KVNamespace,
|
||||
tld: string,
|
||||
price: any
|
||||
price: NamecheapPriceResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = `tld_price:${tld}`;
|
||||
@@ -59,13 +93,13 @@ async function setCachedTLDPrice(
|
||||
// 전체 TLD 가격 캐시 조회
|
||||
async function getCachedAllPrices(
|
||||
kv: KVNamespace
|
||||
): Promise<any[] | null> {
|
||||
): Promise<NamecheapPriceResponse[] | null> {
|
||||
try {
|
||||
const key = 'tld_price:all';
|
||||
const cached = await kv.get(key, 'json');
|
||||
if (cached) {
|
||||
logger.info('TLDCache HIT: all prices');
|
||||
return cached as any[];
|
||||
return cached as NamecheapPriceResponse[];
|
||||
}
|
||||
logger.info('TLDCache MISS: all prices');
|
||||
return null;
|
||||
@@ -78,7 +112,7 @@ async function getCachedAllPrices(
|
||||
// 전체 TLD 가격 캐시 저장
|
||||
async function setCachedAllPrices(
|
||||
kv: KVNamespace,
|
||||
prices: any[]
|
||||
prices: NamecheapPriceResponse[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = 'tld_price:all';
|
||||
@@ -144,13 +178,13 @@ export const suggestDomainsTool = {
|
||||
// Namecheap API 호출 (allowedDomains로 필터링)
|
||||
async function callNamecheapApi(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
funcArgs: Record<string, unknown>,
|
||||
allowedDomains: string[],
|
||||
env?: Env,
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
): Promise<any> {
|
||||
): Promise<unknown> {
|
||||
if (!env?.NAMECHEAP_API_KEY_INTERNAL) {
|
||||
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
||||
}
|
||||
@@ -160,19 +194,22 @@ async function callNamecheapApi(
|
||||
// 도메인 권한 체크 (쓰기 작업만)
|
||||
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
|
||||
if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) {
|
||||
if (!allowedDomains.includes(funcArgs.domain)) {
|
||||
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
|
||||
const domain = funcArgs.domain;
|
||||
if (typeof domain === 'string' && !allowedDomains.includes(domain)) {
|
||||
return { error: `권한 없음: ${domain}은 관리할 수 없는 도메인입니다.` };
|
||||
}
|
||||
}
|
||||
|
||||
switch (funcName) {
|
||||
case 'list_domains': {
|
||||
const page = getNumberValue(funcArgs, 'page') || 1;
|
||||
const pageSize = getNumberValue(funcArgs, 'page_size') || 100;
|
||||
const result = await retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
|
||||
() => fetch(`${apiUrl}/domains?page=${page}&page_size=${pageSize}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
) as any[];
|
||||
) as NamecheapDomainListItem[];
|
||||
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
|
||||
const convertDate = (date: string) => {
|
||||
const [month, day, year] = date.split('/');
|
||||
@@ -180,8 +217,8 @@ async function callNamecheapApi(
|
||||
};
|
||||
// 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환
|
||||
return result
|
||||
.filter((d: any) => allowedDomains.includes(d.name))
|
||||
.map((d: any) => ({
|
||||
.filter((d: NamecheapDomainListItem) => allowedDomains.includes(d.name))
|
||||
.map((d: NamecheapDomainListItem) => ({
|
||||
...d,
|
||||
created: convertDate(d.created),
|
||||
expires: convertDate(d.expires),
|
||||
@@ -189,14 +226,15 @@ async function callNamecheapApi(
|
||||
}));
|
||||
}
|
||||
case 'get_domain_info': {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
// 목록 API에서 더 많은 정보 조회 (단일 API는 정보 부족)
|
||||
const domains = await retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains?page=1&page_size=100`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
) as any[];
|
||||
const domainInfo = domains.find((d: any) => d.name === funcArgs.domain);
|
||||
) as NamecheapDomainListItem[];
|
||||
const domainInfo = domains.find((d: NamecheapDomainListItem) => d.name === domain);
|
||||
if (!domainInfo) {
|
||||
return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` };
|
||||
}
|
||||
@@ -216,18 +254,22 @@ async function callNamecheapApi(
|
||||
whois_guard: domainInfo.whois_guard,
|
||||
};
|
||||
}
|
||||
case 'get_nameservers':
|
||||
case 'get_nameservers': {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||||
() => fetch(`${apiUrl}/dns/${domain}/nameservers`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
}
|
||||
case 'set_nameservers': {
|
||||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
const nameservers = getArrayValue<string>(funcArgs, 'nameservers');
|
||||
const res = await fetch(`${apiUrl}/dns/${domain}/nameservers`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: funcArgs.domain, nameservers: funcArgs.nameservers }),
|
||||
body: JSON.stringify({ domain, nameservers }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
@@ -251,7 +293,7 @@ async function callNamecheapApi(
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nameserver: funcArgs.nameserver, ip: funcArgs.ip }),
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 생성 실패` };
|
||||
}
|
||||
@@ -261,7 +303,7 @@ async function callNamecheapApi(
|
||||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 조회 실패` };
|
||||
}
|
||||
@@ -272,7 +314,7 @@ async function callNamecheapApi(
|
||||
method: 'DELETE',
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 삭제 실패` };
|
||||
}
|
||||
@@ -286,7 +328,8 @@ async function callNamecheapApi(
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
case 'get_price': {
|
||||
const tld = funcArgs.tld?.replace(/^\./, ''); // .com → com
|
||||
const tldRaw = getStringValue(funcArgs, 'tld');
|
||||
const tld = tldRaw?.replace(/^\./, ''); // .com → com
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/prices/${tld}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
@@ -303,12 +346,13 @@ async function callNamecheapApi(
|
||||
);
|
||||
}
|
||||
case 'check_domains': {
|
||||
const domains = getArrayValue<string>(funcArgs, 'domains');
|
||||
// POST but idempotent (read-only check)
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: funcArgs.domains }),
|
||||
body: JSON.stringify({ domains }),
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
@@ -324,7 +368,18 @@ async function callNamecheapApi(
|
||||
if (!whoisRes.ok) {
|
||||
return { error: `WHOIS 조회 실패: HTTP ${whoisRes.status}` };
|
||||
}
|
||||
const whois = await whoisRes.json() as any;
|
||||
const whois = await whoisRes.json() as {
|
||||
error?: string;
|
||||
whois_supported?: boolean;
|
||||
ccSLD?: string;
|
||||
message_ko?: string;
|
||||
suggestion_ko?: string;
|
||||
domain?: string;
|
||||
available?: boolean;
|
||||
whois_server?: string;
|
||||
raw?: string;
|
||||
query_time_ms?: number;
|
||||
};
|
||||
|
||||
if (whois.error) {
|
||||
return { error: `WHOIS 조회 오류: ${whois.error}` };
|
||||
@@ -370,7 +425,7 @@ async function callNamecheapApi(
|
||||
telegram_id: telegramUserId,
|
||||
}),
|
||||
});
|
||||
const result = await res.json() as any;
|
||||
const result = await res.json() as { registered?: boolean; detail?: string; warning?: string };
|
||||
if (!res.ok) {
|
||||
return { error: result.detail || '도메인 등록 실패' };
|
||||
}
|
||||
@@ -409,10 +464,13 @@ async function executeDomainAction(
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (!result.length) return '📋 등록된 도메인이 없습니다.';
|
||||
const list = result.map((d: any) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||
return `📋 내 도메인 목록 (${result.length}개)\n\n${list}`;
|
||||
if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
return `🚫 ${(result as { error: string }).error}`;
|
||||
}
|
||||
const domains = result as NamecheapDomainListItem[];
|
||||
if (!domains.length) return '📋 등록된 도메인이 없습니다.';
|
||||
const list = domains.map((d: NamecheapDomainListItem) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||
return `📋 내 도메인 목록 (${domains.length}개)\n\n${list}`;
|
||||
}
|
||||
|
||||
case 'info': {
|
||||
@@ -455,8 +513,9 @@ async function executeDomainAction(
|
||||
case 'check': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
const available = result[domain];
|
||||
if (isErrorResult(result)) return `🚫 ${result.error}`;
|
||||
const checkResult = result as NamecheapCheckResult;
|
||||
const available = checkResult[domain];
|
||||
if (available) {
|
||||
// 가격도 함께 조회
|
||||
const domainTld = domain.split('.').pop() || '';
|
||||
@@ -473,11 +532,13 @@ async function executeDomainAction(
|
||||
// 캐시 미스 시 API 호출
|
||||
if (!price) {
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||||
price = priceResult.krw || priceResult.register_krw;
|
||||
if (isNamecheapPriceResponse(priceResult)) {
|
||||
price = priceResult.krw || priceResult.register_krw;
|
||||
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult);
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,15 +550,23 @@ async function executeDomainAction(
|
||||
case 'whois': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (isErrorResult(result)) return `🚫 ${result.error}`;
|
||||
|
||||
const whoisResult = result as {
|
||||
whois_supported?: boolean;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
raw?: string;
|
||||
available?: boolean;
|
||||
};
|
||||
|
||||
// ccSLD WHOIS 미지원
|
||||
if (result.whois_supported === false) {
|
||||
return `🔍 ${domain} WHOIS\n\n⚠️ ${result.message}\n💡 ${result.suggestion}`;
|
||||
if (whoisResult.whois_supported === false) {
|
||||
return `🔍 ${domain} WHOIS\n\n⚠️ ${whoisResult.message}\n💡 ${whoisResult.suggestion}`;
|
||||
}
|
||||
|
||||
// raw WHOIS 데이터에서 주요 정보 추출
|
||||
const raw = result.raw || '';
|
||||
const raw = whoisResult.raw || '';
|
||||
const extractField = (patterns: RegExp[]): string => {
|
||||
for (const pattern of patterns) {
|
||||
const match = raw.match(pattern);
|
||||
@@ -606,11 +675,11 @@ async function executeDomainAction(
|
||||
const cached = await getCachedAllPrices(env.RATE_LIMIT_KV);
|
||||
if (cached) {
|
||||
const sorted = cached
|
||||
.filter((p: any) => p.krw > 0)
|
||||
.sort((a: any, b: any) => a.krw - b.krw)
|
||||
.filter((p: NamecheapPriceResponse) => p.krw > 0)
|
||||
.sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw)
|
||||
.slice(0, 15);
|
||||
const list = sorted
|
||||
.map((p: any, idx: number) => `${idx + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`)
|
||||
.map((p: NamecheapPriceResponse, idx: number) => `${idx + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`)
|
||||
.join('\n');
|
||||
return `💰 가장 저렴한 TLD TOP 15\n\n${list}\n\n📌 캐시된 정보\n💡 특정 TLD 가격은 ".com 가격" 형식으로 조회`;
|
||||
}
|
||||
@@ -618,24 +687,26 @@ async function executeDomainAction(
|
||||
|
||||
// API 호출
|
||||
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
return `🚫 ${(result as { error: string }).error}`;
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV && Array.isArray(result)) {
|
||||
await setCachedAllPrices(env.RATE_LIMIT_KV, result);
|
||||
await setCachedAllPrices(env.RATE_LIMIT_KV, result as NamecheapPriceResponse[]);
|
||||
}
|
||||
|
||||
// 가격 > 0인 TLD만 필터링, krw 기준 정렬
|
||||
const sorted = (result as any[])
|
||||
.filter((p: any) => p.krw > 0)
|
||||
.sort((a: any, b: any) => a.krw - b.krw)
|
||||
const sorted = (result as NamecheapPriceResponse[])
|
||||
.filter((p: NamecheapPriceResponse) => p.krw > 0)
|
||||
.sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw)
|
||||
.slice(0, 15);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return '🚫 TLD 가격 정보를 가져올 수 없습니다.';
|
||||
}
|
||||
|
||||
const list = sorted.map((p: any, i: number) =>
|
||||
const list = sorted.map((p: NamecheapPriceResponse, i: number) =>
|
||||
`${i + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`
|
||||
).join('\n');
|
||||
|
||||
@@ -832,7 +903,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
return '🚫 도메인 아이디어 생성 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
const ideaData = await ideaResponse.json() as any;
|
||||
const ideaData = await ideaResponse.json() as OpenAIResponse;
|
||||
const ideaContent = ideaData.choices?.[0]?.message?.content || '[]';
|
||||
|
||||
let domains: string[];
|
||||
@@ -865,7 +936,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
|
||||
if (!checkResponse.ok) continue;
|
||||
|
||||
const checkRaw = await checkResponse.json() as Record<string, boolean>;
|
||||
const checkRaw = await checkResponse.json() as NamecheapCheckResult;
|
||||
|
||||
// 등록 가능한 도메인만 추가
|
||||
for (const [domain, isAvailable] of Object.entries(checkRaw)) {
|
||||
@@ -910,7 +981,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
return { tld, price: null, error: `HTTP ${priceRes.status}` };
|
||||
}
|
||||
|
||||
const priceData = await priceRes.json() as { krw?: number };
|
||||
const priceData = await priceRes.json() as NamecheapPriceResponse;
|
||||
const price = priceData.krw || 0;
|
||||
|
||||
// 캐시 저장
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Env } from '../types';
|
||||
import type {
|
||||
Env,
|
||||
OpenAIResponse,
|
||||
BraveSearchResponse,
|
||||
BraveSearchResult,
|
||||
Context7SearchResponse,
|
||||
Context7DocsResponse
|
||||
} from '../types';
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
@@ -85,7 +92,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
{ maxRetries: 2 } // 번역은 중요하지 않으므로 재시도 2회로 제한
|
||||
);
|
||||
if (translateRes.ok) {
|
||||
const translateData = await translateRes.json() as any;
|
||||
const translateData = await translateRes.json() as OpenAIResponse;
|
||||
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
|
||||
logger.info('번역', { original: query, translated: translatedQuery });
|
||||
}
|
||||
@@ -112,7 +119,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
if (!response.ok) {
|
||||
return `🔍 검색 오류: ${response.status}`;
|
||||
}
|
||||
const data = await response.json() as any;
|
||||
const data = await response.json() as BraveSearchResponse;
|
||||
|
||||
// Web 검색 결과 파싱
|
||||
const webResults = data.web?.results || [];
|
||||
@@ -120,7 +127,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
return `🔍 "${query}"에 대한 검색 결과가 없습니다.`;
|
||||
}
|
||||
|
||||
const results = webResults.slice(0, 3).map((r: any, i: number) =>
|
||||
const results = webResults.slice(0, 3).map((r: BraveSearchResult, i: number) =>
|
||||
`${i + 1}. <b>${r.title}</b>\n ${r.description}\n ${r.url}`
|
||||
).join('\n\n');
|
||||
|
||||
@@ -149,7 +156,7 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
() => fetch(searchUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const searchData = await searchResponse.json() as any;
|
||||
const searchData = await searchResponse.json() as Context7SearchResponse;
|
||||
|
||||
if (!searchData.libraries?.length) {
|
||||
return `📚 "${library}" 라이브러리를 찾을 수 없습니다.`;
|
||||
@@ -163,7 +170,7 @@ export async function executeLookupDocs(args: { library: string; query: string }
|
||||
() => fetch(docsUrl),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
const docsData = await docsResponse.json() as any;
|
||||
const docsData = await docsResponse.json() as Context7DocsResponse;
|
||||
|
||||
if (docsData.error) {
|
||||
return `📚 문서 조회 실패: ${docsData.message || docsData.error}`;
|
||||
|
||||
209
src/types.ts
209
src/types.ts
@@ -111,3 +111,212 @@ export interface BankNotification {
|
||||
transactionTime?: Date;
|
||||
rawMessage: string;
|
||||
}
|
||||
|
||||
// Namecheap API 응답 타입
|
||||
export interface NamecheapPriceResponse {
|
||||
tld: string;
|
||||
krw: number;
|
||||
usd?: number;
|
||||
register_krw?: number;
|
||||
renew_krw?: number;
|
||||
transfer_krw?: number;
|
||||
}
|
||||
|
||||
export interface NamecheapDomainInfo {
|
||||
name: string;
|
||||
created: string;
|
||||
expires: string;
|
||||
is_expired: boolean;
|
||||
auto_renew: boolean;
|
||||
is_locked: boolean;
|
||||
whois_guard: boolean;
|
||||
nameservers?: string[];
|
||||
}
|
||||
|
||||
export interface NamecheapCheckResult {
|
||||
[domain: string]: boolean;
|
||||
}
|
||||
|
||||
export interface NamecheapDomainListItem {
|
||||
name: string;
|
||||
created: string;
|
||||
expires: string;
|
||||
is_expired: boolean;
|
||||
auto_renew: boolean;
|
||||
is_locked: boolean;
|
||||
whois_guard: boolean;
|
||||
}
|
||||
|
||||
// Function Calling 인자 타입
|
||||
export interface ManageDomainArgs {
|
||||
action: 'register' | 'check' | 'whois' | 'list' | 'info' | 'get_ns' | 'set_ns' | 'price' | 'cheapest';
|
||||
domain?: string;
|
||||
nameservers?: string[];
|
||||
tld?: string;
|
||||
}
|
||||
|
||||
export interface ManageDepositArgs {
|
||||
action: 'balance' | 'account' | 'request' | 'history' | 'cancel' | 'pending' | 'confirm' | 'reject';
|
||||
depositor_name?: string;
|
||||
amount?: number;
|
||||
transaction_id?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SuggestDomainsArgs {
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
export interface SearchWebArgs {
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface LookupDocsArgs {
|
||||
library: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
// Deposit Agent 결과 타입
|
||||
export interface DepositBalanceResult {
|
||||
balance: number;
|
||||
formatted: string;
|
||||
}
|
||||
|
||||
export interface DepositAccountInfoResult {
|
||||
bank: string;
|
||||
account: string;
|
||||
holder: string;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface DepositRequestResult {
|
||||
success: boolean;
|
||||
auto_matched: boolean;
|
||||
transaction_id: number;
|
||||
amount: number;
|
||||
depositor_name: string;
|
||||
status?: string;
|
||||
new_balance?: number;
|
||||
message: string;
|
||||
account_info?: {
|
||||
bank: string;
|
||||
account: string;
|
||||
holder: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DepositTransaction {
|
||||
id: number;
|
||||
type: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
depositor_name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
confirmed_at: string | null;
|
||||
}
|
||||
|
||||
export interface DepositTransactionsResult {
|
||||
transactions: DepositTransaction[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DepositCancelResult {
|
||||
success: boolean;
|
||||
transaction_id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DepositPendingItem {
|
||||
id: number;
|
||||
amount: number;
|
||||
depositor_name: string;
|
||||
created_at: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface DepositPendingResult {
|
||||
pending: DepositPendingItem[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DepositConfirmResult {
|
||||
success: boolean;
|
||||
transaction_id: number;
|
||||
amount: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DepositRejectResult {
|
||||
success: boolean;
|
||||
transaction_id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DepositErrorResult {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type DepositFunctionResult =
|
||||
| DepositBalanceResult
|
||||
| DepositAccountInfoResult
|
||||
| DepositRequestResult
|
||||
| DepositTransactionsResult
|
||||
| DepositCancelResult
|
||||
| DepositPendingResult
|
||||
| DepositConfirmResult
|
||||
| DepositRejectResult
|
||||
| DepositErrorResult;
|
||||
|
||||
// Brave Search API 응답 타입
|
||||
export interface BraveSearchResult {
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface BraveSearchResponse {
|
||||
web?: {
|
||||
results: BraveSearchResult[];
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI API 응답 타입
|
||||
export interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface OpenAIChoice {
|
||||
message: OpenAIMessage;
|
||||
}
|
||||
|
||||
export interface OpenAIResponse {
|
||||
choices?: OpenAIChoice[];
|
||||
}
|
||||
|
||||
// Context7 API 응답 타입
|
||||
export interface Context7Library {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Context7SearchResponse {
|
||||
libraries?: Context7Library[];
|
||||
}
|
||||
|
||||
export interface Context7DocsResponse {
|
||||
context?: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Telegram Inline Keyboard 데이터
|
||||
export interface DomainRegisterKeyboardData {
|
||||
type: 'domain_register';
|
||||
domain: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export type KeyboardData = DomainRegisterKeyboardData;
|
||||
|
||||
109
src/utils/optimistic-lock.ts
Normal file
109
src/utils/optimistic-lock.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Optimistic Locking Utility
|
||||
*
|
||||
* Purpose: Prevent data inconsistencies in financial operations where D1 batch()
|
||||
* is not a true transaction and partial failures can occur.
|
||||
*
|
||||
* Pattern:
|
||||
* 1. Read current version from user_deposits
|
||||
* 2. Perform operations
|
||||
* 3. UPDATE with version check (WHERE version = ?)
|
||||
* 4. If version mismatch (changes = 0), throw OptimisticLockError
|
||||
* 5. Retry with exponential backoff (max 3 attempts)
|
||||
*
|
||||
* Usage:
|
||||
* await executeWithOptimisticLock(db, async (attempt) => {
|
||||
* const current = await db.prepare('SELECT version FROM user_deposits WHERE user_id = ?')
|
||||
* .bind(userId).first<{ version: number }>();
|
||||
*
|
||||
* const result = await db.prepare(
|
||||
* 'UPDATE user_deposits SET balance = balance + ?, version = version + 1 WHERE user_id = ? AND version = ?'
|
||||
* ).bind(amount, userId, current.version).run();
|
||||
*
|
||||
* if (result.meta.changes === 0) {
|
||||
* throw new OptimisticLockError('Version mismatch');
|
||||
* }
|
||||
*
|
||||
* return result;
|
||||
* });
|
||||
*/
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('optimistic-lock');
|
||||
|
||||
/**
|
||||
* Custom error for optimistic lock failures
|
||||
*/
|
||||
export class OptimisticLockError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'OptimisticLockError';
|
||||
// Maintain proper stack trace for debugging
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, OptimisticLockError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with optimistic locking and automatic retry
|
||||
*
|
||||
* @param db - D1 Database instance
|
||||
* @param operation - Async operation to execute (receives attempt number)
|
||||
* @param maxRetries - Maximum retry attempts (default: 3)
|
||||
* @returns Promise resolving to operation result
|
||||
* @throws Error if all retries exhausted or non-OptimisticLockError occurs
|
||||
*/
|
||||
export async function executeWithOptimisticLock<T>(
|
||||
db: D1Database,
|
||||
operation: (attempt: number) => Promise<T>,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
logger.info(`Optimistic lock attempt ${attempt}/${maxRetries}`, { attempt });
|
||||
const result = await operation(attempt);
|
||||
|
||||
if (attempt > 1) {
|
||||
logger.info('Optimistic lock succeeded after retry', { attempt, retriesNeeded: attempt - 1 });
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (!(error instanceof OptimisticLockError)) {
|
||||
// Not a version conflict - propagate immediately
|
||||
logger.error('Non-optimistic-lock error in operation', error as Error, { attempt });
|
||||
throw error;
|
||||
}
|
||||
|
||||
lastError = error;
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
// Exponential backoff: 100ms, 200ms, 400ms
|
||||
const delayMs = 100 * Math.pow(2, attempt - 1);
|
||||
logger.warn('Optimistic lock conflict - retrying', {
|
||||
attempt,
|
||||
nextRetryIn: `${delayMs}ms`,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// Wait before retry
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
// Max retries exhausted
|
||||
logger.error('Optimistic lock failed - max retries exhausted', error, {
|
||||
maxRetries,
|
||||
finalAttempt: attempt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw new Error(
|
||||
`처리 중 동시성 충돌이 발생했습니다. 다시 시도해주세요. (${maxRetries}회 재시도 실패)`
|
||||
);
|
||||
}
|
||||
175
src/utils/reconciliation.ts
Normal file
175
src/utils/reconciliation.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Deposit Reconciliation Utility
|
||||
*
|
||||
* Purpose: Verify data integrity by comparing user_deposits.balance with
|
||||
* actual transaction history (SUM of confirmed deposits - withdrawals).
|
||||
*
|
||||
* Schedule: Daily via Cron (runs after expiry cleanup)
|
||||
*
|
||||
* Detection:
|
||||
* - Balance mismatch: user_deposits.balance != calculated balance
|
||||
* - Missing deposits: transactions with no balance update
|
||||
* - Orphaned balances: balance exists but no transactions
|
||||
*
|
||||
* Response:
|
||||
* - Log all discrepancies
|
||||
* - Send admin notification if issues found
|
||||
* - Return detailed report for monitoring
|
||||
*/
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
const logger = createLogger('reconciliation');
|
||||
|
||||
export interface ReconciliationReport {
|
||||
totalUsers: number;
|
||||
inconsistencies: number;
|
||||
details: InconsistencyDetail[];
|
||||
}
|
||||
|
||||
export interface InconsistencyDetail {
|
||||
userId: number;
|
||||
telegramId: string;
|
||||
username: string | null;
|
||||
storedBalance: number;
|
||||
calculatedBalance: number;
|
||||
difference: number;
|
||||
transactionCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile deposit balances with transaction history
|
||||
*
|
||||
* @param db - D1 Database instance
|
||||
* @returns Reconciliation report with discrepancies
|
||||
*/
|
||||
export async function reconcileDeposits(
|
||||
db: D1Database
|
||||
): Promise<ReconciliationReport> {
|
||||
logger.info('Starting deposit reconciliation');
|
||||
|
||||
try {
|
||||
// Query all users with deposits or transactions
|
||||
const query = `
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
u.telegram_id,
|
||||
u.username,
|
||||
COALESCE(ud.balance, 0) as stored_balance,
|
||||
COALESCE(
|
||||
SUM(CASE
|
||||
WHEN dt.type = 'deposit' AND dt.status = 'confirmed' THEN dt.amount
|
||||
WHEN dt.type IN ('withdrawal', 'refund') AND dt.status = 'confirmed' THEN -dt.amount
|
||||
ELSE 0
|
||||
END),
|
||||
0
|
||||
) as calculated_balance,
|
||||
COUNT(dt.id) as transaction_count
|
||||
FROM users u
|
||||
LEFT JOIN user_deposits ud ON u.id = ud.user_id
|
||||
LEFT JOIN deposit_transactions dt ON u.id = dt.user_id
|
||||
WHERE ud.id IS NOT NULL OR dt.id IS NOT NULL
|
||||
GROUP BY u.id, u.telegram_id, u.username, ud.balance
|
||||
HAVING stored_balance != calculated_balance
|
||||
`;
|
||||
|
||||
const result = await db.prepare(query).all<{
|
||||
user_id: number;
|
||||
telegram_id: string;
|
||||
username: string | null;
|
||||
stored_balance: number;
|
||||
calculated_balance: number;
|
||||
transaction_count: number;
|
||||
}>();
|
||||
|
||||
const inconsistencies: InconsistencyDetail[] = (result.results || []).map(row => ({
|
||||
userId: row.user_id,
|
||||
telegramId: row.telegram_id,
|
||||
username: row.username,
|
||||
storedBalance: row.stored_balance,
|
||||
calculatedBalance: row.calculated_balance,
|
||||
difference: row.stored_balance - row.calculated_balance,
|
||||
transactionCount: row.transaction_count,
|
||||
}));
|
||||
|
||||
// Get total users with deposits for context
|
||||
const totalUsersResult = await db.prepare(
|
||||
'SELECT COUNT(DISTINCT user_id) as count FROM user_deposits WHERE balance > 0'
|
||||
).first<{ count: number }>();
|
||||
|
||||
const totalUsers = totalUsersResult?.count || 0;
|
||||
|
||||
const report: ReconciliationReport = {
|
||||
totalUsers,
|
||||
inconsistencies: inconsistencies.length,
|
||||
details: inconsistencies,
|
||||
};
|
||||
|
||||
if (inconsistencies.length > 0) {
|
||||
logger.error('Reconciliation found inconsistencies', undefined, {
|
||||
totalUsers,
|
||||
inconsistencies: inconsistencies.length,
|
||||
totalDifference: inconsistencies.reduce((sum, d) => sum + Math.abs(d.difference), 0),
|
||||
});
|
||||
|
||||
// Log each inconsistency for investigation
|
||||
inconsistencies.forEach(detail => {
|
||||
logger.warn('Balance mismatch detected', {
|
||||
userId: detail.userId,
|
||||
telegramId: detail.telegramId,
|
||||
storedBalance: detail.storedBalance,
|
||||
calculatedBalance: detail.calculatedBalance,
|
||||
difference: detail.difference,
|
||||
transactionCount: detail.transactionCount,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
logger.info('Reconciliation completed - no inconsistencies found', { totalUsers });
|
||||
}
|
||||
|
||||
return report;
|
||||
} catch (error) {
|
||||
logger.error('Reconciliation failed', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reconciliation report for admin notification
|
||||
*
|
||||
* @param report - Reconciliation report
|
||||
* @returns Formatted message string
|
||||
*/
|
||||
export function formatReconciliationReport(report: ReconciliationReport): string {
|
||||
if (report.inconsistencies === 0) {
|
||||
return `✅ <b>예치금 정합성 검증 완료</b>\n\n` +
|
||||
`검증 대상: ${report.totalUsers}명\n` +
|
||||
`불일치: 없음`;
|
||||
}
|
||||
|
||||
let message = `⚠️ <b>예치금 불일치 발견</b>\n\n` +
|
||||
`검증 대상: ${report.totalUsers}명\n` +
|
||||
`불일치 건수: ${report.inconsistencies}건\n\n`;
|
||||
|
||||
// Show top 5 discrepancies
|
||||
const topIssues = report.details
|
||||
.sort((a, b) => Math.abs(b.difference) - Math.abs(a.difference))
|
||||
.slice(0, 5);
|
||||
|
||||
message += `<b>주요 불일치 내역:</b>\n`;
|
||||
topIssues.forEach((detail, idx) => {
|
||||
message += `\n${idx + 1}. 사용자 ${detail.username || detail.telegramId}\n` +
|
||||
` 저장된 잔액: ${detail.storedBalance.toLocaleString()}원\n` +
|
||||
` 실제 잔액: ${detail.calculatedBalance.toLocaleString()}원\n` +
|
||||
` 차이: ${detail.difference.toLocaleString()}원\n` +
|
||||
` 거래 수: ${detail.transactionCount}건`;
|
||||
});
|
||||
|
||||
if (report.inconsistencies > 5) {
|
||||
message += `\n\n... 외 ${report.inconsistencies - 5}건`;
|
||||
}
|
||||
|
||||
message += `\n\n로그를 확인하여 원인을 조사하세요.`;
|
||||
|
||||
return message;
|
||||
}
|
||||
Reference in New Issue
Block a user