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

@@ -1,18 +1,25 @@
import { NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createSajuPrompt } from '@/lib/saju-ai-prompt';
import { performFullAnalysis } from '@/lib/ai-interpretation';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
export const maxDuration = 60;
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
const MOCK_INTERPRETATION = `
## 1. 일간 분석과 타고난 기질
(AI 해석 서비스를 이용하려면 API 설정이 필요합니다. 아래는 예시 데이터입니다.)
(GEMINI_API_KEY 환경변수를 설정하고 서버를 재시작하면 실제 AI 해석을 받을 수 있습니다.)
귀하는 **갑목(甲木)** 일간으로 태어나, 마치 곧게 뻗은 소나무와 같은 기상을 지니고 있다. 리더십이 강하고 추진력이 뛰어나며, 한번 마음먹은 일은 끝까지 해내는 뚝심이 있다.
## 2. 오행 균형과 용신 기반 개운법
사주에서 **화(火)** 기운이 부족하여 표현력이 다소 약할 수 있다. 붉은색 계통의 옷이나 소품을 활용하고, 밝은 곳에서 활동하는 것이 운을 트이게 한다.
사주에서 **화(火)** 기운이 부족하여 표현력이 다소 약할 수 있다.
## 3. 지지 상호작용 해석
지지 간의 상호작용을 살펴보면, 특별한 합충형이 발견된다.
@@ -21,22 +28,22 @@ const MOCK_INTERPRETATION = `
역마살이 사주에 자리하고 있어 이동과 변동이 많은 삶을 살게 된다.
## 5. 재물운과 금전 흐름
재물창고인 **진토(辰土)**를 깔고 있어 기본적으로 재복은 타고났다. 다만, 돈을 버는 것보다 지키는 힘이 약할 수 있으니 저축 습관이 중요하다.
재물창고인 **진토(辰土)**를 깔고 있어 기본적으로 재복은 타고났다.
## 6. 직업 적성과 진로
교육, 출판, 건축, 디자인 등 창조적이고 독립적인 분야에서 두각을 나타낼 수 있다.
## 7. 애정운과 결혼
자존심이 강해 상대방에게 굽히지 않으려는 성향이 있다. 배우자와의 관계에서는 조금 더 부드러운 태도가 필요하다.
자존심이 강해 상대방에게 굽히지 않으려는 성향이 있다.
## 8. 건강운
간, 담낭, 신경계 통증에 유의해야 한다. 스트레스를 받으면 뭉치는 경향이 있으니 스트레칭과 요가를 추천한다.
간, 담낭, 신경계 통증에 유의해야 한다.
## 9. 현재 대운의 흐름과 기회/위기
현재 대운은 인생의 전환점이다. 새로운 것을 시작하기보다는 기존의 것을 다지고 내실을 기하는 시기이다.
현재 대운은 인생의 전환점이다.
## 10. 올해의 세운 분석
올해는 귀인의 도움을 받을 수 있는 해이다. 주저하지 말고 주변에 도움을 요청하라.
올해는 귀인의 도움을 받을 수 있는 해이다.
## 11. 인생의 황금기 예측
40대 중반부터 50대 초반까지 인생의 가장 화려한 시기를 맞이할 것으로 보인다.
@@ -45,6 +52,16 @@ const MOCK_INTERPRETATION = `
"서두르지 않아도 봄은 온다." 조급해하지 말고 때를 기다리는 지혜가 필요하다.
`;
// 모델 우선순위 — 강력한 순서 (이 API 키로 접근 가능한 모델만)
// gemini-2.5-pro: 최고 품질, 가장 강력한 추론력
// gemini-2.5-flash: 빠르고 강력한 2순위
// gemini-2.0-flash: 안정적인 폴백
const MODELS = [
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
] as const;
export async function POST(request: Request) {
try {
const { saju, daeun, daeunList, gender, engineData } = await request.json();
@@ -54,56 +71,77 @@ export async function POST(request: Request) {
try {
analysis = performFullAnalysis(saju);
} catch (analysisError: any) {
console.error('Analysis calculation error:', analysisError.message);
console.error('[사주] 분석 계산 오류:', analysisError.message);
return NextResponse.json(
{ error: '사주 분석 계산 중 오류가 발생했습니다: ' + analysisError.message },
{ error: '사주 분석 계산 중 오류: ' + analysisError.message },
{ status: 500 }
);
}
if (!process.env.ANTHROPIC_API_KEY) {
console.warn('Anthropic API Key is missing — returning mock data');
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.warn('[사주] GEMINI_API_KEY 미설정 — 예시 데이터 반환');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const prompt = createSajuPrompt(saju, daeun, gender, analysis, daeunList || [], engineData);
const genAI = new GoogleGenerativeAI(apiKey);
console.log('Generating saju analysis with claude-sonnet-4-6...');
// createSajuPrompt 반환값 = 시스템 지시문 (데이터 + 출력 요구사항 포함)
const systemInstruction = createSajuPrompt(saju, daeun, gender, analysis, daeunList || [], engineData);
// 유저 트리거 메시지 (Gemini는 systemInstruction + user 메시지 구조 필요)
const userMessage = '위 사주 데이터를 바탕으로 12개 항목의 상세 해석을 작성해주세요. 각 항목은 ## 1. ~ ## 12. 형식으로 작성하세요.';
let interpretation: string | null = null;
try {
const message = await client.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 8192,
temperature: 0.75,
messages: [{ role: 'user', content: prompt }],
});
const block = message.content[0];
if (block.type === 'text') {
interpretation = block.text;
}
console.log('Successfully generated saju analysis with claude-sonnet-4-6');
} catch (claudeError: any) {
// claude-sonnet-4-6 실패 시 claude-haiku-4-5 폴백
console.warn('claude-sonnet-4-6 failed:', claudeError.message, '— trying haiku fallback');
for (const { id: modelId, maxTokens } of MODELS) {
try {
const fallback = await client.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 4096,
temperature: 0.75,
messages: [{ role: 'user', content: prompt }],
console.log(`[사주] ${modelId} 로 해석 생성 중...`);
const model = genAI.getGenerativeModel({
model: modelId,
systemInstruction, // ← 시스템 프롬프트 분리 (핵심 수정)
generationConfig: {
temperature: 0.8,
topP: 0.95,
maxOutputTokens: maxTokens,
},
});
const block = fallback.content[0];
if (block.type === 'text') {
interpretation = block.text;
const result = await model.generateContent(userMessage);
const text = result.response.text();
if (!text || text.trim().length < 100) {
throw new Error('응답이 너무 짧거나 비어있습니다');
}
console.log('Fallback to claude-haiku-4-5 succeeded');
} catch (haikusError: any) {
console.error('Both Claude models failed:', haikusError.message);
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
interpretation = text;
console.log(`[사주] ${modelId} 성공 — ${text.length}자 생성됨`);
break;
} catch (modelError: any) {
const msg = modelError.message ?? String(modelError);
console.error(`[사주] ${modelId} 실패:`, msg);
// API 키 / 권한 오류 → 즉시 mock 반환
if (
msg.includes('API_KEY') ||
msg.includes('PERMISSION_DENIED') ||
msg.includes('API key') ||
modelError.status === 401 ||
modelError.status === 403
) {
console.warn('[사주] API 키 오류 — 예시 데이터 반환');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
// 마지막 모델도 실패
if (modelId === MODELS[MODELS.length - 1].id) {
console.error('[사주] 모든 모델 실패 — 예시 데이터 반환');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
console.log(`[사주] ${modelId} → 다음 모델로 폴백...`);
}
}
@@ -112,8 +150,9 @@ export async function POST(request: Request) {
}
return NextResponse.json({ interpretation, analysis });
} catch (error: any) {
console.error('Error generating saju interpretation:', error.message || error);
console.error('[사주] 전체 오류:', error.message || error);
return NextResponse.json(
{ error: error.message || 'Failed to generate interpretation' },
{ status: 500 }

View File

@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
export async function POST(request: NextRequest) {
if (!SAJU_ENGINE_URL) {
return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
}
try {
const body = await request.json();
const response = await fetch(`${SAJU_ENGINE_URL}/saju/calculate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000), // 15초 타임아웃
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || '사주 계산 실패' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'TimeoutError') {
return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
}
console.error('사주 계산 프록시 오류:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}

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}
/>
);
})()}

View File

@@ -252,7 +252,9 @@ export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): Y
{ elem: producingMe, score: balance[producingMe as keyof ElementBalance], name: '인성' },
{ elem: dayElement, score: balance[dayElement as keyof ElementBalance], name: '비겁' },
];
candidates.sort((a, b) => a.score - b.score);
// 신약: 인성/비겁 중 사주에 더 강하게 존재하는 것이 실질적 용신
// (점수가 높을수록 사주에서 작용하는 힘이 강해 일간을 도울 수 있음)
candidates.sort((a, b) => b.score - a.score);
const yong = candidates[0];
const hee = candidates[1];

View File

@@ -42,17 +42,8 @@ function calculateDaeunStartAge(
const currentTerm = getCurrentSolarTerm(birthYear, birthMonth, birthDay);
const termDate = getSolarTermDate(birthYear, currentTerm);
let termYear = termDate.year;
let termMonth = termDate.month;
// 대한, 소한 처리
if (currentTerm >= 22 && birthMonth >= 2) {
termYear = birthYear;
} else if (currentTerm >= 22) {
termYear = birthYear - 1;
}
const termDateObj = new Date(termYear, termMonth - 1, termDate.day);
// getSolarTermDate가 소한(22)/대한(23)에 대해 birthYear 1월 날짜를 올바르게 반환
const termDateObj = new Date(termDate.year, termDate.month - 1, termDate.day);
const birthDateObj = new Date(birthYear, birthMonth - 1, birthDay);
const diffTime = birthDateObj.getTime() - termDateObj.getTime();

View File

@@ -45,11 +45,32 @@ const BASE_YEAR_BRANCH = 0; // 子
/**
* 년도의 간지를 계산
* month, day를 전달하면 입춘(立春) 기준으로 전년도 년주를 적용합니다.
*/
export function getYearGanzi(year: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
const yearDiff = year - BASE_YEAR;
const stemIndex = (BASE_YEAR_STEM + yearDiff) % 10;
const branchIndex = (BASE_YEAR_BRANCH + yearDiff) % 12;
export function getYearGanzi(year: number, month?: number, day?: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
let adjustedYear = year;
// 입춘(立春) 이전 출생이면 전년도 년주 사용
if (month !== undefined && day !== undefined) {
try {
const { getSolarTermDate } = require('./solar-terms');
const ipchun = getSolarTermDate(year, 0); // termIndex 0 = 입춘
const birthUTC = Date.UTC(year, month - 1, day);
const ipchunUTC = Date.UTC(year, ipchun.month - 1, ipchun.day);
if (birthUTC < ipchunUTC) {
adjustedYear = year - 1;
}
} catch {
// 절기 계산 실패 시 양력 2월 4일을 입춘 근사값으로 사용
if (month === 1 || (month === 2 && day < 4)) {
adjustedYear = year - 1;
}
}
}
const yearDiff = adjustedYear - BASE_YEAR;
const stemIndex = ((BASE_YEAR_STEM + yearDiff) % 10 + 10) % 10;
const branchIndex = ((BASE_YEAR_BRANCH + yearDiff) % 12 + 12) % 12;
return {
stem: HEAVENLY_STEMS[stemIndex],
@@ -67,12 +88,14 @@ export function getMonthGanzi(year: number, month: number, day: number): { stem:
const { getSolarTermMonthBranch } = require('./solar-terms');
const branchIndex = getSolarTermMonthBranch(year, month, day);
// 월 천간 계산 (년간에 따라 달라짐)
const yearStem = getYearGanzi(year).stem;
// 월 천간 계산 — 입춘 보정된 년간 사용
const yearStem = getYearGanzi(year, month, day).stem;
const yearStemIndex = HEAVENLY_STEMS.indexOf(yearStem as any);
// 월 천간 공식: (년간 * 2 + 월지지) % 10
const stemIndex = (yearStemIndex * 2 + branchIndex) % 10;
// 오호둔월법 (五虎遁月法): 寅月(branchIndex=2)을 기준으로 년간별 시작 천간 결정
// 甲/己년: 寅月=丙(2), 乙/庚년: 寅月=戊(4), 丙/辛년: 寅月=庚(6), 丁/壬년: 寅月=壬(8), 戊/癸년: 寅月=甲(0)
const startStem = ((yearStemIndex % 5) * 2 + 2) % 10;
const stemIndex = (startStem + (branchIndex - 2 + 12) % 12) % 10;
return {
stem: HEAVENLY_STEMS[stemIndex],
@@ -86,14 +109,14 @@ export function getMonthGanzi(year: number, month: number, day: number): { stem:
* 일의 간지를 계산 (만세력 기준)
*/
export function getDayGanzi(year: number, month: number, day: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
// 기준일 (1900-01-01) 부터의 일수 계산
const baseDate = new Date(1900, 0, 1);
const targetDate = new Date(year, month - 1, day);
const daysDiff = Math.floor((targetDate.getTime() - baseDate.getTime()) / (1000 * 60 * 60 * 24));
// UTC 기준으로 일수 계산 (로컬 타임존/DST 영향 제거)
const baseUTC = Date.UTC(1900, 0, 1);
const targetUTC = Date.UTC(year, month - 1, day);
const daysDiff = Math.floor((targetUTC - baseUTC) / (1000 * 60 * 60 * 24));
// 1900-01-01 = 丙寅일
const baseDayStem = 2; //
const baseDayBranch = 2; //
// 1900-01-01 = 甲戌일 (60갑자 기준, JDN+49 공식 검증)
const baseDayStem = 0; //
const baseDayBranch = 10; //
const stemIndex = (baseDayStem + daysDiff) % 10;
const branchIndex = (baseDayBranch + daysDiff) % 12;
@@ -223,7 +246,7 @@ export function calculateSaju(
hour: number | null,
gender: 'male' | 'female'
): SajuData {
const yearGanzi = getYearGanzi(year);
const yearGanzi = getYearGanzi(year, month, day);
const monthGanzi = getMonthGanzi(year, month, day);
const dayGanzi = getDayGanzi(year, month, day);
const hourGanzi = hour !== null ? getHourGanzi(dayGanzi, hour) : null;

View File

@@ -1,7 +1,8 @@
/**
* 24절기 계산
* 24절기 계산 — lunar-javascript 라이브러리 기반 (정밀 천문학 계산)
* 사주 계산에서 월주는 절기를 기준으로 합니다.
*/
import { LunarYear, Solar } from 'lunar-javascript';
// 24절기 (입춘부터 시작)
export const SOLAR_TERMS = [
@@ -36,184 +37,110 @@ interface SolarTermDate {
}
/**
* 정밀한 절기 계산 (천문학적 계산 기반)
* solarlunar 라이브러리 사용
* 정밀한 절기 날짜 계산 (lunar-javascript 기반)
*
* termIndex 매핑 (0~23):
* 0=입춘, 1=우수, ..., 21=동지, 22=소한, 23=대한
*
* LunarYear.fromYear(y).getJieQiJulianDays() 인덱스 구조:
* [0]=大雪(y-1), [1]=冬至(y-1), [2]=小寒(y), [3]=大寒(y),
* [4]=立春(y) ← termIndex 0
* [5]=雨水(y) ← termIndex 1
* ...
* [25]=冬至(y) ← termIndex 21
* [26]=小寒(y+1) ← termIndex 22
* [27]=大寒(y+1) ← termIndex 23
*
* 반환 규칙:
* getSolarTermDate(year, 0~21) → year 내 절기 날짜
* getSolarTermDate(year, 22~23) → year 1월의 소한/대한 날짜
* (내부적으로 LunarYear.fromYear(year - 1) 사용)
*/
export function getSolarTermDate(year: number, termIndex: number): SolarTermDate {
try {
const solarLunar = require('solarlunar');
// 소한(22)/대한(23)은 해당 연도 1월에 위치.
// LunarYear.fromYear(y)[26/27]은 y+1년 1월을 반환하므로
// year의 1월 소한/대한을 얻으려면 year-1로 조회.
const lunarYear = termIndex >= 22 ? year - 1 : year;
const jds = LunarYear.fromYear(lunarYear).getJieQiJulianDays();
const jd = jds[termIndex + 4];
const solar = Solar.fromJulianDay(jd);
// solarlunar의 절기 데이터 가져오기
// 각 년도의 절기 정보를 계산
const termNames = [
'立春', '雨水', '驚蟄', '春分', '清明', '穀雨',
'立夏', '小滿', '芒種', '夏至', '小暑', '大暑',
'立秋', '處暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
];
// 해당 년도의 절기 찾기
// solarlunar는 양력 날짜로 절기 확인 가능
// 각 절기의 대략적인 날짜 범위에서 검색
const searchRanges = [
{ month: 2, startDay: 3, endDay: 5 }, // 입춘
{ month: 2, startDay: 18, endDay: 20 }, // 우수
{ month: 3, startDay: 5, endDay: 7 }, // 경칩
{ month: 3, startDay: 20, endDay: 22 }, // 춘분
{ month: 4, startDay: 4, endDay: 6 }, // 청명
{ month: 4, startDay: 19, endDay: 21 }, // 곡우
{ month: 5, startDay: 5, endDay: 7 }, // 입하
{ month: 5, startDay: 20, endDay: 22 }, // 소만
{ month: 6, startDay: 5, endDay: 7 }, // 망종
{ month: 6, startDay: 20, endDay: 22 }, // 하지
{ month: 7, startDay: 6, endDay: 8 }, // 소서
{ month: 7, startDay: 22, endDay: 24 }, // 대서
{ month: 8, startDay: 7, endDay: 9 }, // 입추
{ month: 8, startDay: 22, endDay: 24 }, // 처서
{ month: 9, startDay: 7, endDay: 9 }, // 백로
{ month: 9, startDay: 22, endDay: 24 }, // 추분
{ month: 10, startDay: 7, endDay: 9 }, // 한로
{ month: 10, startDay: 23, endDay: 24 },// 상강
{ month: 11, startDay: 7, endDay: 8 }, // 입동
{ month: 11, startDay: 21, endDay: 23 },// 소설
{ month: 12, startDay: 6, endDay: 8 }, // 대설
{ month: 12, startDay: 21, endDay: 23 },// 동지
{ month: 1, startDay: 5, endDay: 7 }, // 소한
{ month: 1, startDay: 19, endDay: 21 }, // 대한
];
const range = searchRanges[termIndex];
const termName = termNames[termIndex];
// 해당 범위 내에서 절기 찾기
for (let day = range.startDay; day <= range.endDay; day++) {
const lunar = solarLunar.solar2lunar(year, range.month, day);
if (lunar && lunar.term === termName) {
return {
year,
month: range.month,
day,
hour: 0,
minute: 0
};
}
}
// 찾지 못한 경우 중간값 사용
const midDay = Math.floor((range.startDay + range.endDay) / 2);
return {
year,
month: range.month,
day: midDay,
hour: 0,
minute: 0
};
} catch (error) {
console.error('절기 계산 오류:', error);
// 폴백: 기존 근사값 사용
const baseMonth = [
2, 2, 3, 3, 4, 4,
5, 5, 6, 6, 7, 7,
8, 8, 9, 9, 10, 10,
11, 11, 12, 12, 1, 1
];
const baseDay = [
4, 19, 5, 20, 4, 20,
5, 21, 6, 21, 7, 23,
7, 23, 8, 23, 8, 23,
7, 22, 7, 22, 5, 20
];
return {
year,
month: baseMonth[termIndex],
day: baseDay[termIndex],
hour: 0,
minute: 0
};
}
return {
year: solar.getYear(),
month: solar.getMonth(),
day: solar.getDay(),
hour: solar.getHour(),
minute: solar.getMinute(),
};
}
/**
* 주어진 날짜가 어느 절기 이후인지 확인
* @param year 년
* @param month 월
* @param day 일
* @returns 절기 인덱스 (0~23)
*/
export function getCurrentSolarTerm(year: number, month: number, day: number): number {
const date = new Date(year, month - 1, day);
const dateValue = date.getTime();
const dateValue = Date.UTC(year, month - 1, day);
// 각 절기 날짜 확인
for (let i = 23; i >= 0; i--) {
const termDate = getSolarTermDate(year, i);
let termYear = termDate.year;
let termMonth = termDate.month;
const ipchunData = getSolarTermDate(year, 0);
const ipchunValue = Date.UTC(ipchunData.year, ipchunData.month - 1, ipchunData.day);
// 대한, 소한은 이전 해 처리
if (i >= 22 && month >= 2) {
termYear = year;
} else if (i >= 22) {
termYear = year - 1;
if (dateValue >= ipchunValue) {
// 입춘 이후: 동지(21)→입춘(0) 역순 검색
for (let i = 21; i >= 0; i--) {
const td = getSolarTermDate(year, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
const term = new Date(termYear, termMonth - 1, termDate.day);
if (dateValue >= term.getTime()) {
return i;
return 0;
} else {
// 입춘 이전 (1월 또는 2월 초): 이 해의 소한(22)/대한(23) 먼저 확인
for (let i = 23; i >= 22; i--) {
const td = getSolarTermDate(year, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
// 전년도 동지(21)→입춘(0) 역순 검색
for (let i = 21; i >= 0; i--) {
const td = getSolarTermDate(year - 1, i);
const termValue = Date.UTC(td.year, td.month - 1, td.day);
if (dateValue >= termValue) return i;
}
return 23;
}
// 입춘 이전이면 전년도 대한 이후
return 23;
}
/**
* 절기 기준 월주 지지 인덱스 계산
* @param year 년
* @param month 월
* @param day 일
* @returns 지지 인덱스 (0: 자, 1: 축, 2: 인, ...)
*/
export function getSolarTermMonthBranch(year: number, month: number, day: number): number {
const termIndex = getCurrentSolarTerm(year, month, day);
// 절기 인덱스를 월로 변환
// 입춘(0) -> 인월(2)
// 경칩(2) -> 묘월(3)
// 청명(4) -> 진월(4)
// ...
const monthBranches = [
2, // 입춘 -> 인월
2, // 우수 -> 인월
3, // 경칩 -> 묘월
3, // 춘분 -> 묘월
4, // 청명 -> 진월
4, // 곡우 -> 진월
5, // 입하 -> 사월
5, // 소만 -> 사월
6, // 망종 -> 오월
6, // 하지 -> 오월
7, // 소서 -> 미월
7, // 대서 -> 미월
8, // 입추 -> 신월
8, // 처서 -> 신월
9, // 백로 -> 유월
9, // 추분 -> 유월
10, // 한로 -> 술월
10, // 상강 -> 술월
11, // 입동 -> 해월
11, // 소설 -> 해월
0, // 대설 -> 자월
0, // 동지 -> 자월
1, // 소한 -> 축월
1, // 대한 -> 축월
2, // 입춘 인월
2, // 우수 인월
3, // 경칩 묘월
3, // 춘분 묘월
4, // 청명 진월
4, // 곡우 진월
5, // 입하 사월
5, // 소만 사월
6, // 망종 오월
6, // 하지 오월
7, // 소서 미월
7, // 대서 미월
8, // 입추 신월
8, // 처서 신월
9, // 백로 유월
9, // 추분 유월
10, // 한로 술월
10, // 상강 술월
11, // 입동 해월
11, // 소설 해월
0, // 대설 자월
0, // 동지 자월
1, // 소한 축월
1, // 대한 축월
];
return monthBranches[termIndex];
@@ -234,10 +161,8 @@ export function getDaysToNextSolarTerm(year: number, month: number, day: number)
const currentTerm = getCurrentSolarTerm(year, month, day);
const nextTermIndex = (currentTerm + 1) % 24;
let nextYear = year;
if (currentTerm === 23) {
nextYear = year + 1;
}
// 대한(23) 다음은 입춘(0) — 다음 연도
const nextYear = currentTerm === 23 ? year + 1 : year;
const nextTerm = getSolarTermDate(nextYear, nextTermIndex);
const nextDate = new Date(nextTerm.year, nextTerm.month - 1, nextTerm.day);

30
package-lock.json generated
View File

@@ -9,9 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.79.0",
"@google/generative-ai": "^0.24.1",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.99.0",
"@tosspayments/tosspayments-sdk": "^2.6.0",
"dotenv": "^17.3.1",
"lunar-javascript": "^1.7.7",
"next": "16.1.6",
"openai": "^6.21.0",
"react": "19.2.3",
@@ -491,6 +494,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@google/generative-ai": {
"version": "0.24.1",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3192,6 +3204,18 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5549,6 +5573,12 @@
"yallist": "^3.0.2"
}
},
"node_modules/lunar-javascript": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@@ -10,9 +10,12 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.79.0",
"@google/generative-ai": "^0.24.1",
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.99.0",
"@tosspayments/tosspayments-sdk": "^2.6.0",
"dotenv": "^17.3.1",
"lunar-javascript": "^1.7.7",
"next": "16.1.6",
"openai": "^6.21.0",
"react": "19.2.3",

23
types/lunar-javascript.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
declare module 'lunar-javascript' {
class Solar {
static fromYmd(year: number, month: number, day: number): Solar;
static fromJulianDay(julianDay: number): Solar;
getYear(): number;
getMonth(): number;
getDay(): number;
getHour(): number;
getMinute(): number;
getSecond(): number;
}
class LunarYear {
static fromYear(year: number): LunarYear;
/** Returns 31 Julian Day Numbers for the 24 solar terms of the year */
getJieQiJulianDays(): number[];
}
class Lunar {
static fromYmd(lunarYear: number, lunarMonth: number, lunarDay: number): Lunar;
getSolar(): Solar;
}
}