refactor: 사주 Python 엔진 제거 + lunar-javascript 기반 절기 계산 도입

- lib/solar-terms.ts: solarlunar → lunar-javascript로 전면 교체
  - getSolarTermDate(): LunarYear.fromYear().getJieQiJulianDays() 사용 (시분 단위 정밀도)
  - 소한(22)/대한(23)은 year-1로 조회해 해당 연도 1월 날짜 정확히 반환
  - getCurrentSolarTerm(): 입춘 기준 두 구간 분리, Date.UTC() 비교
- lib/daeun-calculator.ts: getSolarTermDate 정확도 향상으로 termYear 수동 보정 제거
- lib/saju-calculator.ts: 일주 기준일 甲戌, Date.UTC(), 오호둔월법 공식 적용
- lib/ai-interpretation.ts: 신약 용신 후보 내림차순 정렬 수정
- app/saju/result/page.tsx: Python 엔진(fetchFromPythonEngine) 완전 제거, TS 전용
- app/api/saju/calculate/route.ts: Python 프록시 라우트 삭제
- app/saju/page.tsx: fromHistory 파라미터 제거
- types/lunar-javascript.d.ts: 타입 선언 파일 추가

검증 케이스(1992-12-23 16:30 남성): 壬申/壬子/癸酉/庚申

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 23:38:25 +09:00
parent 7f4fb8027a
commit 1193a075c2
12 changed files with 351 additions and 343 deletions

View File

