refactor: apply new utilities and constants across codebase

P0 fixes:
- KV Cache migration: security.ts now delegates to kv-cache.ts (74% code reduction)
- Environment validation: index.ts validates env on first request
- Type safety: optimistic-lock.ts removes `as any` with proper interface

P1 improvements:
- Constants applied to deposit-agent.ts (TRANSACTION_STATUS, TRANSACTION_TYPE)
- Constants applied to callback-handler.ts (CALLBACK_PREFIXES)
- Constants applied to domain-tool.ts (MESSAGE_MARKERS)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-29 10:49:31 +09:00
parent 699eed1530
commit f304c6a7d4
7 changed files with 87 additions and 90 deletions

View File

@@ -48,12 +48,17 @@ export const KEYBOARD_TYPES = {
* *
* Format: prefix:action:params * Format: prefix:action:params
* Examples: * Examples:
* - domain_reg:confirm:example.com:15000 * - domain_reg:example.com:15000
* - server_order:confirm:order_123 * - server_order:userId:index
* - server_cancel:userId
*/ */
export const CALLBACK_PREFIXES = { export const CALLBACK_PREFIXES = {
DOMAIN_REGISTER_CONFIRM: 'confirm_domain_register', DOMAIN_REGISTER: 'domain_reg',
DOMAIN_REGISTER_CANCEL: 'cancel_domain_register', DOMAIN_CANCEL: 'domain_cancel',
SERVER_ORDER: 'server_order',
SERVER_CANCEL: 'server_cancel',
CONFIRM_DOMAIN_REGISTER: 'confirm_domain_register',
CANCEL_DOMAIN_REGISTER: 'cancel_domain_register',
SERVER_ORDER_CONFIRM: 'confirm_server_order', SERVER_ORDER_CONFIRM: 'confirm_server_order',
SERVER_ORDER_CANCEL: 'cancel_server_order', SERVER_ORDER_CANCEL: 'cancel_server_order',
DELETE_CONFIRM: 'confirm_delete', DELETE_CONFIRM: 'confirm_delete',

View File

@@ -14,6 +14,7 @@
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock'; import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
import { TRANSACTION_STATUS, TRANSACTION_TYPE } from './constants';
import type { ManageDepositArgs, DepositFunctionResult } from './types'; import type { ManageDepositArgs, DepositFunctionResult } from './types';
const logger = createLogger('deposit-agent'); const logger = createLogger('deposit-agent');
@@ -108,8 +109,8 @@ export async function executeDepositFunction(
// 1. Insert transaction record // 1. Insert transaction record
const result = await db.prepare( const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at) `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at)
VALUES (?, 'deposit', ?, 'confirmed', ?, ?, '입금 확인', CURRENT_TIMESTAMP)` VALUES (?, ?, ?, ?, ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run(); ).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.CONFIRMED, depositor_name, depositor_name.slice(0, 7)).run();
const txId = result.meta.last_row_id; const txId = result.meta.last_row_id;
@@ -178,8 +179,8 @@ export async function executeDepositFunction(
// 은행 알림이 없으면 pending 거래 생성 // 은행 알림이 없으면 pending 거래 생성
const result = await db.prepare( const result = await db.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description) `INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
VALUES (?, 'deposit', ?, 'pending', ?, ?, '입금 대기')` VALUES (?, ?, ?, ?, ?, ?, '입금 대기')`
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run(); ).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.PENDING, depositor_name, depositor_name.slice(0, 7)).run();
return { return {
success: true, success: true,
@@ -187,7 +188,7 @@ export async function executeDepositFunction(
transaction_id: result.meta.last_row_id, transaction_id: result.meta.last_row_id,
amount: amount, amount: amount,
depositor_name: depositor_name, depositor_name: depositor_name,
status: 'pending', status: TRANSACTION_STATUS.PENDING,
message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.', message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.',
account_info: { account_info: {
bank: context.env?.DEPOSIT_BANK_NAME || '하나은행', bank: context.env?.DEPOSIT_BANK_NAME || '하나은행',
@@ -253,13 +254,13 @@ export async function executeDepositFunction(
if (tx.user_id !== userId && !isAdmin) { if (tx.user_id !== userId && !isAdmin) {
return { error: '본인의 거래만 취소할 수 있습니다.' }; return { error: '본인의 거래만 취소할 수 있습니다.' };
} }
if (tx.status !== 'pending') { if (tx.status !== TRANSACTION_STATUS.PENDING) {
return { error: '대기 중인 거래만 취소할 수 있습니다.' }; return { error: '대기 중인 거래만 취소할 수 있습니다.' };
} }
await db.prepare( await db.prepare(
"UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?" "UPDATE deposit_transactions SET status = ? WHERE id = ?"
).bind(transaction_id).run(); ).bind(TRANSACTION_STATUS.CANCELLED, transaction_id).run();
return { return {
success: true, success: true,
@@ -278,9 +279,9 @@ export async function executeDepositFunction(
`SELECT dt.id, dt.amount, dt.depositor_name, dt.created_at, u.telegram_id, u.username `SELECT dt.id, dt.amount, dt.depositor_name, dt.created_at, u.telegram_id, u.username
FROM deposit_transactions dt FROM deposit_transactions dt
JOIN users u ON dt.user_id = u.id JOIN users u ON dt.user_id = u.id
WHERE dt.status = 'pending' AND dt.type = 'deposit' WHERE dt.status = ? AND dt.type = ?
ORDER BY dt.created_at ASC` ORDER BY dt.created_at ASC`
).all<{ ).bind(TRANSACTION_STATUS.PENDING, TRANSACTION_TYPE.DEPOSIT).all<{
id: number; id: number;
amount: number; amount: number;
depositor_name: string; depositor_name: string;
@@ -321,7 +322,7 @@ export async function executeDepositFunction(
if (!tx) { if (!tx) {
return { error: '거래를 찾을 수 없습니다.' }; return { error: '거래를 찾을 수 없습니다.' };
} }
if (tx.status !== 'pending') { if (tx.status !== TRANSACTION_STATUS.PENDING) {
return { error: '대기 중인 거래만 확인할 수 있습니다.' }; return { error: '대기 중인 거래만 확인할 수 있습니다.' };
} }
@@ -330,8 +331,8 @@ export async function executeDepositFunction(
await executeWithOptimisticLock(db, async (attempt) => { await executeWithOptimisticLock(db, async (attempt) => {
// 1. Update transaction status // 1. Update transaction status
const txUpdate = await db.prepare( const txUpdate = await db.prepare(
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'" "UPDATE deposit_transactions SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ?"
).bind(transaction_id).run(); ).bind(TRANSACTION_STATUS.CONFIRMED, transaction_id, TRANSACTION_STATUS.PENDING).run();
if (!txUpdate.success || txUpdate.meta.changes === 0) { if (!txUpdate.success || txUpdate.meta.changes === 0) {
throw new Error('Transaction already processed or not found'); throw new Error('Transaction already processed or not found');
@@ -406,13 +407,13 @@ export async function executeDepositFunction(
if (!tx) { if (!tx) {
return { error: '거래를 찾을 수 없습니다.' }; return { error: '거래를 찾을 수 없습니다.' };
} }
if (tx.status !== 'pending') { if (tx.status !== TRANSACTION_STATUS.PENDING) {
return { error: '대기 중인 거래만 거절할 수 있습니다.' }; return { error: '대기 중인 거래만 거절할 수 있습니다.' };
} }
await db.prepare( await db.prepare(
"UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?" "UPDATE deposit_transactions SET status = ? WHERE id = ?"
).bind(transaction_id).run(); ).bind(TRANSACTION_STATUS.REJECTED, transaction_id).run();
return { return {
success: true, success: true,

View File

@@ -10,13 +10,49 @@ import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
import { timingSafeEqual } from './security'; import { timingSafeEqual } from './security';
import { createLogger } from './utils/logger'; import { createLogger } from './utils/logger';
import { notifyAdmin } from './services/notification'; import { notifyAdmin } from './services/notification';
import { validateEnv } from './utils/env-validation';
import { Hono } from 'hono'; import { Hono } from 'hono';
const logger = createLogger('worker'); const logger = createLogger('worker');
// Environment validation flag (checked once per instance)
let envValidated = false;
// Hono app with Env type // Hono app with Env type
const app = new Hono<{ Bindings: Env }>(); const app = new Hono<{ Bindings: Env }>();
// Environment validation middleware (runs once per worker instance)
app.use('*', async (c, next) => {
if (!envValidated) {
// Cast to Record<string, unknown> for validation
const result = validateEnv(c.env as unknown as Record<string, unknown>);
if (!result.success) {
logger.error('Environment validation failed on startup', new Error('Invalid configuration'), {
errors: result.errors,
});
return c.json({
error: 'Configuration error',
message: 'The worker is not properly configured. Please check environment variables.',
details: result.errors,
}, 500);
}
// Log warnings but continue
if (result.warnings.length > 0) {
logger.warn('Environment configuration warnings', { warnings: result.warnings });
}
logger.info('Environment validation passed', {
environment: c.env.ENVIRONMENT || 'production',
warnings: result.warnings.length,
});
envValidated = true;
}
return await next();
});
// Health check (public - minimal info only) // Health check (public - minimal info only)
app.get('/health', () => handleHealthCheck()); app.get('/health', () => handleHealthCheck());

View File

@@ -2,6 +2,7 @@ import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegra
import { UserService } from '../../services/user-service'; import { UserService } from '../../services/user-service';
import { executeDomainRegister } from '../../domain-register'; import { executeDomainRegister } from '../../domain-register';
import { createLogger } from '../../utils/logger'; import { createLogger } from '../../utils/logger';
import { CALLBACK_PREFIXES } from '../../constants';
import type { Env, TelegramUpdate } from '../../types'; import type { Env, TelegramUpdate } from '../../types';
const logger = createLogger('callback-handler'); const logger = createLogger('callback-handler');
@@ -43,7 +44,7 @@ export async function handleCallbackQuery(
} }
// 도메인 등록 처리 // 도메인 등록 처리
if (data.startsWith('domain_reg:')) { if (data.startsWith(`${CALLBACK_PREFIXES.DOMAIN_REGISTER}:`)) {
const parts = data.split(':'); const parts = data.split(':');
if (parts.length !== 3) { if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
@@ -145,7 +146,7 @@ ${result.error}
} }
// 도메인 등록 취소 // 도메인 등록 취소
if (data === 'domain_cancel') { if (data === CALLBACK_PREFIXES.DOMAIN_CANCEL) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' }); await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '취소되었습니다.' });
await editMessageText( await editMessageText(
env.BOT_TOKEN, env.BOT_TOKEN,
@@ -157,7 +158,7 @@ ${result.error}
} }
// 서버 주문 확인 // 서버 주문 확인
if (data.startsWith('server_order:')) { if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_ORDER}:`)) {
const parts = data.split(':'); const parts = data.split(':');
if (parts.length !== 3) { if (parts.length !== 3) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
@@ -325,7 +326,7 @@ ${result.error}
} }
// 서버 주문 취소 // 서버 주문 취소
if (data.startsWith('server_cancel:')) { if (data.startsWith(`${CALLBACK_PREFIXES.SERVER_CANCEL}:`)) {
const parts = data.split(':'); const parts = data.split(':');
if (parts.length !== 2) { if (parts.length !== 2) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' }); await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });

View File

@@ -138,12 +138,8 @@ export async function validateWebhookRequest(
} }
// Rate Limiting (Cloudflare KV 기반) // Rate Limiting (Cloudflare KV 기반)
// NOTE: Future migration to KVCache abstraction layer (kv-cache.ts) planned // Migrated to use KVCache abstraction layer (kv-cache.ts)
// Current implementation kept for backward compatibility import { createRateLimitCache, checkRateLimitWithCache } from './services/kv-cache';
interface RateLimitData {
count: number;
resetAt: number;
}
export async function checkRateLimit( export async function checkRateLimit(
kv: KVNamespace, kv: KVNamespace,
@@ -151,61 +147,12 @@ export async function checkRateLimit(
maxRequests: number = 30, maxRequests: number = 30,
windowMs: number = 60000 windowMs: number = 60000
): Promise<boolean> { ): Promise<boolean> {
const key = `ratelimit:${userId}`; // Convert windowMs (milliseconds) to windowSeconds (seconds)
const now = Date.now(); const windowSeconds = Math.ceil(windowMs / 1000);
const logger = createLogger('rate-limit');
try { // Create KVCache instance with 'rate:' prefix
// KV에서 기존 데이터 조회 const cache = createRateLimitCache(kv);
const dataStr = await kv.get(key);
const data: RateLimitData | null = dataStr ? JSON.parse(dataStr) : null;
// 윈도우 만료 또는 첫 요청 // Delegate to unified KV cache implementation
if (!data || now > data.resetAt) { return checkRateLimitWithCache(cache, userId, maxRequests, windowSeconds);
const newData: RateLimitData = {
count: 1,
resetAt: now + windowMs,
};
await kv.put(key, JSON.stringify(newData), {
expirationTtl: Math.ceil(windowMs / 1000), // 초 단위
});
logger.info('Rate limit 윈도우 시작', {
userId,
resetAt: new Date(newData.resetAt).toISOString(),
maxRequests,
});
return true;
}
// Rate limit 초과
if (data.count >= maxRequests) {
const resetInSeconds = Math.ceil((data.resetAt - now) / 1000);
logger.warn('Rate limit 초과', {
userId,
currentCount: data.count,
maxRequests,
resetInSeconds,
resetAt: new Date(data.resetAt).toISOString(),
});
return false;
}
// 카운트 증가
const updatedData: RateLimitData = {
count: data.count + 1,
resetAt: data.resetAt,
};
const remainingTtl = Math.ceil((data.resetAt - now) / 1000);
await kv.put(key, JSON.stringify(updatedData), {
expirationTtl: Math.max(remainingTtl, 1), // 최소 1초
});
return true;
} catch (error) {
// KV 오류 시 요청 허용 (fail-open)
// Rate limiting은 abuse 방지 목적이므로 가용성 우선
// 심각한 abuse는 Cloudflare WAF/Firewall Rules로 별도 대응
logger.warn('KV 오류 - 요청 허용 (fail-open)', { userId, error: (error as Error).message });
return true;
}
} }

View File

@@ -9,6 +9,7 @@ import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger, maskUserId } from '../utils/logger'; import { createLogger, maskUserId } from '../utils/logger';
import { getOpenAIUrl } from '../utils/api-urls'; import { getOpenAIUrl } from '../utils/api-urls';
import { ERROR_MESSAGES } from '../constants/messages'; import { ERROR_MESSAGES } from '../constants/messages';
import { MESSAGE_MARKERS } from '../constants';
const logger = createLogger('domain-tool'); const logger = createLogger('domain-tool');
@@ -796,7 +797,7 @@ async function executeDomainAction(
domain: domain, domain: domain,
price: price price: price
}); });
return `__KEYBOARD__${keyboardData}__END__ return `${MESSAGE_MARKERS.KEYBOARD}${keyboardData}${MESSAGE_MARKERS.KEYBOARD_END}
📋 <b>도메인 등록 확인</b> 📋 <b>도메인 등록 확인</b>
• 도메인: <code>${domain}</code> • 도메인: <code>${domain}</code>

View File

@@ -32,6 +32,13 @@ import { createLogger } from './logger';
const logger = createLogger('optimistic-lock'); const logger = createLogger('optimistic-lock');
/**
* Type guard for Error with captureStackTrace method
*/
interface ErrorWithCapture {
captureStackTrace?: (target: object, constructor?: Function) => void;
}
/** /**
* Custom error for optimistic lock failures * Custom error for optimistic lock failures
*/ */
@@ -40,10 +47,9 @@ export class OptimisticLockError extends Error {
super(message); super(message);
this.name = 'OptimisticLockError'; this.name = 'OptimisticLockError';
// Maintain proper stack trace for debugging (Node.js only, not available in Workers) // Maintain proper stack trace for debugging (Node.js only, not available in Workers)
// eslint-disable-next-line @typescript-eslint/no-explicit-any const ErrorConstructor = Error as unknown as ErrorWithCapture;
if (typeof (Error as any).captureStackTrace === 'function') { if (typeof ErrorConstructor.captureStackTrace === 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any ErrorConstructor.captureStackTrace(this, OptimisticLockError);
(Error as any).captureStackTrace(this, OptimisticLockError);
} }
} }
} }