fix: apply optimistic locking to deposit API and add weather types

Security (P1):
- Add optimistic locking to /api/deposit/deduct endpoint
- Prevent race conditions on concurrent balance deductions
- Return 409 Conflict on version mismatch with retry hint

Type Safety (P1):
- Add WttrResponse, WttrCurrentCondition, WttrWeatherDay types
- Remove `as any` from weather-tool.ts
- Add safety checks for malformed API responses

Both P1 issues from security review resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-19 23:49:16 +09:00
parent a2cb4ce686
commit 1708d78526
2 changed files with 89 additions and 25 deletions

View File

@@ -160,36 +160,50 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
return Response.json({ error: 'User not found' }, { status: 404 });
}
// 현재 잔액 확인
const deposit = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number }>();
// 현재 잔액과 version 확인 (Optimistic Locking)
const current = await env.DB.prepare(
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number; version: number }>();
const currentBalance = deposit?.balance || 0;
if (currentBalance < body.amount) {
if (!current) {
return Response.json({ error: 'User deposit account not found' }, { status: 404 });
}
if (current.balance < body.amount) {
return Response.json({
error: 'Insufficient balance',
current_balance: currentBalance,
current_balance: current.balance,
required: body.amount,
}, { status: 400 });
}
// 트랜잭션: 잔액 차감 + 거래 기록
const results = await env.DB.batch([
env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?'
).bind(body.amount, user.id),
env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(user.id, body.amount, body.reason),
]);
// Optimistic Locking: version 조건으로 잔액 차감
const balanceUpdate = await env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
).bind(body.amount, user.id, current.version).run();
// Batch 결과 검증
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0);
if (!allSuccessful) {
logger.error('Batch 부분 실패 (외부 API 잔액 차감)', undefined, {
results,
if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', {
userId: user.id,
telegram_id: body.telegram_id,
amount: body.amount,
expectedVersion: current.version,
context: 'api_deposit_deduct'
});
return Response.json({
error: 'Concurrent modification detected',
message: '동시 요청 감지 - 다시 시도해주세요'
}, { status: 409 });
}
// 거래 기록 INSERT
const transactionInsert = await env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(user.id, body.amount, body.reason).run();
if (!transactionInsert.success) {
logger.error('거래 기록 INSERT 실패 (외부 API)', undefined, {
userId: user.id,
telegram_id: body.telegram_id,
amount: body.amount,
@@ -202,7 +216,7 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
}, { status: 500 });
}
const newBalance = currentBalance - body.amount;
const newBalance = current.balance - body.amount;
console.log(`[API] Deposit deducted: user=${body.telegram_id}, amount=${body.amount}, reason=${body.reason}`);
@@ -210,7 +224,7 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
success: true,
telegram_id: body.telegram_id,
deducted: body.amount,
previous_balance: currentBalance,
previous_balance: current.balance,
new_balance: newBalance,
});
} catch (error) {