feat: add optimistic locking and improve type safety
- Implement optimistic locking for deposit balance updates - Prevent race conditions in concurrent deposit requests - Add automatic retry with exponential backoff (max 3 attempts) - Add version column to user_deposits table - Improve type safety across codebase - Add explicit types for Namecheap API responses - Add typed function arguments (ManageDepositArgs, etc.) - Remove `any` types from deposit-agent and tool files - Add reconciliation job for balance integrity verification - Compare user_deposits.balance vs SUM(confirmed transactions) - Alert admin on discrepancy detection - Set up test environment with Vitest + Miniflare - Add 50+ test cases for deposit system - Add helper functions for test data creation - Update documentation - Add migration guide for version columns - Document optimistic locking patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,44 @@
|
||||
import type { Env } from '../types';
|
||||
import type {
|
||||
Env,
|
||||
NamecheapPriceResponse,
|
||||
NamecheapDomainListItem,
|
||||
NamecheapCheckResult,
|
||||
OpenAIResponse
|
||||
} from '../types';
|
||||
import { retryWithBackoff, RetryError } from '../utils/retry';
|
||||
import { createLogger, maskUserId } from '../utils/logger';
|
||||
import { getOpenAIUrl } from '../utils/api-urls';
|
||||
|
||||
const logger = createLogger('domain-tool');
|
||||
|
||||
// Helper to safely get string value from Record<string, unknown>
|
||||
function getStringValue(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
// Helper to safely get number value from Record<string, unknown>
|
||||
function getNumberValue(obj: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = obj[key];
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
// Helper to safely get array value from Record<string, unknown>
|
||||
function getArrayValue<T>(obj: Record<string, unknown>, key: string): T[] | undefined {
|
||||
const value = obj[key];
|
||||
return Array.isArray(value) ? value as T[] : undefined;
|
||||
}
|
||||
|
||||
// Type guard to check if result is an error
|
||||
function isErrorResult(result: unknown): result is { error: string } {
|
||||
return typeof result === 'object' && result !== null && 'error' in result;
|
||||
}
|
||||
|
||||
// Type guard to check if result is NamecheapPriceResponse
|
||||
function isNamecheapPriceResponse(result: unknown): result is NamecheapPriceResponse {
|
||||
return typeof result === 'object' && result !== null && 'krw' in result;
|
||||
}
|
||||
|
||||
// KV 캐싱 인터페이스
|
||||
interface CachedTLDPrice {
|
||||
tld: string;
|
||||
@@ -37,7 +71,7 @@ async function getCachedTLDPrice(
|
||||
async function setCachedTLDPrice(
|
||||
kv: KVNamespace,
|
||||
tld: string,
|
||||
price: any
|
||||
price: NamecheapPriceResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = `tld_price:${tld}`;
|
||||
@@ -59,13 +93,13 @@ async function setCachedTLDPrice(
|
||||
// 전체 TLD 가격 캐시 조회
|
||||
async function getCachedAllPrices(
|
||||
kv: KVNamespace
|
||||
): Promise<any[] | null> {
|
||||
): Promise<NamecheapPriceResponse[] | null> {
|
||||
try {
|
||||
const key = 'tld_price:all';
|
||||
const cached = await kv.get(key, 'json');
|
||||
if (cached) {
|
||||
logger.info('TLDCache HIT: all prices');
|
||||
return cached as any[];
|
||||
return cached as NamecheapPriceResponse[];
|
||||
}
|
||||
logger.info('TLDCache MISS: all prices');
|
||||
return null;
|
||||
@@ -78,7 +112,7 @@ async function getCachedAllPrices(
|
||||
// 전체 TLD 가격 캐시 저장
|
||||
async function setCachedAllPrices(
|
||||
kv: KVNamespace,
|
||||
prices: any[]
|
||||
prices: NamecheapPriceResponse[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const key = 'tld_price:all';
|
||||
@@ -144,13 +178,13 @@ export const suggestDomainsTool = {
|
||||
// Namecheap API 호출 (allowedDomains로 필터링)
|
||||
async function callNamecheapApi(
|
||||
funcName: string,
|
||||
funcArgs: Record<string, any>,
|
||||
funcArgs: Record<string, unknown>,
|
||||
allowedDomains: string[],
|
||||
env?: Env,
|
||||
telegramUserId?: string,
|
||||
db?: D1Database,
|
||||
userId?: number
|
||||
): Promise<any> {
|
||||
): Promise<unknown> {
|
||||
if (!env?.NAMECHEAP_API_KEY_INTERNAL) {
|
||||
return { error: 'Namecheap API 키가 설정되지 않았습니다.' };
|
||||
}
|
||||
@@ -160,19 +194,22 @@ async function callNamecheapApi(
|
||||
// 도메인 권한 체크 (쓰기 작업만)
|
||||
// 읽기 작업(get_domain_info, get_nameservers)은 누구나 조회 가능
|
||||
if (['set_nameservers', 'create_child_ns', 'delete_child_ns'].includes(funcName)) {
|
||||
if (!allowedDomains.includes(funcArgs.domain)) {
|
||||
return { error: `권한 없음: ${funcArgs.domain}은 관리할 수 없는 도메인입니다.` };
|
||||
const domain = funcArgs.domain;
|
||||
if (typeof domain === 'string' && !allowedDomains.includes(domain)) {
|
||||
return { error: `권한 없음: ${domain}은 관리할 수 없는 도메인입니다.` };
|
||||
}
|
||||
}
|
||||
|
||||
switch (funcName) {
|
||||
case 'list_domains': {
|
||||
const page = getNumberValue(funcArgs, 'page') || 1;
|
||||
const pageSize = getNumberValue(funcArgs, 'page_size') || 100;
|
||||
const result = await retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains?page=${funcArgs.page || 1}&page_size=${funcArgs.page_size || 100}`, {
|
||||
() => fetch(`${apiUrl}/domains?page=${page}&page_size=${pageSize}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
) as any[];
|
||||
) as NamecheapDomainListItem[];
|
||||
// MM/DD/YYYY → YYYY-MM-DD 변환 (Namecheap은 미국 형식 사용)
|
||||
const convertDate = (date: string) => {
|
||||
const [month, day, year] = date.split('/');
|
||||
@@ -180,8 +217,8 @@ async function callNamecheapApi(
|
||||
};
|
||||
// 허용된 도메인만 필터링, 날짜는 ISO 형식으로 변환
|
||||
return result
|
||||
.filter((d: any) => allowedDomains.includes(d.name))
|
||||
.map((d: any) => ({
|
||||
.filter((d: NamecheapDomainListItem) => allowedDomains.includes(d.name))
|
||||
.map((d: NamecheapDomainListItem) => ({
|
||||
...d,
|
||||
created: convertDate(d.created),
|
||||
expires: convertDate(d.expires),
|
||||
@@ -189,14 +226,15 @@ async function callNamecheapApi(
|
||||
}));
|
||||
}
|
||||
case 'get_domain_info': {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
// 목록 API에서 더 많은 정보 조회 (단일 API는 정보 부족)
|
||||
const domains = await retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains?page=1&page_size=100`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
) as any[];
|
||||
const domainInfo = domains.find((d: any) => d.name === funcArgs.domain);
|
||||
) as NamecheapDomainListItem[];
|
||||
const domainInfo = domains.find((d: NamecheapDomainListItem) => d.name === domain);
|
||||
if (!domainInfo) {
|
||||
return { error: `도메인을 찾을 수 없습니다: ${funcArgs.domain}` };
|
||||
}
|
||||
@@ -216,18 +254,22 @@ async function callNamecheapApi(
|
||||
whois_guard: domainInfo.whois_guard,
|
||||
};
|
||||
}
|
||||
case 'get_nameservers':
|
||||
case 'get_nameservers': {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||||
() => fetch(`${apiUrl}/dns/${domain}/nameservers`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
}
|
||||
case 'set_nameservers': {
|
||||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/nameservers`, {
|
||||
const domain = getStringValue(funcArgs, 'domain');
|
||||
const nameservers = getArrayValue<string>(funcArgs, 'nameservers');
|
||||
const res = await fetch(`${apiUrl}/dns/${domain}/nameservers`, {
|
||||
method: 'PUT',
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domain: funcArgs.domain, nameservers: funcArgs.nameservers }),
|
||||
body: JSON.stringify({ domain, nameservers }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
@@ -251,7 +293,7 @@ async function callNamecheapApi(
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nameserver: funcArgs.nameserver, ip: funcArgs.ip }),
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 생성 실패` };
|
||||
}
|
||||
@@ -261,7 +303,7 @@ async function callNamecheapApi(
|
||||
const res = await fetch(`${apiUrl}/dns/${funcArgs.domain}/childns/${funcArgs.nameserver}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 조회 실패` };
|
||||
}
|
||||
@@ -272,7 +314,7 @@ async function callNamecheapApi(
|
||||
method: 'DELETE',
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
const data = await res.json() as { detail?: string };
|
||||
if (!res.ok) {
|
||||
return { error: data.detail || `Child NS 삭제 실패` };
|
||||
}
|
||||
@@ -286,7 +328,8 @@ async function callNamecheapApi(
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
case 'get_price': {
|
||||
const tld = funcArgs.tld?.replace(/^\./, ''); // .com → com
|
||||
const tldRaw = getStringValue(funcArgs, 'tld');
|
||||
const tld = tldRaw?.replace(/^\./, ''); // .com → com
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/prices/${tld}`, {
|
||||
headers: { 'X-API-Key': apiKey },
|
||||
@@ -303,12 +346,13 @@ async function callNamecheapApi(
|
||||
);
|
||||
}
|
||||
case 'check_domains': {
|
||||
const domains = getArrayValue<string>(funcArgs, 'domains');
|
||||
// POST but idempotent (read-only check)
|
||||
return retryWithBackoff(
|
||||
() => fetch(`${apiUrl}/domains/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ domains: funcArgs.domains }),
|
||||
body: JSON.stringify({ domains }),
|
||||
}).then(r => r.json()),
|
||||
{ maxRetries: 3 }
|
||||
);
|
||||
@@ -324,7 +368,18 @@ async function callNamecheapApi(
|
||||
if (!whoisRes.ok) {
|
||||
return { error: `WHOIS 조회 실패: HTTP ${whoisRes.status}` };
|
||||
}
|
||||
const whois = await whoisRes.json() as any;
|
||||
const whois = await whoisRes.json() as {
|
||||
error?: string;
|
||||
whois_supported?: boolean;
|
||||
ccSLD?: string;
|
||||
message_ko?: string;
|
||||
suggestion_ko?: string;
|
||||
domain?: string;
|
||||
available?: boolean;
|
||||
whois_server?: string;
|
||||
raw?: string;
|
||||
query_time_ms?: number;
|
||||
};
|
||||
|
||||
if (whois.error) {
|
||||
return { error: `WHOIS 조회 오류: ${whois.error}` };
|
||||
@@ -370,7 +425,7 @@ async function callNamecheapApi(
|
||||
telegram_id: telegramUserId,
|
||||
}),
|
||||
});
|
||||
const result = await res.json() as any;
|
||||
const result = await res.json() as { registered?: boolean; detail?: string; warning?: string };
|
||||
if (!res.ok) {
|
||||
return { error: result.detail || '도메인 등록 실패' };
|
||||
}
|
||||
@@ -409,10 +464,13 @@ async function executeDomainAction(
|
||||
switch (action) {
|
||||
case 'list': {
|
||||
const result = await callNamecheapApi('list_domains', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (!result.length) return '📋 등록된 도메인이 없습니다.';
|
||||
const list = result.map((d: any) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||
return `📋 내 도메인 목록 (${result.length}개)\n\n${list}`;
|
||||
if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
return `🚫 ${(result as { error: string }).error}`;
|
||||
}
|
||||
const domains = result as NamecheapDomainListItem[];
|
||||
if (!domains.length) return '📋 등록된 도메인이 없습니다.';
|
||||
const list = domains.map((d: NamecheapDomainListItem) => `• ${d.name} (만료: ${d.expires})`).join('\n');
|
||||
return `📋 내 도메인 목록 (${domains.length}개)\n\n${list}`;
|
||||
}
|
||||
|
||||
case 'info': {
|
||||
@@ -455,8 +513,9 @@ async function executeDomainAction(
|
||||
case 'check': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('check_domains', { domains: [domain] }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
const available = result[domain];
|
||||
if (isErrorResult(result)) return `🚫 ${result.error}`;
|
||||
const checkResult = result as NamecheapCheckResult;
|
||||
const available = checkResult[domain];
|
||||
if (available) {
|
||||
// 가격도 함께 조회
|
||||
const domainTld = domain.split('.').pop() || '';
|
||||
@@ -473,11 +532,13 @@ async function executeDomainAction(
|
||||
// 캐시 미스 시 API 호출
|
||||
if (!price) {
|
||||
const priceResult = await callNamecheapApi('get_price', { tld: domainTld }, allowedDomains, env, telegramUserId, db, userId);
|
||||
price = priceResult.krw || priceResult.register_krw;
|
||||
if (isNamecheapPriceResponse(priceResult)) {
|
||||
price = priceResult.krw || priceResult.register_krw;
|
||||
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult);
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV) {
|
||||
await setCachedTLDPrice(env.RATE_LIMIT_KV, domainTld, priceResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,15 +550,23 @@ async function executeDomainAction(
|
||||
case 'whois': {
|
||||
if (!domain) return '🚫 도메인을 지정해주세요.';
|
||||
const result = await callNamecheapApi('whois_lookup', { domain }, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (isErrorResult(result)) return `🚫 ${result.error}`;
|
||||
|
||||
const whoisResult = result as {
|
||||
whois_supported?: boolean;
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
raw?: string;
|
||||
available?: boolean;
|
||||
};
|
||||
|
||||
// ccSLD WHOIS 미지원
|
||||
if (result.whois_supported === false) {
|
||||
return `🔍 ${domain} WHOIS\n\n⚠️ ${result.message}\n💡 ${result.suggestion}`;
|
||||
if (whoisResult.whois_supported === false) {
|
||||
return `🔍 ${domain} WHOIS\n\n⚠️ ${whoisResult.message}\n💡 ${whoisResult.suggestion}`;
|
||||
}
|
||||
|
||||
// raw WHOIS 데이터에서 주요 정보 추출
|
||||
const raw = result.raw || '';
|
||||
const raw = whoisResult.raw || '';
|
||||
const extractField = (patterns: RegExp[]): string => {
|
||||
for (const pattern of patterns) {
|
||||
const match = raw.match(pattern);
|
||||
@@ -606,11 +675,11 @@ async function executeDomainAction(
|
||||
const cached = await getCachedAllPrices(env.RATE_LIMIT_KV);
|
||||
if (cached) {
|
||||
const sorted = cached
|
||||
.filter((p: any) => p.krw > 0)
|
||||
.sort((a: any, b: any) => a.krw - b.krw)
|
||||
.filter((p: NamecheapPriceResponse) => p.krw > 0)
|
||||
.sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw)
|
||||
.slice(0, 15);
|
||||
const list = sorted
|
||||
.map((p: any, idx: number) => `${idx + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`)
|
||||
.map((p: NamecheapPriceResponse, idx: number) => `${idx + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`)
|
||||
.join('\n');
|
||||
return `💰 가장 저렴한 TLD TOP 15\n\n${list}\n\n📌 캐시된 정보\n💡 특정 TLD 가격은 ".com 가격" 형식으로 조회`;
|
||||
}
|
||||
@@ -618,24 +687,26 @@ async function executeDomainAction(
|
||||
|
||||
// API 호출
|
||||
const result = await callNamecheapApi('get_all_prices', {}, allowedDomains, env, telegramUserId, db, userId);
|
||||
if (result.error) return `🚫 ${result.error}`;
|
||||
if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
return `🚫 ${(result as { error: string }).error}`;
|
||||
}
|
||||
|
||||
// 캐시 저장
|
||||
if (env?.RATE_LIMIT_KV && Array.isArray(result)) {
|
||||
await setCachedAllPrices(env.RATE_LIMIT_KV, result);
|
||||
await setCachedAllPrices(env.RATE_LIMIT_KV, result as NamecheapPriceResponse[]);
|
||||
}
|
||||
|
||||
// 가격 > 0인 TLD만 필터링, krw 기준 정렬
|
||||
const sorted = (result as any[])
|
||||
.filter((p: any) => p.krw > 0)
|
||||
.sort((a: any, b: any) => a.krw - b.krw)
|
||||
const sorted = (result as NamecheapPriceResponse[])
|
||||
.filter((p: NamecheapPriceResponse) => p.krw > 0)
|
||||
.sort((a: NamecheapPriceResponse, b: NamecheapPriceResponse) => a.krw - b.krw)
|
||||
.slice(0, 15);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
return '🚫 TLD 가격 정보를 가져올 수 없습니다.';
|
||||
}
|
||||
|
||||
const list = sorted.map((p: any, i: number) =>
|
||||
const list = sorted.map((p: NamecheapPriceResponse, i: number) =>
|
||||
`${i + 1}. .${p.tld} - ${p.krw.toLocaleString()}원/년`
|
||||
).join('\n');
|
||||
|
||||
@@ -832,7 +903,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
return '🚫 도메인 아이디어 생성 중 오류가 발생했습니다.';
|
||||
}
|
||||
|
||||
const ideaData = await ideaResponse.json() as any;
|
||||
const ideaData = await ideaResponse.json() as OpenAIResponse;
|
||||
const ideaContent = ideaData.choices?.[0]?.message?.content || '[]';
|
||||
|
||||
let domains: string[];
|
||||
@@ -865,7 +936,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
|
||||
if (!checkResponse.ok) continue;
|
||||
|
||||
const checkRaw = await checkResponse.json() as Record<string, boolean>;
|
||||
const checkRaw = await checkResponse.json() as NamecheapCheckResult;
|
||||
|
||||
// 등록 가능한 도메인만 추가
|
||||
for (const [domain, isAvailable] of Object.entries(checkRaw)) {
|
||||
@@ -910,7 +981,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
|
||||
return { tld, price: null, error: `HTTP ${priceRes.status}` };
|
||||
}
|
||||
|
||||
const priceData = await priceRes.json() as { krw?: number };
|
||||
const priceData = await priceRes.json() as NamecheapPriceResponse;
|
||||
const price = priceData.krw || 0;
|
||||
|
||||
// 캐시 저장
|
||||
|
||||
Reference in New Issue
Block a user