From 6533039fd7ab8d224cacefa19dcbb7b0bb5c8c77 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 23 Mar 2026 00:28:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=A3=BC=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20-=20=EC=98=A4=EB=8A=98=EC=9D=98=20=EC=9A=B4=EC=84=B8=20?= =?UTF-8?q?=EC=84=B9=EC=85=98=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20AI=20?= =?UTF-8?q?=EC=9E=AC=ED=98=B8=EC=B6=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SajuFortuneSection 신규 추가: 일진 기반 결정론적 오늘의 운세 (AI 불필요) - 1900-01-01 甲戌 기준 오늘의 일주 계산 (CLAUDE.md 검증 로직) - 용신·희신 오행과 일진 오행의 상생·상극으로 종합 점수 산출 - 재물/애정/직업/건강/사회 5대 운세 seededRand 결정론적 생성 - 사주 AI 섹션 → 오늘의 운세 → 로또 추천 순서로 자연스럽게 연결 - SajuLottoSection: id="saju-lotto-section" 추가 (운세 섹션 스크롤 대상) - page.tsx: savedInterpretation 2차 폴백 쿼리 추가 - birth_hour 불일치 시 시간 제외 키로 재조회 → 다시 보기 시 AI 재호출 방지 Co-Authored-By: Claude Sonnet 4.6 --- app/saju/result/SajuFortuneSection.tsx | 323 +++++++++++++++++++++++++ app/saju/result/SajuLottoSection.tsx | 2 +- app/saju/result/page.tsx | 26 ++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 app/saju/result/SajuFortuneSection.tsx diff --git a/app/saju/result/SajuFortuneSection.tsx b/app/saju/result/SajuFortuneSection.tsx new file mode 100644 index 0000000..8607d95 --- /dev/null +++ b/app/saju/result/SajuFortuneSection.tsx @@ -0,0 +1,323 @@ +'use client'; + +import { useMemo } from 'react'; + +// ── 천간 / 지지 ─────────────────────────────────────────────────────── +const STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸']; +const STEMS_KR = ['갑','을','병','정','무','기','경','신','임','계']; +const BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥']; +const BRANCHES_KR= ['자','축','인','묘','진','사','오','미','신','유','술','해']; + +const STEM_ELEM: Record = { '甲':'木','乙':'木','丙':'火','丁':'火','戊':'土','己':'土','庚':'金','辛':'金','壬':'水','癸':'水' }; +const BRANCH_ELEM: Record = { '子':'水','亥':'水','寅':'木','卯':'木','巳':'火','午':'火','申':'金','酉':'金','丑':'土','辰':'土','未':'土','戌':'土' }; + +// 1900-01-01 = 甲戌 (stem=0, branch=10) — CLAUDE.md 검증 완료 +const BASE_MS = Date.UTC(1900, 0, 1); + +function getTodayPillar() { + const now = new Date(); + const todayMs = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + const diff = Math.round((todayMs - BASE_MS) / 86400000); + const si = ((0 + diff) % 10 + 10) % 10; + const bi = ((10 + diff) % 12 + 12) % 12; + return { + stem: STEMS[si], stemKr: STEMS_KR[si], + branch: BRANCHES[bi], branchKr: BRANCHES_KR[bi], + stemElem: STEM_ELEM[STEMS[si]] ?? '木', + branchElem: BRANCH_ELEM[BRANCHES[bi]] ?? '水', + year: now.getFullYear(), month: now.getMonth() + 1, date: now.getDate(), + }; +} + +// ── 오행 상생·상극 ──────────────────────────────────────────────────── +const GENERATES: Record = { '木':'火','火':'土','土':'金','金':'水','水':'木' }; +const OVERCOMES: Record = { '木':'土','火':'金','土':'水','金':'木','水':'火' }; + +type Rel = 'same'|'generates'|'generated'|'overcomes'|'overcome'|'neutral'; +function getRelation(a: string, b: string): Rel { + if (a === b) return 'same'; + if (GENERATES[a] === b) return 'generates'; + if (GENERATES[b] === a) return 'generated'; + if (OVERCOMES[a] === b) return 'overcomes'; + if (OVERCOMES[b] === a) return 'overcome'; + return 'neutral'; +} + +// ── 오늘 종합 점수 (0–100) ──────────────────────────────────────────── +function calcOverallScore(stemElem: string, branchElem: string, yongShin: string, heeShin: string) { + let score = 50; + const add = (rel: Rel, weight: number) => { + if (rel === 'same') score += 25 * weight; + else if (rel === 'generates' || rel === 'generated') score += 15 * weight; + else if (rel === 'overcomes') score -= 20 * weight; + else if (rel === 'overcome') score -= 8 * weight; + }; + add(getRelation(stemElem, yongShin), 1); + add(getRelation(branchElem, yongShin), 0.8); + add(getRelation(stemElem, heeShin), 0.3); + add(getRelation(branchElem, heeShin), 0.2); + return Math.round(Math.max(10, Math.min(100, score))); +} + +type Level = 'great'|'good'|'neutral'|'caution'; +function toLevel(s: number): Level { + if (s >= 78) return 'great'; + if (s >= 58) return 'good'; + if (s >= 38) return 'neutral'; + return 'caution'; +} + +// ── 결정론적 랜덤 ──────────────────────────────────────────────────── +function seededRand(seed: number) { + let s = seed; + return () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; }; +} + +// ── 운세 항목 빌드 ──────────────────────────────────────────────────── +type Area = { icon: string; label: string; score: number; desc: string }; + +const DESCS: Record> = { + money: { + great: '재물 흐름이 활발합니다. 작은 투자나 구매 결정에 긍정적인 시기입니다.', + good: '재물 운이 순조롭습니다. 무리하지 않는 범위에서 움직이면 이익이 납니다.', + neutral: '수입·지출이 균형을 이루는 날. 큰 결정은 잠시 미루세요.', + caution: '충동 지출에 주의하세요. 중요한 금전 거래는 신중히 검토하세요.', + }, + love: { + great: '감정 교류가 잘 이루어지는 날. 마음을 전하기 좋은 타이밍입니다.', + good: '관계에 따뜻한 기운이 감돕니다. 오래 연락 못 했던 사람에게 먼저 다가가 보세요.', + neutral: '평온한 관계를 유지하는 날입니다. 억지로 변화를 만들 필요 없습니다.', + caution: '오해가 생기기 쉬운 날입니다. 중요한 대화는 감정이 차분해진 후에 하세요.', + }, + career: { + great: '능력이 잘 발휘되는 날. 중요한 프레젠테이션이나 면담에 최적입니다.', + good: '업무 효율이 올라가는 날입니다. 오늘 마무리한 과제는 좋은 결과로 이어집니다.', + neutral: '꾸준히 하던 일을 이어가는 날. 새 프로젝트보다 마무리에 집중하세요.', + caution: '실수가 생기기 쉬운 날입니다. 중요한 결재·계약은 하루 늦춰보세요.', + }, + health: { + great: '체력·집중력 모두 좋은 날. 평소보다 활동량을 늘려도 괜찮습니다.', + good: '컨디션이 안정적입니다. 가벼운 운동으로 기운을 더 끌어올리세요.', + neutral: '무리하지 않는 것이 최선. 충분한 수분과 수면을 챙겨주세요.', + caution: '피로가 쌓이기 쉬운 날입니다. 무리한 약속은 피하고 충분히 쉬세요.', + }, + social: { + great: '대인관계 운이 열린 날. 중요한 만남·협상에 유리한 시기입니다.', + good: '사교적 기운이 넘칩니다. 새 인맥을 만들거나 협업을 제안해보세요.', + neutral: '조용히 자신의 일에 집중하는 날. 복잡한 인간관계는 잠시 내려놓으세요.', + caution: '갈등이 생기기 쉬운 날입니다. 중요한 협상은 다음 기회로 미루는 것이 현명합니다.', + }, +}; + +function buildAreas( + overall: number, + yongShin: string, heeShin: string, + yearNum: number, monthNum: number, dayNum: number, +): Area[] { + const now = new Date(); + const seed = yearNum * 1_000_000 + monthNum * 10_000 + dayNum * 100 + now.getFullYear() % 100 * 10 + now.getMonth(); + const rand = seededRand(seed); + const roll = () => Math.round(Math.max(15, Math.min(98, rand() * 40 + overall - 20))); + const keys = ['money','love','career','health','social'] as const; + const icons = ['💰','💕','🎯','🌿','🤝']; + const labels = ['재물운','애정운','직업운','건강운','사회운']; + return keys.map((k, i) => { + const s = roll(); + return { icon: icons[i], label: labels[i], score: s, desc: DESCS[k][toLevel(s)] }; + }); +} + +// ── 레벨별 색상/라벨 ───────────────────────────────────────────────── +const LEVEL_META: Record = { + great: { emoji:'🌟', label:'아주 좋은 날', bar:'#f59e0b', bg:'bg-amber-50', border:'border-amber-300', text:'text-amber-800', badge:'bg-amber-100 text-amber-700 border-amber-300' }, + good: { emoji:'✨', label:'좋은 날', bar:'#22c55e', bg:'bg-emerald-50',border:'border-emerald-300',text:'text-emerald-800',badge:'bg-emerald-100 text-emerald-700 border-emerald-300' }, + neutral: { emoji:'🌤️', label:'평온한 날', bar:'#64748b', bg:'bg-slate-50', border:'border-slate-200', text:'text-slate-700', badge:'bg-slate-100 text-slate-600 border-slate-200' }, + caution: { emoji:'⚠️', label:'조심하는 날', bar:'#f97316', bg:'bg-orange-50', border:'border-orange-300',text:'text-orange-800', badge:'bg-orange-100 text-orange-700 border-orange-300' }, +}; + +const REL_DESC: (yongShin: string, yongShinKr: string) => Record = (y, yk) => ({ + same: `오늘 기운이 당신의 용신 ${y}(${yk})과 같은 오행으로 강하게 공명합니다.`, + generates: `오늘 기운이 용신 ${y}(${yk})을 생(生)해줍니다. 순조롭게 힘이 실리는 날.`, + generated: `용신 ${y}(${yk})이 오늘 기운을 생(生)해주고 있어 에너지를 베풀기 좋은 날입니다.`, + overcomes: `오늘 기운이 용신 ${y}(${yk})을 극(克)합니다. 신중하게 움직이는 것이 좋습니다.`, + overcome: `용신 ${y}(${yk})이 오늘 기운을 극(克)합니다. 주도적으로 판단하기 좋은 날.`, + neutral: `오늘 기운과 용신 ${y}(${yk})은 독립적으로 작용합니다. 차분하게 나아가세요.`, +}); + +// ── 점수 바 ────────────────────────────────────────────────────────── +function ScoreBar({ score, color }: { score: number; color: string }) { + return ( +
+
+
+
+ {score} +
+ ); +} + +// ── 메인 컴포넌트 ───────────────────────────────────────────────────── +interface Props { + yongShin: string; + yongShinKr: string; + heeShin: string; + heeShinKr: string; + yearNum: number; + monthNum: number; + dayNum: number; + hasLottoSubscription: boolean; +} + +export default function SajuFortuneSection({ + yongShin, yongShinKr, heeShin, heeShinKr, + yearNum, monthNum, dayNum, + hasLottoSubscription, +}: Props) { + const today = useMemo(getTodayPillar, []); + const overall = useMemo(() => calcOverallScore(today.stemElem, today.branchElem, yongShin, heeShin), [today, yongShin, heeShin]); + const level = toLevel(overall); + const meta = LEVEL_META[level]; + const areas = useMemo(() => buildAreas(overall, yongShin, heeShin, yearNum, monthNum, dayNum), [overall, yongShin, heeShin, yearNum, monthNum, dayNum]); + const stemRel = getRelation(today.stemElem, yongShin); + const relDesc = REL_DESC(yongShin, yongShinKr)[stemRel]; + + return ( + <> + {/* ── 상단 연결 화살표 ── */} +
+
+
+ 사주 분석에서 이어지는 오늘의 운세 +
+
+
+ + {/* ── 본문 카드 ── */} +
+ {/* 헤더 */} +
+
+
+
+ ☀️ +
+
+

