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:
@@ -48,12 +48,17 @@ export const KEYBOARD_TYPES = {
|
||||
*
|
||||
* Format: prefix:action:params
|
||||
* Examples:
|
||||
* - domain_reg:confirm:example.com:15000
|
||||
* - server_order:confirm:order_123
|
||||
* - domain_reg:example.com:15000
|
||||
* - server_order:userId:index
|
||||
* - server_cancel:userId
|
||||
*/
|
||||
export const CALLBACK_PREFIXES = {
|
||||
DOMAIN_REGISTER_CONFIRM: 'confirm_domain_register',
|
||||
DOMAIN_REGISTER_CANCEL: 'cancel_domain_register',
|
||||
DOMAIN_REGISTER: 'domain_reg',
|
||||
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_CANCEL: 'cancel_server_order',
|
||||
DELETE_CONFIRM: 'confirm_delete',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { createLogger } from './utils/logger';
|
||||
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
|
||||
import { TRANSACTION_STATUS, TRANSACTION_TYPE } from './constants';
|
||||
import type { ManageDepositArgs, DepositFunctionResult } from './types';
|
||||
|
||||
const logger = createLogger('deposit-agent');
|
||||
@@ -108,8 +109,8 @@ export async function executeDepositFunction(
|
||||
// 1. Insert transaction record
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description, confirmed_at)
|
||||
VALUES (?, 'deposit', ?, 'confirmed', ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
|
||||
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
VALUES (?, ?, ?, ?, ?, ?, '입금 확인', CURRENT_TIMESTAMP)`
|
||||
).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.CONFIRMED, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
|
||||
const txId = result.meta.last_row_id;
|
||||
|
||||
@@ -178,8 +179,8 @@ export async function executeDepositFunction(
|
||||
// 은행 알림이 없으면 pending 거래 생성
|
||||
const result = await db.prepare(
|
||||
`INSERT INTO deposit_transactions (user_id, type, amount, status, depositor_name, depositor_name_prefix, description)
|
||||
VALUES (?, 'deposit', ?, 'pending', ?, ?, '입금 대기')`
|
||||
).bind(userId, amount, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
VALUES (?, ?, ?, ?, ?, ?, '입금 대기')`
|
||||
).bind(userId, TRANSACTION_TYPE.DEPOSIT, amount, TRANSACTION_STATUS.PENDING, depositor_name, depositor_name.slice(0, 7)).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -187,7 +188,7 @@ export async function executeDepositFunction(
|
||||
transaction_id: result.meta.last_row_id,
|
||||
amount: amount,
|
||||
depositor_name: depositor_name,
|
||||
status: 'pending',
|
||||
status: TRANSACTION_STATUS.PENDING,
|
||||
message: '입금 요청이 등록되었습니다. 은행 입금 확인 후 자동으로 충전됩니다.',
|
||||
account_info: {
|
||||
bank: context.env?.DEPOSIT_BANK_NAME || '하나은행',
|
||||
@@ -253,13 +254,13 @@ export async function executeDepositFunction(
|
||||
if (tx.user_id !== userId && !isAdmin) {
|
||||
return { error: '본인의 거래만 취소할 수 있습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
if (tx.status !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 취소할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'cancelled' WHERE id = ?"
|
||||
).bind(transaction_id).run();
|
||||
"UPDATE deposit_transactions SET status = ? WHERE id = ?"
|
||||
).bind(TRANSACTION_STATUS.CANCELLED, transaction_id).run();
|
||||
|
||||
return {
|
||||
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
|
||||
FROM deposit_transactions dt
|
||||
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`
|
||||
).all<{
|
||||
).bind(TRANSACTION_STATUS.PENDING, TRANSACTION_TYPE.DEPOSIT).all<{
|
||||
id: number;
|
||||
amount: number;
|
||||
depositor_name: string;
|
||||
@@ -321,7 +322,7 @@ export async function executeDepositFunction(
|
||||
if (!tx) {
|
||||
return { error: '거래를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
if (tx.status !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 확인할 수 있습니다.' };
|
||||
}
|
||||
|
||||
@@ -330,8 +331,8 @@ export async function executeDepositFunction(
|
||||
await executeWithOptimisticLock(db, async (attempt) => {
|
||||
// 1. Update transaction status
|
||||
const txUpdate = await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'pending'"
|
||||
).bind(transaction_id).run();
|
||||
"UPDATE deposit_transactions SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ? AND status = ?"
|
||||
).bind(TRANSACTION_STATUS.CONFIRMED, transaction_id, TRANSACTION_STATUS.PENDING).run();
|
||||
|
||||
if (!txUpdate.success || txUpdate.meta.changes === 0) {
|
||||
throw new Error('Transaction already processed or not found');
|
||||
@@ -406,13 +407,13 @@ export async function executeDepositFunction(
|
||||
if (!tx) {
|
||||
return { error: '거래를 찾을 수 없습니다.' };
|
||||
}
|
||||
if (tx.status !== 'pending') {
|
||||
if (tx.status !== TRANSACTION_STATUS.PENDING) {
|
||||
return { error: '대기 중인 거래만 거절할 수 있습니다.' };
|
||||
}
|
||||
|
||||
await db.prepare(
|
||||
"UPDATE deposit_transactions SET status = 'rejected' WHERE id = ?"
|
||||
).bind(transaction_id).run();
|
||||
"UPDATE deposit_transactions SET status = ? WHERE id = ?"
|
||||
).bind(TRANSACTION_STATUS.REJECTED, transaction_id).run();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
36
src/index.ts
36
src/index.ts
@@ -10,13 +10,49 @@ import { handleProvisionQueue, handleProvisionDLQ } from './server-provision';
|
||||
import { timingSafeEqual } from './security';
|
||||
import { createLogger } from './utils/logger';
|
||||
import { notifyAdmin } from './services/notification';
|
||||
import { validateEnv } from './utils/env-validation';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const logger = createLogger('worker');
|
||||
|
||||
// Environment validation flag (checked once per instance)
|
||||
let envValidated = false;
|
||||
|
||||
// Hono app with Env type
|
||||
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)
|
||||
app.get('/health', () => handleHealthCheck());
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { answerCallbackQuery, editMessageText, sendMessage } from '../../telegra
|
||||
import { UserService } from '../../services/user-service';
|
||||
import { executeDomainRegister } from '../../domain-register';
|
||||
import { createLogger } from '../../utils/logger';
|
||||
import { CALLBACK_PREFIXES } from '../../constants';
|
||||
import type { Env, TelegramUpdate } from '../../types';
|
||||
|
||||
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(':');
|
||||
if (parts.length !== 3) {
|
||||
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 editMessageText(
|
||||
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(':');
|
||||
if (parts.length !== 3) {
|
||||
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(':');
|
||||
if (parts.length !== 2) {
|
||||
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '잘못된 데이터입니다.' });
|
||||
|
||||
@@ -138,12 +138,8 @@ export async function validateWebhookRequest(
|
||||
}
|
||||
|
||||
// Rate Limiting (Cloudflare KV 기반)
|
||||
// NOTE: Future migration to KVCache abstraction layer (kv-cache.ts) planned
|
||||
// Current implementation kept for backward compatibility
|
||||
interface RateLimitData {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
// Migrated to use KVCache abstraction layer (kv-cache.ts)
|
||||
import { createRateLimitCache, checkRateLimitWithCache } from './services/kv-cache';
|
||||
|
||||
export async function checkRateLimit(
|
||||
kv: KVNamespace,
|
||||
@@ -151,61 +147,12 @@ export async function checkRateLimit(
|
||||
maxRequests: number = 30,
|
||||
windowMs: number = 60000
|
||||
): Promise<boolean> {
|
||||
const key = `ratelimit:${userId}`;
|
||||
const now = Date.now();
|
||||
const logger = createLogger('rate-limit');
|
||||
// Convert windowMs (milliseconds) to windowSeconds (seconds)
|
||||
const windowSeconds = Math.ceil(windowMs / 1000);
|
||||
|
||||
try {
|
||||
// KV에서 기존 데이터 조회
|
||||
const dataStr = await kv.get(key);
|
||||
const data: RateLimitData | null = dataStr ? JSON.parse(dataStr) : null;
|
||||
// Create KVCache instance with 'rate:' prefix
|
||||
const cache = createRateLimitCache(kv);
|
||||
|
||||
// 윈도우 만료 또는 첫 요청
|
||||
if (!data || now > data.resetAt) {
|
||||
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;
|
||||
}
|
||||
// Delegate to unified KV cache implementation
|
||||
return checkRateLimitWithCache(cache, userId, maxRequests, windowSeconds);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger, maskUserId } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
import { ERROR_MESSAGES } from '../constants/messages';
|
||||
import { MESSAGE_MARKERS } from '../constants';
|
||||
|
||||
const logger = createLogger('domain-tool');
|
||||
|
||||
@@ -796,7 +797,7 @@ async function executeDomainAction(
|
||||
domain: domain,
|
||||
price: price
|
||||
});
|
||||
return `__KEYBOARD__${keyboardData}__END__
|
||||
return `${MESSAGE_MARKERS.KEYBOARD}${keyboardData}${MESSAGE_MARKERS.KEYBOARD_END}
|
||||
📋 <b>도메인 등록 확인</b>
|
||||
|
||||
• 도메인: <code>${domain}</code>
|
||||
|
||||
@@ -32,6 +32,13 @@ import { createLogger } from './logger';
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -40,10 +47,9 @@ export class OptimisticLockError extends Error {
|
||||
super(message);
|
||||
this.name = 'OptimisticLockError';
|
||||
// Maintain proper stack trace for debugging (Node.js only, not available in Workers)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (typeof (Error as any).captureStackTrace === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Error as any).captureStackTrace(this, OptimisticLockError);
|
||||
const ErrorConstructor = Error as unknown as ErrorWithCapture;
|
||||
if (typeof ErrorConstructor.captureStackTrace === 'function') {
|
||||
ErrorConstructor.captureStackTrace(this, OptimisticLockError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user