fix: P2 medium priority issues - validation and logging

P2-1: Tool selection fallback optimization
- Return only utility tools when no patterns match
- Reduces token usage by ~80% in fallback cases

P2-2: Minimum deposit amount validation
- Add MIN_DEPOSIT_AMOUNT = 1,000원
- Prevents spam with tiny deposits

P2-3: Standardize logging
- Replace console.log/error with structured logger
- bank-sms-parser.ts and security.ts

P2-4: Nameserver format validation
- Add validateNameservers() function
- Check minimum 2 NS, valid hostname format
- Clear error messages in Korean

P2-5: Optimistic lock error context
- Return specific error for version conflicts
- User-friendly message: "동시 요청으로 처리가 지연됨"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-28 20:40:41 +09:00
parent 97b8f3c7f7
commit 69e4dcc338
5 changed files with 63 additions and 17 deletions

View File

@@ -18,6 +18,7 @@ import type { ManageDepositArgs, DepositFunctionResult } from './types';
const logger = createLogger('deposit-agent'); const logger = createLogger('deposit-agent');
const MIN_DEPOSIT_AMOUNT = 1000; // 1,000원
const MAX_DEPOSIT_AMOUNT = 100_000_000; // 1억원 const MAX_DEPOSIT_AMOUNT = 100_000_000; // 1억원
export interface DepositContext { export interface DepositContext {
@@ -81,6 +82,9 @@ export async function executeDepositFunction(
if (!amount || amount <= 0) { if (!amount || amount <= 0) {
return { error: '충전 금액을 입력해주세요.' }; return { error: '충전 금액을 입력해주세요.' };
} }
if (amount < MIN_DEPOSIT_AMOUNT) {
return { error: `최소 충전 금액은 ${MIN_DEPOSIT_AMOUNT.toLocaleString()}원입니다.` };
}
if (amount > MAX_DEPOSIT_AMOUNT) { if (amount > MAX_DEPOSIT_AMOUNT) {
return { error: `최대 충전 금액은 ${MAX_DEPOSIT_AMOUNT.toLocaleString()}원입니다.` }; return { error: `최대 충전 금액은 ${MAX_DEPOSIT_AMOUNT.toLocaleString()}원입니다.` };
} }
@@ -159,9 +163,14 @@ export async function executeDepositFunction(
} catch (error) { } catch (error) {
if (error instanceof OptimisticLockError) { if (error instanceof OptimisticLockError) {
logger.warn('동시성 충돌 감지 (입금 자동 매칭)', { userId, amount, depositor_name }); logger.warn('동시성 충돌 감지 (입금 자동 매칭)', { userId, amount, depositor_name });
throw new Error('처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); return {
error: '⚠️ 동시 요청으로 처리가 지연되었습니다. 잠시 후 다시 시도해주세요.',
};
} }
throw error; logger.error('입금 자동 매칭 실패', error as Error, { userId, amount, depositor_name });
return {
error: '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
};
} }
} }
@@ -368,9 +377,14 @@ export async function executeDepositFunction(
user_id: tx.user_id, user_id: tx.user_id,
amount: tx.amount, amount: tx.amount,
}); });
throw new Error('처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); return {
error: '⚠️ 동시 요청으로 처리가 지연되었습니다. 잠시 후 다시 시도해주세요.',
};
} }
throw error; logger.error('관리자 입금 확인 실패', error as Error, { transaction_id, user_id: tx.user_id, amount: tx.amount });
return {
error: '처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
};
} }
} }

View File

