From 1193a075c2ecf5c400d9012ca6fdf5dac9f211e5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 19 Mar 2026 23:38:25 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=82=AC=EC=A3=BC=20Python=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EC=A0=9C=EA=B1=B0=20+=20lunar-javascript?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=20=EC=A0=88=EA=B8=B0=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/saju/analyze/route.ts | 127 ++++++++++------ app/api/saju/calculate/route.ts | 41 ----- app/saju/page.tsx | 1 - app/saju/result/SajuAISection.tsx | 80 +++++++++- app/saju/result/page.tsx | 76 ++-------- lib/ai-interpretation.ts | 4 +- lib/daeun-calculator.ts | 13 +- lib/saju-calculator.ts | 55 +++++-- lib/solar-terms.ts | 241 ++++++++++-------------------- package-lock.json | 30 ++++ package.json | 3 + types/lunar-javascript.d.ts | 23 +++ 12 files changed, 351 insertions(+), 343 deletions(-) delete mode 100644 app/api/saju/calculate/route.ts create mode 100644 types/lunar-javascript.d.ts diff --git a/app/api/saju/analyze/route.ts b/app/api/saju/analyze/route.ts index f05fa3c..870587f 100644 --- a/app/api/saju/analyze/route.ts +++ b/app/api/saju/analyze/route.ts @@ -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 } diff --git a/app/api/saju/calculate/route.ts b/app/api/saju/calculate/route.ts deleted file mode 100644 index ddc8a6f..0000000 --- a/app/api/saju/calculate/route.ts +++ /dev/null @@ -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 }); - } -} diff --git a/app/saju/page.tsx b/app/saju/page.tsx index 8c0b82b..bfb4742 100644 --- a/app/saju/page.tsx +++ b/app/saju/page.tsx @@ -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}`; diff --git a/app/saju/result/SajuAISection.tsx b/app/saju/result/SajuAISection.tsx index 15ca72a..f2d5384 100644 --- a/app/saju/result/SajuAISection.tsx +++ b/app/saju/result/SajuAISection.tsx @@ -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>(new Set([0])); // 첫 섹션 기본 열림 + const [interpretation, setInterpretation] = useState(validSaved ?? ''); + const [openSections, setOpenSections] = useState>(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({

AI 상세 해석 (12개 항목)

성격, 재물운, 직업 적성, 애정운, 건강운, 대운 분석 등
- Claude AI가 생성하는 맞춤형 사주 해석을 받아보세요. + Gemini 2.5 Pro가 생성하는 맞춤형 사주 해석을 받아보세요.

{/* 미리보기 섹션 목록 */} @@ -320,9 +372,21 @@ export default function SajuAISection({

AI 상세 해석

12개 항목 · 클릭해서 펼쳐보세요

- - 결제 완료 - +
+ + + 결제 완료 + +
{/* 섹션 컨트롤 + 목록 */} diff --git a/app/saju/result/page.tsx b/app/saju/result/page.tsx index 48cdad8..1fcb950 100644 --- a/app/saju/result/page.tsx +++ b/app/saju/result/page.tsx @@ -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 - ? Python 엔진 - : TS 폴백; + const engineBadge = TS 계산; return (
@@ -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} /> ); })()} diff --git a/lib/ai-interpretation.ts b/lib/ai-interpretation.ts index 85ffcc8..9a044dc 100644 --- a/lib/ai-interpretation.ts +++ b/lib/ai-interpretation.ts @@ -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]; diff --git a/lib/daeun-calculator.ts b/lib/daeun-calculator.ts index 8a6d94d..305f805 100644 --- a/lib/daeun-calculator.ts +++ b/lib/daeun-calculator.ts @@ -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(); diff --git a/lib/saju-calculator.ts b/lib/saju-calculator.ts index b5fcaaa..5f16274 100644 --- a/lib/saju-calculator.ts +++ b/lib/saju-calculator.ts @@ -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; diff --git a/lib/solar-terms.ts b/lib/solar-terms.ts index 9753e57..0d10756 100644 --- a/lib/solar-terms.ts +++ b/lib/solar-terms.ts @@ -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); diff --git a/package-lock.json b/package-lock.json index 53efcf4..aacce19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 60e4735..a917414 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/types/lunar-javascript.d.ts b/types/lunar-javascript.d.ts new file mode 100644 index 0000000..5f7bf80 --- /dev/null +++ b/types/lunar-javascript.d.ts @@ -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; + } +}