오늘의 운세

+

+ {today.year}년 {today.month}월 {today.date}일 · 일진 {today.stem}{today.branch} ({today.stemKr}{today.branchKr}) +

+
+
+ + {meta.emoji} {meta.label} + +
+
+ +
+ {/* 일진 × 용신 분석 */} +
+
+
+ {today.stem}{today.branch} +
+
+
+ 오늘 일진과 당신의 용신 {yongShin}({yongShinKr}) 분석 +
+

+ {relDesc} +

+
+
+ + {/* 종합 점수 바 */} +
+ 오늘 종합 운세 +
+
+
+ {overall}점 +
+
+ + {/* 5대 운세 그리드 */} +
+

오늘의 분야별 운세

+
+ {areas.map((area) => { + const aLevel = toLevel(area.score); + const aMeta = LEVEL_META[aLevel]; + return ( +
+
+ {area.icon} +
+
+
+ {area.label} + {aMeta.emoji} +
+ +

{area.desc}

+
+
+ ); + })} +
+
+ + {/* 면책 */} +

+ 오늘의 운세는 당신의 사주 용신({yongShinKr}·{yongShin})과 오늘 일진의 오행 상호작용을 기반으로 합니다.
+ 명리학적 참고 자료이며 결과를 보장하지 않습니다. +