@@ -1,6 +1,8 @@
import { Env, TelegramUpdate } from './types'; import { Env, TelegramUpdate } from './types';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
const logger = createLogger('security');
// Telegram 서버 IP 대역 (2024년 기준) // Telegram 서버 IP 대역 (2024년 기준)
// https://core.telegram.org/bots/webhooks#the-short-version // https://core.telegram.org/bots/webhooks#the-short-version
const TELEGRAM_IP_RANGES = [ const TELEGRAM_IP_RANGES = [
@@ -99,12 +101,12 @@ export async function validateWebhookRequest(
// 3. Secret Token 검증 (필수) // 3. Secret Token 검증 (필수)
if (!env.WEBHOOK_SECRET) { if (!env.WEBHOOK_SECRET) {
console.error('WEBHOOK_SECRET not configured - rejecting request'); logger.error('WEBHOOK_SECRET not configured - rejecting request', new Error('Missing WEBHOOK_SECRET'));
return { valid: false, error: 'Security configuration error' }; return { valid: false, error: 'Security configuration error' };
} }
if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) { if (!isValidSecretToken(request, env.WEBHOOK_SECRET)) {
console.error('Invalid webhook secret token'); logger.warn('Invalid webhook secret token');
return { valid: false, error: 'Invalid secret token' }; return { valid: false, error: 'Invalid secret token' };
} }
@@ -112,7 +114,7 @@ export async function validateWebhookRequest(
const clientIP = request.headers.get('CF-Connecting-IP'); const clientIP = request.headers.get('CF-Connecting-IP');
if (clientIP && !isValidTelegramIP(clientIP)) { if (clientIP && !isValidTelegramIP(clientIP)) {
// 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음) // 경고만 로그 (Cloudflare 프록시 환경에서는 정확하지 않을 수 있음)
console.warn(`Request from non-Telegram IP: ${clientIP}`); logger.warn('Request from non-Telegram IP', { clientIP });
} }
// 5. 요청 본문 파싱 및 검증 // 5. 요청 본문 파싱 및 검증

View File

@@ -1,5 +1,8 @@
import { Env, BankNotification, WorkersAITextGenerationOutput, WorkersAITextGenerationInput } from '../types'; import { Env, BankNotification, WorkersAITextGenerationOutput, WorkersAITextGenerationInput } from '../types';
import { parseQuotedPrintable } from '../utils/email-decoder'; import { parseQuotedPrintable } from '../utils/email-decoder';
import { createLogger } from '../utils/logger';
const logger = createLogger('bank-sms-parser');
/** /**
* 은행 SMS 파싱 함수 * 은행 SMS 파싱 함수
@@ -23,17 +26,17 @@ export async function parseBankSMS(content: string, env?: Env): Promise<BankNoti
// 3. AI 파싱 시도 (Fallback) // 3. AI 파싱 시도 (Fallback)
if (env) { if (env) {
console.log('[BankSMS] 정규식 파싱 실패, AI 파싱 시도...'); logger.info('정규식 파싱 실패, AI 파싱 시도');
try { try {
// AI 파싱은 비용/시간이 들므로 SMS 패턴이 의심될 때만 시도 // AI 파싱은 비용/시간이 들므로 SMS 패턴이 의심될 때만 시도
// "입금", "원" 같은 키워드가 있는지 확인 // "입금", "원" 같은 키워드가 있는지 확인
if (text.includes('입금') && (text.includes('원') || text.match(/\d/))) { if (text.includes('입금') && (text.includes('원') || text.match(/\d/))) {
return await parseWithAI(text, env); return await parseWithAI(text, env);
} else { } else {
console.log('[BankSMS] 입금 키워드 없음, AI 파싱 건너뜀'); logger.debug('입금 키워드 없음, AI 파싱 건너뜀');
} }
} catch (error) { } catch (error) {
console.error('[BankSMS] AI 파싱 오류:', error); logger.error('AI 파싱 오류', error as Error);
} }
} }
@@ -188,7 +191,7 @@ ${text.slice(0, 500)}
jsonStr = data.choices[0].message.content; jsonStr = data.choices[0].message.content;
} }
} catch (e) { } catch (e) {
console.error('[BankSMS] OpenAI 파싱 실패:', e); logger.error('OpenAI 파싱 실패', e as Error);
} }
} }
@@ -205,7 +208,7 @@ ${text.slice(0, 500)}
) as WorkersAITextGenerationOutput; ) as WorkersAITextGenerationOutput;
jsonStr = response.response || ''; jsonStr = response.response || '';
} catch (e) { } catch (e) {
console.error('[BankSMS] Workers AI 파싱 실패:', e); logger.error('Workers AI 파싱 실패', e as Error);
} }
} }
@@ -228,7 +231,7 @@ ${text.slice(0, 500)}
}; };
} }
} catch (e) { } catch (e) {
console.error('[BankSMS] AI 응답 JSON 파싱 실패:', e, jsonStr); logger.error('AI 응답 JSON 파싱 실패', e as Error, { jsonStr });
} }
return null; return null;

View File

@@ -46,6 +46,26 @@ function convertNamecheapDate(date: string): string {
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
} }
// 네임서버 형식 검증
function validateNameservers(nameservers: string[]): string | null {
if (!nameservers || nameservers.length < 2) {
return '❌ 최소 2개의 네임서버가 필요합니다.';
}
const nsPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/;
for (const ns of nameservers) {
if (!ns || !ns.trim()) {
return '❌ 빈 네임서버가 있습니다.';
}
if (!nsPattern.test(ns.trim())) {
return `❌ 잘못된 네임서버 형식: ${ns}`;
}
}
return null; // valid
}
// KV 캐싱 인터페이스 // KV 캐싱 인터페이스
interface CachedTLDPrice { interface CachedTLDPrice {
tld: string; tld: string;
@@ -519,6 +539,11 @@ async function executeDomainAction(
if (!domain) return '🚫 도메인을 지정해주세요.'; if (!domain) return '🚫 도메인을 지정해주세요.';
if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.'; if (!nameservers?.length) return '🚫 네임서버를 지정해주세요.';
if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`; if (!allowedDomains.includes(domain)) return `🚫 ${domain}은 관리 권한이 없습니다.`;
// 네임서버 형식 검증
const validationError = validateNameservers(nameservers);
if (validationError) return validationError;
const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId); const result = await callNamecheapApi('set_nameservers', { domain, nameservers }, allowedDomains, env, telegramUserId, db, userId);
if (isErrorResult(result)) return `🚫 ${result.error}`; if (isErrorResult(result)) return `🚫 ${result.error}`;
return `${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `${ns}`).join('\n')}`; return `${domain} 네임서버 변경 완료\n\n${nameservers.map(ns => `${ns}`).join('\n')}`;

View File

@@ -134,10 +134,12 @@ export function selectToolsForMessage(message: string): typeof tools {
} }
} }
// 패턴 매칭 없으면 전체 도구 사용 (폴백) // 패턴 매칭 없으면 유틸리티 도구 사용 (토큰 절약)
if (selectedCategories.size === 1) { // utility만 있으면 폴백 if (selectedCategories.size === 1) { // utility만 있으면
logger.info('패턴 매칭 없음 → 전체 도구 사용'); logger.info('패턴 매칭 없음 → 유틸리티 도구 사용 (토큰 절약)');
return tools; // 기본 도구만 반환 (시간, 계산)
const utilityNames = TOOL_CATEGORIES.utility || [];
return tools.filter(t => utilityNames.includes(t.function.name));
} }
const selectedNames = new Set( const selectedNames = new Set(