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