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:
kappa
2026-01-19 23:23:09 +09:00
parent 8d0fe30722
commit f5df0c0ffe
21 changed files with 13448 additions and 169 deletions

View File

@@ -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;
// 캐시 저장