feat: 사주 결과 - 오늘의 운세 섹션 추가 및 AI 재호출 방지

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 00:28:05 +09:00
parent 1e0569dab5
commit 6533039fd7
3 changed files with 350 additions and 1 deletions

View File

@@ -0,0 +1,323 @@
'use client';
import { useMemo } from 'react';
// ── 천간 / 지지 ───────────────────────────────────────────────────────
const STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'];
const STEMS_KR = ['갑','을','병','정','무','기','경','신','임','계'];
const BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥'];
const BRANCHES_KR= ['자','축','인','묘','진','사','오','미','신','유','술','해'];
const STEM_ELEM: Record<string,string> = { '甲':'木','乙':'木','丙':'火','丁':'火','戊':'土','己':'土','庚':'金','辛':'金','壬':'水','癸':'水' };
const BRANCH_ELEM: Record<string,string> = { '子':'水','亥':'水','寅':'木','卯':'木','巳':'火','午':'火','申':'金','酉':'金','丑':'土','辰':'土','未':'土','戌':'土' };
// 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<string,string> = { '木':'火','火':'土','土':'金','金':'水','水':'木' };
const OVERCOMES: Record<string,string> = { '木':'土','火':'金','土':'水','金':'木','水':'火' };
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';
}
// ── 오늘 종합 점수 (0100) ────────────────────────────────────────────
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<string, Record<Level, string>> = {
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<Level, { emoji: string; label: string; bar: string; bg: string; border: string; text: string; badge: string }> = {
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<Rel, string> = (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 (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div style={{ width: `${score}%`, background: color, transition: 'width 0.8s ease' }} className="h-full rounded-full" />
</div>
<span className="text-[10px] font-bold w-6 text-right" style={{ color }}>{score}</span>
</div>
);
}
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────
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 (
<>
{/* ── 상단 연결 화살표 ── */}
<div className="flex flex-col items-center gap-0 py-1">
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-amber-300" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-amber-50 border border-amber-200 rounded-full text-[11px] font-bold text-amber-700">
<span></span>
</div>
<div className="w-px h-5 bg-gradient-to-b from-amber-300 to-amber-100" />
</div>
{/* ── 본문 카드 ── */}
<div id="today-fortune" className="bg-white rounded-2xl border border-amber-200 overflow-hidden shadow-sm">
{/* 헤더 */}
<div className="bg-gradient-to-r from-[#1a0a00] via-[#3d1a00] to-[#1a0a00] px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-md text-xl">
</div>
<div>
<h2 className="text-sm font-extrabold text-white"> </h2>
<p className="text-amber-300/70 text-[11px] mt-0.5">
{today.year} {today.month} {today.date} · {today.stem}{today.branch} ({today.stemKr}{today.branchKr})
</p>
</div>
</div>
<span className={`text-[11px] font-extrabold px-3 py-1.5 rounded-full border ${meta.badge} flex-shrink-0`}>
{meta.emoji} {meta.label}
</span>
</div>
</div>
<div className="p-5 space-y-5">
{/* 일진 × 용신 분석 */}
<div className={`rounded-xl border p-4 ${meta.bg} ${meta.border}`}>
<div className="flex items-start gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center font-bold text-lg flex-shrink-0 ${meta.text}`}
style={{ background: 'rgba(255,255,255,0.65)' }}>
{today.stem}{today.branch}
</div>
<div className="flex-1">
<div className={`text-xs font-extrabold mb-1 ${meta.text}`}>
{yongShin}({yongShinKr})
</div>
<p className={`text-xs leading-relaxed ${meta.text}`} style={{ opacity: 0.88 }}>
{relDesc}
</p>
</div>
</div>
{/* 종합 점수 바 */}
<div className="mt-3 flex items-center gap-3">
<span className="text-[11px] font-bold text-slate-500"> </span>
<div className="flex-1 h-2.5 bg-white/70 rounded-full overflow-hidden border border-white/50">
<div
style={{ width: `${overall}%`, background: `linear-gradient(90deg, ${meta.bar}cc, ${meta.bar})` }}
className="h-full rounded-full"
/>
</div>
<span className="text-sm font-extrabold" style={{ color: meta.bar }}>{overall}</span>
</div>
</div>
{/* 5대 운세 그리드 */}
<div>
<h3 className="text-xs font-extrabold text-[#04102b] mb-3"> </h3>
<div className="space-y-3">
{areas.map((area) => {
const aLevel = toLevel(area.score);
const aMeta = LEVEL_META[aLevel];
return (
<div key={area.label} className="flex gap-3 items-start">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 text-sm ${aMeta.bg} border ${aMeta.border}`}>
{area.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-xs font-bold text-[#04102b]">{area.label}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${aMeta.badge}`}>{aMeta.emoji}</span>
</div>
<ScoreBar score={area.score} color={aMeta.bar} />
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">{area.desc}</p>
</div>
</div>
);
})}
</div>
</div>
{/* 면책 */}
<p className="text-center text-[11px] text-slate-400 leading-relaxed">
({yongShinKr}·{yongShin}) .<br />
.
</p>
{/* 로또 CTA */}
<div className="rounded-2xl bg-gradient-to-br from-[#04102b] via-[#0d1f5c] to-[#04102b] border border-[#1a3a7a] p-5 relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.04]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
<div className="relative">
<div className="flex items-center gap-2 mb-2">
<span className="text-base">🎱</span>
<span className="text-xs font-extrabold text-amber-300">
{level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'}
</span>
</div>
<p className="text-xs text-blue-200/70 leading-relaxed mb-4">
<strong className="text-amber-300">{yongShin}({yongShinKr})</strong>
.
{hasLottoSubscription
? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.'
: ' 로또 구독 시 대운 교차 분석으로 더 정밀한 번호를 매주 받을 수 있어요.'}
</p>
<a
href="#saju-lotto-section"
onClick={e => {
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"
>
</a>
</div>
</div>
</div>
</div>
{/* 하단 연결 */}
<div className="flex flex-col items-center gap-0 py-1">
<div className="w-px h-5 bg-gradient-to-b from-amber-200 to-blue-300" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-blue-50 border border-blue-200 rounded-full text-[11px] font-bold text-blue-700">
<span>🎱</span>
</div>
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-transparent" />
</div>
</>
);
}

View File

@@ -151,7 +151,7 @@ export default function SajuLottoSection({
const currentYear = new Date().getFullYear();
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
<div id="saju-lotto-section" className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
{/* 헤더 */}
<div className="bg-gradient-to-r from-[#04102b] via-[#0d1f5c] to-[#04102b] px-6 py-5">
<div className="flex items-center gap-3">

View File

@@ -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<string, unknown> = { 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<string, unknown> = { 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 && (
<SajuFortuneSection
yongShin={analysis.yongShin.yongShin}
yongShinKr={analysis.yongShin.yongShinKr}
heeShin={analysis.yongShin.heeShin}
heeShinKr={analysis.yongShin.heeShinKr}
yearNum={yearNum}
monthNum={monthNum}
dayNum={dayNum}
hasLottoSubscription={hasLottoSubscription}
/>
)}
{/* 사주 연동 로또 번호 추천 (사주 결제 시 표시) */}
{hasPaid && (
<SajuLottoSection