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:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user