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:
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