Files
telegram-bot-workers/src/domain-register.ts
kaffa 4e246aad22
Some checks failed
TypeScript CI / build (push) Has been cancelled
chore: anvil.it.com → inouter.com
2026-03-27 16:16:18 +00:00

284 lines
9.8 KiB
TypeScript

import { z } from 'zod';
import { Env } from './types';
import { createLogger } from './utils/logger';
import { executeWithOptimisticLock, OptimisticLockError } from './utils/optimistic-lock';
const logger = createLogger('domain-register');
// Zod schemas for API response validation
const NamecheapRegisterResponseSchema = z.object({
registered: z.boolean().optional(),
domain: z.string().optional(),
error: z.string().optional(),
detail: z.string().optional(),
});
const DomainInfoResponseSchema = z.object({
expires: z.string().optional(),
});
const NameserverResponseSchema = z.object({
nameservers: z.array(z.string()).optional(),
});
const PriceResponseSchema = z.object({
krw: z.number().optional(),
register_krw: z.number().optional(),
});
interface RegisterResult {
success: boolean;
domain?: string;
price?: number;
newBalance?: number;
nameservers?: string[];
expiresAt?: string;
error?: string;
}
// 도메인 등록 실행
export async function executeDomainRegister(
env: Env,
userId: number,
telegramUserId: string,
domain: string,
price: number
): Promise<RegisterResult> {
const apiKey = env.NAMECHEAP_API_KEY;
const apiUrl = env.NAMECHEAP_API_URL || 'https://namecheap.api.inouter.com';
if (!apiKey) {
return { success: false, error: 'API 키가 설정되지 않았습니다.' };
}
try {
// 1. Verify price from Namecheap API (security: prevent price manipulation)
const domainTld = domain.split('.').pop() || '';
const priceCheckResponse = await fetch(`${apiUrl}/prices/${domainTld}`, {
headers: { 'X-API-Key': apiKey }
});
if (!priceCheckResponse.ok) {
logger.error('Failed to fetch price from Namecheap API', new Error(`HTTP ${priceCheckResponse.status}`));
return { success: false, error: '가격 정보를 가져올 수 없습니다.' };
}
const priceJsonData = await priceCheckResponse.json();
const priceParseResult = PriceResponseSchema.safeParse(priceJsonData);
if (!priceParseResult.success) {
logger.error('Price response schema validation failed', priceParseResult.error);
return { success: false, error: '가격 정보 형식이 올바르지 않습니다.' };
}
const priceData = priceParseResult.data;
const actualPrice = priceData.krw || priceData.register_krw;
if (!actualPrice || typeof actualPrice !== 'number') {
logger.error('Invalid price data from API', new Error('Missing or invalid krw/register_krw'), { priceData });
return { success: false, error: '가격 정보가 올바르지 않습니다.' };
}
// SECURITY: Verify callback price matches actual API price (allow 5% tolerance for exchange rate fluctuation)
const priceDiff = Math.abs(actualPrice - price);
const tolerance = actualPrice * 0.05; // 5%
if (priceDiff > tolerance) {
logger.warn('Price mismatch detected - potential price manipulation', {
callbackPrice: price,
actualPrice,
difference: priceDiff,
domain
});
return {
success: false,
error: `가격이 변경되었습니다. 현재 가격: ${actualPrice.toLocaleString()}\n다시 등록을 시도해주세요.`
};
}
logger.info('Price verification passed', { domain, callbackPrice: price, actualPrice });
// 2. 현재 잔액 확인
const balanceRow = await env.DB.prepare(
'SELECT balance FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number }>();
const currentBalance = balanceRow?.balance || 0;
// Use actual price from API instead of callback price
if (currentBalance < actualPrice) {
return {
success: false,
error: `잔액이 부족합니다. (현재: ${currentBalance.toLocaleString()}원, 필요: ${actualPrice.toLocaleString()}원)`
};
}
// 3. Namecheap API로 도메인 등록
logger.info('도메인 등록 요청', { domain, actualPrice, callbackPrice: price });
const registerResponse = await fetch(`${apiUrl}/domains/register`, {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
domain: domain,
years: 1,
telegram_id: telegramUserId,
}),
});
const jsonData = await registerResponse.json();
const parseResult = NamecheapRegisterResponseSchema.safeParse(jsonData);
if (!parseResult.success) {
logger.error('Namecheap register response schema validation failed', parseResult.error);
return { success: false, error: '도메인 등록 응답 형식이 올바르지 않습니다.' };
}
const registerResult = parseResult.data;
if (!registerResponse.ok || !registerResult.registered) {
const errorMsg = registerResult.error || registerResult.detail || '도메인 등록에 실패했습니다.';
logger.error('등록 실패', new Error(errorMsg), { registerResult });
return { success: false, error: errorMsg };
}
logger.info('등록 성공', { registerResult });
// 4. 잔액 차감 + 거래 기록 (Optimistic Locking) - USE ACTUAL PRICE
try {
await executeWithOptimisticLock(env.DB, async () => {
// Read current balance and version
const current = await env.DB.prepare(
'SELECT balance, version FROM user_deposits WHERE user_id = ?'
).bind(userId).first<{ balance: number; version: number }>();
if (!current || current.balance < actualPrice) {
throw new Error('잔액이 부족합니다.');
}
// Update balance with version check - USE ACTUAL PRICE
const updateResult = await env.DB.prepare(
'UPDATE user_deposits SET balance = balance - ?, version = version + 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND version = ?'
).bind(actualPrice, userId, current.version).run();
if (!updateResult.success || updateResult.meta.changes === 0) {
throw new OptimisticLockError('Version mismatch on balance update');
}
// Insert transaction record - USE ACTUAL PRICE
const txResult = await env.DB.prepare(
`INSERT INTO deposit_transactions (user_id, type, amount, status, description, confirmed_at)
VALUES (?, 'withdrawal', ?, 'confirmed', ?, CURRENT_TIMESTAMP)`
).bind(userId, actualPrice, `도메인 등록: ${domain}`).run();
if (!txResult.success) {
throw new Error('거래 기록 생성 실패');
}
logger.info('Domain registration payment completed with optimistic locking', {
userId,
telegramUserId,
domain,
actualPrice,
callbackPrice: price,
newBalance: current.balance - actualPrice,
});
});
} catch (error) {
if (error instanceof OptimisticLockError) {
logger.warn('동시성 충돌 감지 (도메인 등록)', {
userId,
telegramUserId,
domain,
actualPrice,
});
return {
success: false,
error: '처리 중 동시성 충돌이 발생했습니다. 잠시 후 다시 시도해주세요.',
};
}
throw error; // Re-throw other errors to be caught by outer catch
}
// 4. user_domains 테이블에 추가
await env.DB.prepare(
'INSERT INTO user_domains (user_id, domain, verified, created_at) VALUES (?, ?, 1, datetime("now"))'
).bind(userId, domain).run();
// 5. 도메인 정보 조회 (네임서버 + 만료일) - 병렬 처리
let nameservers: string[] = [];
let expiresAt: string | undefined;
try {
// 도메인 정보 + 네임서버 병렬 조회
const [infoResponse, nsResponse] = await Promise.all([
fetch(`${apiUrl}/domains/${domain}/info`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${apiUrl}/domains/${domain}/nameservers`, {
headers: { 'X-API-Key': apiKey }
})
]);
// 도메인 정보 처리 (만료일)
if (infoResponse.ok) {
const infoJsonData = await infoResponse.json();
const infoParseResult = DomainInfoResponseSchema.safeParse(infoJsonData);
if (!infoParseResult.success) {
logger.warn('Domain info response schema validation failed', { domain });
} else {
const infoResult = infoParseResult.data;
if (infoResult.expires) {
// MM/DD/YYYY → YYYY-MM-DD 변환
const [month, day, year] = infoResult.expires.split('/');
expiresAt = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
}
}
// 네임서버 처리
if (nsResponse.ok) {
const nsJsonData = await nsResponse.json();
const nsParseResult = NameserverResponseSchema.safeParse(nsJsonData);
if (!nsParseResult.success) {
logger.warn('Nameserver response schema validation failed', { domain });
} else {
const nsResult = nsParseResult.data;
nameservers = nsResult.nameservers || [];
}
}
} catch (infoError) {
logger.info('도메인 정보 조회 실패 (무시)', { error: infoError });
}
const newBalance = currentBalance - actualPrice;
logger.info('도메인 등록 완료', {
domain,
oldBalance: currentBalance,
newBalance,
actualPrice,
expiresAt,
nameservers: nameservers.join(', ')
});
return {
success: true,
domain: domain,
price: actualPrice, // Return actual price charged
newBalance: newBalance,
nameservers: nameservers,
expiresAt: expiresAt,
};
} catch (error) {
logger.error('도메인 등록 중 오류', error as Error, { domain, callbackPrice: price });
return {
success: false,
error: '도메인 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
};
}
}