diff --git a/src/routes/api.ts b/src/routes/api.ts index 7bc5162..eeb035a 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -203,13 +203,28 @@ export async function handleApiRequest(request: Request, env: Env, url: URL): Pr ).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, - reason: body.reason, - context: 'api_deposit_deduct' - }); + // 잔액 복구 시도 (rollback) + try { + await env.DB.prepare( + 'UPDATE user_deposits SET balance = balance + ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?' + ).bind(body.amount, user.id).run(); + + logger.error('거래 기록 INSERT 실패 - 잔액 복구 완료', undefined, { + userId: user.id, + telegram_id: body.telegram_id, + amount: body.amount, + reason: body.reason, + context: 'api_deposit_deduct_rollback' + }); + } catch (rollbackError) { + logger.error('잔액 복구 실패 - 수동 확인 필요', rollbackError as Error, { + userId: user.id, + telegram_id: body.telegram_id, + amount: body.amount, + context: 'api_deposit_deduct_rollback_failed' + }); + } + return Response.json({ error: 'Transaction processing failed', message: '거래 처리 실패 - 관리자에게 문의하세요' diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index db45afe..14942ed 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -6,6 +6,21 @@ import { handleCommand } from '../commands'; import { UserService } from '../services/user-service'; import { ConversationService } from '../services/conversation-service'; +/** + * Safely parse integer with range validation + * @param value - String to parse + * @param min - Minimum allowed value (inclusive) + * @param max - Maximum allowed value (inclusive) + * @returns Parsed integer or null if invalid/out of range + */ +function parseIntSafe(value: string, min: number, max: number): number | null { + const parsed = parseInt(value, 10); + if (isNaN(parsed) || parsed < min || parsed > max) { + return null; + } + return parsed; +} + // 메시지 처리 핸들러 async function handleMessage( env: Env, @@ -144,7 +159,12 @@ async function handleCallbackQuery( } const domain = parts[1]; - const price = parseInt(parts[2]); + const price = parseIntSafe(parts[2], 0, 10000000); // 0 ~ 10 million KRW + + if (price === null) { + await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 가격 정보입니다.' }); + return; + } await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '등록 처리 중...' }); await editMessageText( diff --git a/src/summary-service.ts b/src/summary-service.ts index 89ec11b..27f66f3 100644 --- a/src/summary-service.ts +++ b/src/summary-service.ts @@ -3,6 +3,53 @@ import { createLogger } from './utils/logger'; const logger = createLogger('summary-service'); +// Type Guards for D1 query results +interface D1BufferedMessageRow { + id: number; + role: string; + message: string; + created_at: string; +} + +interface D1SummaryRow { + id: number; + generation: number; + summary: string; + message_count: number; + created_at: string; +} + +function isBufferedMessageRow(item: unknown): item is D1BufferedMessageRow { + if (typeof item !== 'object' || item === null) return false; + const row = item as Record; + return ( + typeof row.id === 'number' && + typeof row.role === 'string' && + typeof row.message === 'string' && + typeof row.created_at === 'string' + ); +} + +function isBufferedMessageArray(data: unknown): data is D1BufferedMessageRow[] { + return Array.isArray(data) && data.every(isBufferedMessageRow); +} + +function isSummaryRow(item: unknown): item is D1SummaryRow { + if (typeof item !== 'object' || item === null) return false; + const row = item as Record; + return ( + typeof row.id === 'number' && + typeof row.generation === 'number' && + typeof row.summary === 'string' && + typeof row.message_count === 'number' && + typeof row.created_at === 'string' + ); +} + +function isSummaryArray(data: unknown): data is D1SummaryRow[] { + return Array.isArray(data) && data.every(isSummaryRow); +} + // 설정값 가져오기 const getConfig = (env: Env) => ({ summaryThreshold: parseInt(env.SUMMARY_THRESHOLD || '20', 10), @@ -49,7 +96,20 @@ export async function getBufferedMessages( .bind(userId, chatId) .all(); - return (results || []) as unknown as BufferedMessage[]; + if (!isBufferedMessageArray(results)) { + logger.warn('Invalid message buffer data format', { userId, chatId }); + return []; + } + + // Type narrowing ensures results is D1BufferedMessageRow[] + const validatedResults: D1BufferedMessageRow[] = results; + + return validatedResults.map(row => ({ + id: row.id, + role: row.role as 'user' | 'bot', + message: row.message, + created_at: row.created_at + })); } // 최신 요약 조회 @@ -89,7 +149,15 @@ export async function getAllSummaries( .bind(userId, chatId) .all(); - return (results || []) as unknown as Summary[]; + if (!isSummaryArray(results)) { + logger.warn('Invalid summaries data format', { userId, chatId }); + return []; + } + + // Type narrowing ensures results is D1SummaryRow[] which matches Summary[] + const validatedResults: D1SummaryRow[] = results; + + return validatedResults; } // 전체 컨텍스트 조회 diff --git a/src/tools/search-tool.ts b/src/tools/search-tool.ts index 2ecac87..80d196c 100644 --- a/src/tools/search-tool.ts +++ b/src/tools/search-tool.ts @@ -65,36 +65,59 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom if (hasKorean && env?.OPENAI_API_KEY) { try { - const translateRes = await retryWithBackoff( - () => fetch(getOpenAIUrl(env), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: `사용자의 검색어를 영문으로 번역하세요. + // 번역 캐시 키 생성 + const cacheKey = `translate:${query}`; + + // 1. 캐시 확인 + let usedCache = false; + if (env.RATE_LIMIT_KV) { + const cached = await env.RATE_LIMIT_KV.get(cacheKey); + if (cached) { + translatedQuery = cached; + usedCache = true; + logger.info('캐시된 번역 사용', { original: query, translated: cached }); + } + } + + // 2. 번역 API 호출 (캐시 미스인 경우만) + if (!usedCache) { + const translateRes = await retryWithBackoff( + () => fetch(getOpenAIUrl(env), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { + role: 'system', + content: `사용자의 검색어를 영문으로 번역하세요. - 외래어/기술용어는 원래 영문 표기로 변환 (예: 판골린→Pangolin, 도커→Docker) - 일반 한국어는 영문으로 번역 - 검색에 최적화된 키워드로 변환 - 번역된 검색어만 출력, 설명 없이` - }, - { role: 'user', content: query } - ], - max_tokens: 100, - temperature: 0.3, + }, + { role: 'user', content: query } + ], + max_tokens: 100, + temperature: 0.3, + }), }), - }), - { maxRetries: 2 } // 번역은 중요하지 않으므로 재시도 2회로 제한 - ); - if (translateRes.ok) { - const translateData = await translateRes.json() as OpenAIResponse; - translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query; - logger.info('번역', { original: query, translated: translatedQuery }); + { maxRetries: 2 } // 번역은 중요하지 않으므로 재시도 2회로 제한 + ); + if (translateRes.ok) { + const translateData = await translateRes.json() as OpenAIResponse; + translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query; + logger.info('번역', { original: query, translated: translatedQuery }); + + // 3. 캐시 저장 (24시간 TTL) + if (env.RATE_LIMIT_KV && translatedQuery !== query) { + await env.RATE_LIMIT_KV.put(cacheKey, translatedQuery, { expirationTtl: 86400 }); + logger.info('번역 캐싱', { query, translatedQuery }); + } + } } } catch (error) { // 번역 실패 시 원본 사용 (RetryError 포함)