fix: critical P0+P1 issues for code quality score 9.0
P0 (Critical): - api.ts: Add transaction rollback on INSERT failure in /api/deposit/deduct - Restores balance if transaction record fails to insert - Logs rollback success/failure for audit trail - Maintains data consistency despite D1's non-transactional nature P1 (Important): - summary-service.ts: Replace double type assertions with Type Guards - Add D1BufferedMessageRow, D1SummaryRow interfaces - Add isBufferedMessageRow, isSummaryRow type guards - Runtime validation with compile-time type safety - Remove all `as unknown as` patterns - webhook.ts: Add integer range validation for callback queries - Add parseIntSafe() utility with min/max bounds - Validate domain registration price (0-10,000,000 KRW) - Prevent negative/overflow/NaN injection attacks - search-tool.ts: Implement KV caching for translation API - Cache Korean→English translations for 24 hours - Use RATE_LIMIT_KV namespace with 'translate:' prefix - Reduce redundant OpenAI API calls for repeated queries Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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, {
|
||||
// 잔액 복구 시도 (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'
|
||||
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: '거래 처리 실패 - 관리자에게 문의하세요'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
// 전체 컨텍스트 조회
|
||||
|
||||
@@ -65,6 +65,22 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
|
||||
if (hasKorean && env?.OPENAI_API_KEY) {
|
||||
try {
|
||||
// 번역 캐시 키 생성
|
||||
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',
|
||||
@@ -95,6 +111,13 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
|
||||
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 포함)
|
||||
|
||||
Reference in New Issue
Block a user