+ + {/* 로또 CTA */} +
+
+
+
+ 🎱 + + {level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'} + +
+

+ 용신 {yongShin}({yongShinKr}) 오행이 담긴 + 사주 기반 로또 번호가 아래에 준비되어 있습니다. + {hasLottoSubscription + ? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.' + : ' 로또 구독 시 대운 교차 분석으로 더 정밀한 번호를 매주 받을 수 있어요.'} +

+ { + e.preventDefault(); + document.getElementById('saju-lotto-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }} + className="block w-full text-center bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 text-[#04102b] text-sm font-extrabold px-4 py-2.5 rounded-xl transition-all shadow-lg cursor-pointer" + > + 오늘의 로또 번호 추천 보기 ↓ + +
+
+
+
+ + {/* 하단 연결 */} +
+
+
+ 🎱 오늘의 운세에서 이어지는 사주 로또 추천 +
+
+
+ + ); +} diff --git a/app/saju/result/SajuLottoSection.tsx b/app/saju/result/SajuLottoSection.tsx index 6a556fc..774c027 100644 --- a/app/saju/result/SajuLottoSection.tsx +++ b/app/saju/result/SajuLottoSection.tsx @@ -151,7 +151,7 @@ export default function SajuLottoSection({ const currentYear = new Date().getFullYear(); return ( -
+
{/* 헤더 */}
diff --git a/app/saju/result/page.tsx b/app/saju/result/page.tsx index 1fcb950..f62ea97 100644 --- a/app/saju/result/page.tsx +++ b/app/saju/result/page.tsx @@ -7,6 +7,7 @@ import { calculateElementScore, performFullAnalysis } from '@/lib/ai-interpretat import { createClient } from '@/lib/supabase/server'; import SajuAISection from './SajuAISection'; import SajuLottoSection from './SajuLottoSection'; +import SajuFortuneSection from './SajuFortuneSection'; interface PageProps { searchParams: Promise<{ @@ -90,6 +91,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) { hasPaid = !!order; if (hasPaid) { + // 1차: birth_hour 포함 정확한 키로 조회 const birthKey: Record = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender }; if (hourNum !== null) birthKey.birth_hour = hourNum; const { data: record } = await supabase @@ -97,6 +99,16 @@ export default async function SajuResultPage({ searchParams }: PageProps) { .eq('user_id', user.id).eq('is_paid', true) .contains('saju_data', birthKey).maybeSingle(); savedInterpretation = record?.interpretation ?? null; + + // 2차 폴백: birth_hour 없이 조회 (시간 입력 안 한 케이스 or 불일치 방지) + if (!savedInterpretation) { + const birthKeyNoHour: Record = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender }; + const { data: record2 } = await supabase + .from('saju_records').select('interpretation') + .eq('user_id', user.id).eq('is_paid', true) + .contains('saju_data', birthKeyNoHour).maybeSingle(); + savedInterpretation = record2?.interpretation ?? null; + } } // 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과) @@ -543,6 +555,20 @@ export default async function SajuResultPage({ searchParams }: PageProps) { ); })()} + {/* 오늘의 운세 (사주 결제 시 표시) */} + {hasPaid && ( + + )} + {/* 사주 연동 로또 번호 추천 (사주 결제 시 표시) */} {hasPaid && (