From 1708d78526ef6d0b4010b4df8c65efb7efd39e28 Mon Sep 17 00:00:00 2001 From: kappa Date: Mon, 19 Jan 2026 23:49:16 +0900 Subject: [PATCH] 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 --- src/routes/api.ts | 62 ++++++++++++++++++++++++--------------- src/tools/weather-tool.ts | 52 +++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 25 deletions(-) diff --git a/src/routes/api.ts b/src/routes/api.ts index 2fc8fe2..7bc5162 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -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) { diff --git a/src/tools/weather-tool.ts b/src/tools/weather-tool.ts index b0151a6..85abc2f 100644 --- a/src/tools/weather-tool.ts +++ b/src/tools/weather-tool.ts @@ -1,6 +1,39 @@ // Weather Tool - wttr.in integration 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 = { type: 'function', function: { @@ -26,8 +59,25 @@ export async function executeWeather(args: { city: string }, env?: Env): Promise const response = await fetch( `${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]; + + // weatherDesc 배열 존재 확인 + if (!current.weatherDesc?.[0]?.value) { + return `날씨 정보가 불완전합니다: ${city}`; + } + return `🌤 ${city} 날씨 온도: ${current.temp_C}°C (체감 ${current.FeelsLikeC}°C) 상태: ${current.weatherDesc[0].value}