@@ -40,7 +40,6 @@ interface SajuRecord {
function buildResultUrl(rec: SajuRecord) {
const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
// null/undefined 값이 있으면 URL 생성 불가
if (!birth_year || !birth_month || !birth_day) return '/saju/input';
let url = `/saju/result?year=${birth_year}&month=${birth_month}&day=${birth_day}&gender=${gender}&calendarType=solar`;
if (birth_hour != null) url += `&hour=${birth_hour}`;

View File

@@ -165,6 +165,17 @@ function SectionCard({ section, meta, isOpen, onToggle }: {
);
}
// mock 데이터 여부 감지 (저장된 해석이 예시 데이터인 경우 재생성 필요)
function isMockInterpretation(text: string | null): boolean {
if (!text) return false;
return (
text.includes('API 키 문제 또는 할당량 초과') ||
text.includes('GEMINI_API_KEY 환경변수를 설정') ||
text.includes('예시 데이터를 보여드립니다') ||
text.includes('API 설정이 필요합니다')
);
}
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────
export default function SajuAISection({
hasPaid,
@@ -177,11 +188,15 @@ export default function SajuAISection({
currentUrl,
engineData,
}: SajuAISectionProps) {
// 저장된 해석이 mock 데이터면 재생성 필요
const isMock = isMockInterpretation(savedInterpretation);
const validSaved = savedInterpretation && !isMock ? savedInterpretation : null;
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>(
savedInterpretation ? 'done' : 'idle'
validSaved ? 'done' : 'idle'
);
const [interpretation, setInterpretation] = useState(savedInterpretation ?? '');
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0])); // 첫 섹션 기본 열림
const [interpretation, setInterpretation] = useState(validSaved ?? '');
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0]));
const called = useRef(false);
const sections = parseInterpretation(interpretation);
@@ -198,8 +213,45 @@ export default function SajuAISection({
const expandAll = () => setOpenSections(new Set(sections.map((_, i) => i)));
const collapseAll = () => setOpenSections(new Set());
// 재생성: called ref 초기화 후 다시 API 호출
const handleRegenerate = () => {
called.current = false;
setStatus('idle');
setInterpretation('');
// idle → useEffect가 다시 실행되도록 상태 전환 트리거
setTimeout(() => {
called.current = false;
setStatus('loading');
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
})
.then(r => r.json())
.then(data => {
if (data.interpretation && !isMockInterpretation(data.interpretation)) {
setInterpretation(data.interpretation);
setStatus('done');
setOpenSections(new Set([0]));
// DB에 실제 해석으로 덮어쓰기
const { birth_year, birth_month, birth_day } = birthKey;
if (typeof birth_year === 'number' && typeof birth_month === 'number' && typeof birth_day === 'number') {
fetch('/api/saju/save-interpretation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interpretation: data.interpretation, birthKey }),
}).catch(() => {});
}
} else {
setStatus('error');
}
})
.catch(() => setStatus('error'));
}, 0);
};
useEffect(() => {
if (!hasPaid || savedInterpretation || called.current) return;
if (!hasPaid || validSaved || called.current) return;
called.current = true;
setStatus('loading');
@@ -248,7 +300,7 @@ export default function SajuAISection({
<h3 className="text-xl font-extrabold text-white mb-2">AI (12 )</h3>
<p className="text-blue-200/60 text-sm mb-6">
, , , , , <br />
Claude AI .
Gemini 2.5 Pro .
</p>
{/* 미리보기 섹션 목록 */}
@@ -320,9 +372,21 @@ export default function SajuAISection({
<h2 className="text-sm font-extrabold text-white">AI </h2>
<p className="text-blue-300/60 text-[11px]">12 · </p>
</div>
<span className="text-xs bg-emerald-400/20 border border-emerald-400/30 text-emerald-300 font-bold px-2.5 py-1 rounded-full">
</span>
<div className="flex items-center gap-2">
<button
onClick={handleRegenerate}
title="AI 해석 재생성"
className="text-[11px] text-blue-300/60 hover:text-blue-200 px-2 py-1 rounded-lg hover:bg-white/10 transition-all flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span className="text-xs bg-emerald-400/20 border border-emerald-400/30 text-emerald-300 font-bold px-2.5 py-1 rounded-full">
</span>
</div>
</div>
{/* 섹션 컨트롤 + 목록 */}

View File

@@ -23,46 +23,6 @@ interface PageProps {
}>;
}
// Python 사주 엔진 호출 (실패 시 null 반환)
async function fetchFromPythonEngine(
year: number, month: number, day: number,
hour: number | null, gender: string
): Promise<{
saju: any; daeunList: any[]; currentDaeun: any;
interactions: any[]; shinsal: any[]; gongmang: any; hiddenStems: any[];
} | null> {
const url = process.env.SAJU_ENGINE_URL;
const secret = process.env.SAJU_ENGINE_SECRET;
if (!url) return null;
try {
const res = await fetch(`${url}/saju/calculate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(secret ? { 'X-API-Secret': secret } : {}),
},
body: JSON.stringify({ year, month, day, hour: hour ?? undefined, gender, calendar_type: 'solar' }),
signal: AbortSignal.timeout(10000),
cache: 'no-store',
});
if (!res.ok) return null;
const data = await res.json();
return {
saju: data.saju,
daeunList: data.daeunList,
currentDaeun: data.currentDaeun,
interactions: data.interactions,
shinsal: data.shinsal,
gongmang: data.gongmang,
hiddenStems: data.hiddenStems,
};
} catch {
console.warn('[사주] Python 엔진 연결 실패 — TypeScript 폴백 사용');
return null;
}
}
export default async function SajuResultPage({ searchParams }: PageProps) {
const params = await searchParams;
const { year, month, day, hour, gender, calendarType, originalYear, originalMonth, originalDay, isLeapMonth } = params;
@@ -89,29 +49,26 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
const isLunar = calendarType === 'lunar';
const isLeap = isLeapMonth === 'true';
// ── Python 엔진 호출 (폴백: TypeScript) ──────────────────────────────
const engineResult = await fetchFromPythonEngine(yearNum, monthNum, dayNum, hourNum, gender);
// ── 사주팔자 계산 (TypeScript — lunar-javascript 기반 정밀 절기 계산) ──
const sajuData = calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
// 사주팔자 (Python 엔진 우선, TS 폴백)
const sajuData = engineResult?.saju ?? calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
// 추가 분석 (신강신약, 용신, 오행균형, 세운) — TypeScript 계산 유지
// 추가 분석 (신강신약, 용신, 오행균형, 세운)
const analysis = performFullAnalysis(sajuData);
const elementScores = analysis.elementScores;
// 대운 (Python 엔진 우선, TS 폴백)
// 대운
const currentYear = new Date().getFullYear();
const daeunList = engineResult?.daeunList ?? calculateDaeun(
const daeunList = calculateDaeun(
yearNum, monthNum, dayNum, gender,
sajuData.month.stem, sajuData.month.branch
);
const currentDaeun = engineResult?.currentDaeun ?? getCurrentDaeun(daeunList, currentYear);
const currentDaeun = getCurrentDaeun(daeunList, currentYear);
// 지지 상호작용 / 신살 / 공망 / 지장간 (Python 엔진 우선, TS 폴백)
const branchInteractions = engineResult?.interactions ?? analysis.branchInteractions;
const shinsal = engineResult?.shinsal ?? analysis.shinsal;
const gongmang = engineResult?.gongmang ?? analysis.gongmang;
const hiddenStems = engineResult?.hiddenStems ?? analysis.hiddenStems;
// 지지 상호작용 / 신살 / 공망 / 지장간
const branchInteractions = analysis.branchInteractions;
const shinsal = analysis.shinsal;
const gongmang = analysis.gongmang;
const hiddenStems = analysis.hiddenStems;
// ── 절기 정보 (표시용) ────────────────────────────────────────────────
const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
@@ -187,9 +144,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
const zodiacIdx = (yearNum - 4) % 12;
const zodiacAnimal = zodiacAnimals[zodiacIdx >= 0 ? zodiacIdx : zodiacIdx + 12];
const engineBadge = engineResult
? <span className="text-[10px] bg-emerald-50 border border-emerald-200 text-emerald-700 px-2 py-0.5 rounded-full font-semibold">Python </span>
: <span className="text-[10px] bg-slate-100 border border-slate-200 text-slate-500 px-2 py-0.5 rounded-full">TS </span>;
const engineBadge = <span className="text-[10px] bg-blue-50 border border-blue-200 text-blue-600 px-2 py-0.5 rounded-full font-semibold">TS </span>;
return (
<div className="min-h-full bg-[#f0f5ff]">
@@ -583,12 +538,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
gender={gender}
birthKey={birthKey}
currentUrl={currentUrl}
engineData={engineResult ? {
interactions: engineResult.interactions,
shinsal: engineResult.shinsal,
gongmang: engineResult.gongmang,
hiddenStems: engineResult.hiddenStems,
} : undefined}
engineData={undefined}
/>
);
})()}