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 }); return Response.json({ error: 'User not found' }, { status: 404 });
} }
// 현재 잔액 확인 // 현재 잔액과 version 확인 (Optimistic Locking)
const deposit = await env.DB.prepare( const current = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?' 'SELECT balance, version FROM user_deposits WHERE user_id = ?'
).bind(user.id).first<{ balance: number }>(); ).bind(user.id).first<{ balance: number; version: number }>();
const currentBalance = deposit?.balance || 0; if (!current) {
if (currentBalance < body.amount) { return Response.json({ error: 'User deposit account not found' }, { status: 404 });
}
if (current.balance < body.amount) {
return Response.json({ return Response.json({
error: 'Insufficient balance', error: 'Insufficient balance',
current_balance: currentBalance, current_balance: current.balance,
required: body.amount, required: body.amount,
}, { status: 400 }); }, { status: 400 });
} }
// 트랜잭션: 잔액 차감 + 거래 기록 // Optimistic Locking: version 조건으로 잔액 차감
const results = await env.DB.batch([ const balanceUpdate = await env.DB.prepare(
env.DB.prepare( 'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
'UPDATE user_deposits SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' ).bind(body.amount, user.id, current.version).run();
).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),
]);
// Batch 결과 검증 if (!balanceUpdate.success || balanceUpdate.meta.changes === 0) {
const allSuccessful = results.every(r => r.success && r.meta?.changes && r.meta.changes > 0); logger.warn('Optimistic locking conflict (외부 API 잔액 차감)', {
if (!allSuccessful) { userId: user.id,
logger.error('Batch 부분 실패 (외부 API 잔액 차감)', undefined, { telegram_id: body.telegram_id,
results, 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, userId: user.id,
telegram_id: body.telegram_id, telegram_id: body.telegram_id,
amount: body.amount, amount: body.amount,
@@ -202,7 +216,7 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr
}, { status: 500 }); }, { 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}`); 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, success: true,
telegram_id: body.telegram_id, telegram_id: body.telegram_id,
deducted: body.amount, deducted: body.amount,
previous_balance: currentBalance, previous_balance: current.balance,
new_balance: newBalance, new_balance: newBalance,
}); });
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,39 @@
// Weather Tool - wttr.in integration // Weather Tool - wttr.in integration
import type { Env } from '../types'; import type { Env } from '../types';
// wttr.in API 응답 타입 정의
interface WttrCurrentCondition {
temp_C: string;
FeelsLikeC: string;
weatherDesc: Array<{ value: string }>;
humidity: string;
windspeedKmph: string;
winddir16Point: string;
uvIndex: string;
visibility: string;
}
interface WttrWeatherDay {
date: string;
maxtempC: string;
mintempC: string;
hourly: Array<{
time: string;
tempC: string;
weatherDesc: Array<{ value: string }>;
chanceofrain: string;
}>;
}
interface WttrResponse {
current_condition: WttrCurrentCondition[];
weather: WttrWeatherDay[];
nearest_area: Array<{
areaName: Array<{ value: string }>;
country: Array<{ value: string }>;
}>;
}
export const weatherTool = { export const weatherTool = {
type: 'function', type: 'function',
function: { function: {
@@ -26,8 +59,25 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise
const response = await fetch( const response = await fetch(
`${wttrUrl}/${encodeURIComponent(city)}?format=j1` `${wttrUrl}/${encodeURIComponent(city)}?format=j1`
); );
const data = await response.json() as any;
if (!response.ok) {
throw new Error(`API 응답 실패: ${response.status}`);
}
const data = await response.json() as WttrResponse;
// 안전한 접근 - 데이터 유효성 확인
if (!data.current_condition?.[0]) {
return `날씨 정보를 가져올 수 없습니다: ${city}`;
}
const current = data.current_condition[0]; const current = data.current_condition[0];
// weatherDesc 배열 존재 확인
if (!current.weatherDesc?.[0]?.value) {
return `날씨 정보가 불완전합니다: ${city}`;
}
return `🌤 ${city} 날씨 return `🌤 ${city} 날씨
온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C) 온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C)
상태: ${current.weatherDesc[0].value} 상태: ${current.weatherDesc[0].value}