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:
kappa
2026-01-19 23:23:09 +09:00
parent 8d0fe30722
commit f5df0c0ffe
21 changed files with 13448 additions and 169 deletions

View 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
View 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;
}