사주 기능 이식 & 로그인, 유저 페이지 Supabase 연동 & 토스 페이먼츠 결제 연동 & 사주 심층 분석을 위한 기능 분리

This commit is contained in:
2026-03-10 04:28:56 +09:00
parent e8076b2b7a
commit 83043a357b
45 changed files with 8058 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)"
]
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
try {
const { paymentKey, orderId, amount } = await request.json();
if (!paymentKey || !orderId || !amount) {
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
}
// 1. Supabase에서 order 확인
const supabase = await createClient();
const { data: order, error: orderFetchError } = await supabase
.from('orders')
.select('*')
.eq('id', orderId)
.single();
if (orderFetchError || !order) {
return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
}
if (order.amount !== amount) {
return NextResponse.json({ error: '결제 금액 불일치' }, { status: 400 });
}
if (order.status === 'paid') {
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
}
// 2. 토스페이먼츠 서버 승인
const secretKey = process.env.TOSS_SECRET_KEY!;
const encoded = Buffer.from(`${secretKey}:`).toString('base64');
const tossRes = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
method: 'POST',
headers: {
Authorization: `Basic ${encoded}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ paymentKey, orderId, amount }),
});
if (!tossRes.ok) {
const err = await tossRes.json();
return NextResponse.json({ error: err.message || '토스 승인 실패' }, { status: 400 });
}
const tossData = await tossRes.json();
// 3. orders 상태 paid로 업데이트
const { error: updateError } = await supabase
.from('orders')
.update({ status: 'paid' })
.eq('id', orderId);
if (updateError) {
console.error('Order update error:', updateError);
return NextResponse.json({ error: '주문 상태 업데이트 실패: ' + updateError.message }, { status: 500 });
}
// 4. payments 레코드 생성
const { error: paymentError } = await supabase.from('payments').insert({
user_id: order.user_id,
order_id: orderId,
product_name: order.metadata?.product_name ?? order.product_id,
amount: order.amount,
status: 'paid',
pg_provider: 'toss',
pg_payment_key: paymentKey,
});
if (paymentError) {
console.error('Payment insert error:', paymentError);
return NextResponse.json({ error: '결제 내역 저장 실패: ' + paymentError.message }, { status: 500 });
}
return NextResponse.json({ success: true, data: tossData });
} catch (error: unknown) {
console.error('Payment confirm error:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}

View File

@@ -0,0 +1,121 @@
import { NextResponse } from 'next/server';
import OpenAI from 'openai';
import { createSajuPrompt } from '@/lib/saju-ai-prompt';
import { performFullAnalysis } from '@/lib/ai-interpretation';
export const runtime = 'nodejs';
const MOCK_INTERPRETATION = `
## 1. 일간 분석과 타고난 기질
(API 키 문제 또는 할당량 초과로 인해 예시 데이터를 보여드립니다.)
귀하는 **갑목(甲木)** 일간으로 태어나, 마치 곧게 뻗은 소나무와 같은 기상을 지니고 있다. 리더십이 강하고 추진력이 뛰어나며, 한번 마음먹은 일은 끝까지 해내는 뚝심이 있다.
## 2. 오행 균형과 용신 기반 개운법
사주에서 **화(火)** 기운이 부족하여 표현력이 다소 약할 수 있다. 붉은색 계통의 옷이나 소품을 활용하고, 밝은 곳에서 활동하는 것이 운을 트이게 한다.
## 3. 지지 상호작용 해석
지지 간의 상호작용을 살펴보면, 특별한 합충형이 발견된다.
## 4. 신살이 삶에 미치는 영향
역마살이 사주에 자리하고 있어 이동과 변동이 많은 삶을 살게 된다.
## 5. 재물운과 금전 흐름
재물창고인 **진토(辰土)**를 깔고 있어 기본적으로 재복은 타고났다. 다만, 돈을 버는 것보다 지키는 힘이 약할 수 있으니 저축 습관이 중요하다.
## 6. 직업 적성과 진로
교육, 출판, 건축, 디자인 등 창조적이고 독립적인 분야에서 두각을 나타낼 수 있다.
## 7. 애정운과 결혼
자존심이 강해 상대방에게 굽히지 않으려는 성향이 있다. 배우자와의 관계에서는 조금 더 부드러운 태도가 필요하다.
## 8. 건강운
간, 담낭, 신경계 통증에 유의해야 한다. 스트레스를 받으면 뭉치는 경향이 있으니 스트레칭과 요가를 추천한다.
## 9. 현재 대운의 흐름과 기회/위기
현재 대운은 인생의 전환점이다. 새로운 것을 시작하기보다는 기존의 것을 다지고 내실을 기하는 시기이다.
## 10. 올해의 세운 분석
올해는 귀인의 도움을 받을 수 있는 해이다. 주저하지 말고 주변에 도움을 요청하라.
## 11. 인생의 황금기 예측
40대 중반부터 50대 초반까지 인생의 가장 화려한 시기를 맞이할 것으로 보인다.
## 12. 종합 조언
"서두르지 않아도 봄은 온다." 조급해하지 말고 때를 기다리는 지혜가 필요하다.
`;
// 사용 가능한 모델 우선순위 (gpt-4o → gpt-4o-mini 폴백)
const MODELS = ['gpt-4o', 'gpt-4o-mini'] as const;
export async function POST(request: Request) {
try {
const { saju, daeun, daeunList, gender } = await request.json();
// 종합 분석 수행
let analysis;
try {
analysis = performFullAnalysis(saju);
} catch (analysisError: any) {
console.error('Analysis calculation error:', analysisError.message);
return NextResponse.json(
{ error: '사주 분석 계산 중 오류가 발생했습니다: ' + analysisError.message },
{ status: 500 }
);
}
if (!process.env.OPENAI_API_KEY) {
console.warn('OpenAI API Key is missing');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const prompt = createSajuPrompt(saju, daeun, gender, analysis, daeunList || []);
// 모델 폴백: gpt-4o 실패 시 gpt-4o-mini로 재시도
let interpretation: string | null = null;
let usedModel = '';
for (const model of MODELS) {
try {
console.log(`Generating saju analysis with model: ${model}`);
const completion = await openai.chat.completions.create({
messages: [{ role: 'system', content: prompt }],
model,
max_tokens: model === 'gpt-4o' ? 8192 : 4096,
temperature: 0.75,
});
interpretation = completion.choices[0].message.content;
usedModel = model;
console.log(`Successfully generated with model: ${model}`);
break;
} catch (modelError: any) {
console.warn(`Model ${model} failed:`, modelError.message || modelError.status);
if (modelError.status === 401) {
console.warn('OpenAI API Key is invalid (401). Returning mock data.');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
if (modelError.status === 429 || (modelError.error && modelError.error.code === 'insufficient_quota')) {
console.warn('OpenAI Quota Exceeded. Returning mock data.');
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
if (model === MODELS[MODELS.length - 1]) {
throw modelError;
}
console.log(`Falling back to next model...`);
}
}
return NextResponse.json({ interpretation, analysis });
} catch (error: any) {
console.error('Error generating saju interpretation:', error.message || error);
return NextResponse.json(
{ error: error.message || 'Failed to generate interpretation' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,41 @@
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/lotto`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
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

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function POST(request: NextRequest) {
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
const { interpretation, birthKey } = await request.json();
if (!interpretation || !birthKey) {
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
}
// 기존 레코드 확인 (중복 저장 방지)
const { data: existing } = await supabase
.from('saju_records')
.select('id')
.eq('user_id', user.id)
.eq('is_paid', true)
.contains('saju_data', birthKey)
.maybeSingle();
if (existing) {
// 기존 레코드 업데이트
await supabase
.from('saju_records')
.update({ interpretation })
.eq('id', existing.id);
} else {
// 새 레코드 생성
await supabase.from('saju_records').insert({
user_id: user.id,
saju_data: birthKey,
interpretation,
is_paid: true,
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Save interpretation error:', error);
return NextResponse.json({ error: '저장 실패' }, { status: 500 });
}
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/mypage';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
const forwardedHost = request.headers.get('x-forwarded-host');
const isLocalEnv = process.env.NODE_ENV === 'development';
if (isLocalEnv) {
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
return NextResponse.redirect(`${origin}${next}`);
}
}
}
return NextResponse.redirect(`${origin}/login?error=auth-callback-error`);
}

View File

@@ -1,10 +1,20 @@
'use client';
import { useState } from 'react';
import { usePathname } from 'next/navigation';
import Sidebar from './Sidebar';
const AUTH_PATHS = ['/login', '/signup'];
export default function DashboardShell({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p));
if (isAuthPage) {
return <>{children}</>;
}
return (
<div className="dashboard-layout">

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { PRODUCTS } from '@/lib/products';
interface PaymentButtonProps {
productId: string;
className?: string;
children: React.ReactNode;
returnUrl?: string;
}
export default function PaymentButton({ productId, className, children, returnUrl }: PaymentButtonProps) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const supabase = createClient();
const product = PRODUCTS[productId];
const handlePayment = async () => {
setLoading(true);
try {
// 1. 로그인 확인
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login?next=' + encodeURIComponent(window.location.pathname));
return;
}
// 2. 프로필 없으면 생성 (Google OAuth 등으로 트리거 미실행된 경우 대비)
await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
// 3. Supabase에 order 생성
const orderId = crypto.randomUUID();
const { error: orderError } = await supabase
.from('orders')
.insert({
id: orderId,
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: { product_name: product.name },
});
if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
// 4. 토스페이먼츠 결제창 호출
const { loadTossPayments } = await import('@tosspayments/tosspayments-sdk');
const clientKey = process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!;
const tossPayments = await loadTossPayments(clientKey);
const payment = tossPayments.payment({
customerKey: user.id,
});
await payment.requestPayment({
method: 'CARD',
amount: {
currency: 'KRW',
value: product.price,
},
orderId,
orderName: product.name,
successUrl: `${window.location.origin}/payment/success${returnUrl ? '?returnUrl=' + encodeURIComponent(returnUrl) : ''}`,
failUrl: `${window.location.origin}/payment/fail`,
customerEmail: user.email,
});
} catch (err: unknown) {
const error = err as { code?: string; message?: string };
// 사용자가 결제창 닫은 경우는 무시
if (error?.code !== 'USER_CANCEL') {
alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
console.error(err);
}
} finally {
setLoading(false);
}
};
if (!product) return null;
return (
<button
onClick={handlePayment}
disabled={loading}
className={className}
>
{loading ? '결제 처리 중...' : children}
</button>
);
}

View File

@@ -1,7 +1,9 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
const navItems = [
{
@@ -57,6 +59,17 @@ const navItems = [
label: '업무 자동화',
desc: 'RPA 개발',
},
{
href: '/saju',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
),
label: 'AI 사주 분석',
desc: '사주팔자 + AI 해석',
badge: 'NEW',
},
{
href: '/freelance',
icon: (
@@ -76,6 +89,28 @@ interface SidebarProps {
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const [userEmail, setUserEmail] = useState<string | null>(null);
const supabase = createClient();
useEffect(() => {
supabase.auth.getUser().then(({ data }) => {
setUserEmail(data.user?.email ?? null);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_, session) => {
setUserEmail(session?.user?.email ?? null);
});
return () => subscription.unsubscribe();
}, []);
const handleLogout = async () => {
await supabase.auth.signOut();
router.push('/');
router.refresh();
onClose();
};
return (
<>
@@ -116,7 +151,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
<span className="text-slate-500 text-xs font-semibold uppercase tracking-wider"></span>
</div>
{navItems.map((item) => {
const isActive = pathname === item.href;
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
@@ -157,18 +192,59 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
})}
</nav>
{/* Bottom: Developer profile */}
{/* Bottom: 로그인 상태 */}
<div className="p-4 border-t border-[#1a3a7a]/50 flex-shrink-0">
<div className="flex items-center gap-3 px-1">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-sm font-bold flex-shrink-0 shadow">
{userEmail ? (
/* 로그인 상태 */
<div className="space-y-2">
<Link
href="/mypage"
onClick={onClose}
className={`flex items-center gap-3 px-3 py-2 rounded-xl transition-all ${
pathname.startsWith('/mypage')
? 'bg-gradient-to-r from-blue-600 to-violet-600'
: 'hover:bg-[#0a1f5c]'
}`}
>
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-sm font-bold flex-shrink-0 shadow">
{userEmail[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-semibold truncate">{userEmail}</div>
<div className="text-blue-400 text-xs"></div>
</div>
<div className="w-2 h-2 rounded-full bg-emerald-400 flex-shrink-0" />
</Link>
<button
onClick={handleLogout}
className="w-full text-left px-3 py-1.5 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-[#0a1f5c] text-xs transition-all"
>
</button>
</div>
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-semibold"></div>
<div className="text-slate-500 text-xs"> </div>
) : (
/* 비로그인 상태 */
<div className="space-y-2">
<div className="flex items-center gap-3 px-1 mb-2">
<div className="w-9 h-9 rounded-full bg-slate-800 border border-slate-700 flex items-center justify-center text-slate-500 flex-shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-slate-400 text-sm font-medium"></div>
<div className="text-slate-600 text-xs"> </div>
</div>
</div>
<Link
href="/login"
onClick={onClose}
className="block w-full text-center bg-gradient-to-r from-blue-600 to-violet-600 text-white text-sm font-semibold px-3 py-2 rounded-xl hover:opacity-90 transition-all"
>
/
</Link>
</div>
<div className="w-2 h-2 rounded-full bg-emerald-400 flex-shrink-0" title="온라인" />
</div>
)}
</div>
</aside>
</>

213
app/login/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import { Suspense } from 'react';
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSignUp, setIsSignUp] = useState(false);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
const supabase = createClient();
useEffect(() => {
if (searchParams.get('error')) {
setMessage('인증 중 오류가 발생했습니다. 다시 시도해주세요.');
}
// 이미 로그인된 경우 리다이렉트
supabase.auth.getUser().then(({ data }) => {
if (data.user) router.push('/mypage');
});
}, []);
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
if (isSignUp) {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
},
});
if (error) {
setMessage('회원가입 실패: ' + error.message);
} else if (data.user?.identities?.length === 0) {
setMessage('이미 가입된 이메일입니다. 로그인해주세요.');
setIsSignUp(false);
} else {
setMessage('가입 완료! 이메일 인증 링크를 확인해주세요.');
}
} else {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
} else {
router.push('/mypage');
router.refresh();
}
}
setLoading(false);
};
const handleGoogleLogin = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/callback` },
});
if (error) setMessage('Google 로그인 오류: ' + error.message);
};
return (
<div className="min-h-screen bg-[#04102b] flex items-center justify-center p-4">
{/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 opacity-[0.03]"
style={{ backgroundImage: 'linear-gradient(#4f8ef7 1px, transparent 1px), linear-gradient(90deg, #4f8ef7 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="absolute top-0 right-0 w-96 h-96 rounded-full bg-blue-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="absolute bottom-0 left-0 w-80 h-80 rounded-full bg-violet-500/10 blur-3xl translate-y-1/2 -translate-x-1/4" />
</div>
<div className="relative w-full max-w-md">
{/* 로고 */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xl shadow-lg shadow-blue-500/25">
</div>
<div className="text-left">
<div className="text-white font-bold text-xl leading-tight"></div>
<div className="text-blue-400 text-xs font-medium">Premium Dev Services</div>
</div>
</Link>
</div>
{/* 카드 */}
<div className="bg-white/5 border border-white/10 backdrop-blur rounded-2xl p-8 shadow-2xl">
<div className="text-center mb-7">
<h1 className="text-2xl font-extrabold text-white mb-1">
{isSignUp ? '회원가입' : '로그인'}
</h1>
<p className="text-blue-300/60 text-sm">
{isSignUp
? '가입 후 사주 기록, 결제 내역을 관리하세요'
: '사주 기록·결제·의뢰 내역을 확인하세요'}
</p>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm font-medium ${
message.includes('완료') || message.includes('확인해주세요')
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-300'
: 'bg-red-500/10 border border-red-500/30 text-red-300'
}`}>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4 mb-5">
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="password"
placeholder="6자 이상"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-500 hover:to-violet-500 text-white font-bold py-3 rounded-xl transition-all shadow-lg shadow-blue-600/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
</button>
</form>
{/* 전환 링크 */}
<div className="text-center mb-5">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm text-blue-400 hover:text-blue-300 transition"
>
{isSignUp ? '이미 계정이 있으신가요? 로그인 →' : '아직 계정이 없으신가요? 회원가입 →'}
</button>
</div>
{/* 구분선 */}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-transparent text-slate-500 text-xs"> </span>
</div>
</div>
{/* 구글 로그인 */}
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition text-white font-medium text-sm"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google로
</button>
</div>
{/* 홈으로 */}
<div className="text-center mt-6">
<Link href="/" className="text-slate-500 hover:text-slate-300 text-sm transition">
</Link>
</div>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#04102b] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
);
}

390
app/mypage/page.tsx Normal file
View File

@@ -0,0 +1,390 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
function buildSajuResultUrl(rec: SajuRecord) {
const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
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}`;
return url;
}
type Tab = 'profile' | 'saju' | 'payments' | 'orders';
interface SajuRecord {
id: number;
created_at: string;
saju_data: {
birth_year: number;
birth_month: number;
birth_day: number;
birth_hour?: number;
gender: string;
};
interpretation: string | null;
is_paid: boolean;
}
interface Payment {
id: string;
created_at: string;
amount: number;
status: string;
product_name: string;
}
interface Order {
id: string;
created_at: string;
service: string;
message: string;
status: string;
}
export default function MyPage() {
const router = useRouter();
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('profile');
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
async function init() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
return;
}
setUser(user);
// 사주 기록 조회 (테이블 있을 때 동작)
const { data: saju } = await supabase
.from('saju_records')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setSajuRecords(saju || []);
// 결제 내역 조회
const { data: pay } = await supabase
.from('payments')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setPayments(pay || []);
// 의뢰 내역 조회
const { data: ord } = await supabase
.from('contact_requests')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setOrders(ord || []);
setLoading(false);
}
init();
}, []);
const handleLogout = async () => {
await supabase.auth.signOut();
router.push('/');
router.refresh();
};
if (loading) {
return (
<div className="min-h-full flex items-center justify-center bg-[#f0f5ff]">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return null;
const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '내 정보' },
{ key: 'saju', label: '사주 기록', count: sajuRecords.length },
{ key: 'payments', label: '결제 내역', count: payments.length },
{ key: 'orders', label: '의뢰 내역', count: orders.length },
];
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-blue-400 to-violet-500 flex items-center justify-center text-white text-xl font-bold shadow-lg flex-shrink-0">
{user.email?.[0].toUpperCase()}
</div>
<div>
<div className="text-white font-bold text-lg leading-tight">{user.email}</div>
<div className="text-blue-300/60 text-sm mt-0.5">
: {new Date(user.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
<div className="ml-auto">
<button
onClick={handleLogout}
className="px-4 py-2 bg-white/5 border border-white/10 text-slate-300 text-sm rounded-xl hover:bg-white/10 transition"
>
</button>
</div>
</div>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
{/* 탭 */}
<div className="flex gap-1 bg-white border border-[#dbe8ff] rounded-xl p-1 mb-6">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
tab === t.key
? 'bg-gradient-to-r from-blue-600 to-violet-600 text-white shadow'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-bold ${
tab === t.key ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-600'
}`}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
{/* 내 정보 */}
{tab === 'profile' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-600 to-violet-600 rounded-full" />
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-[#04102b]">{user.email}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"> </span>
<span className="text-sm font-semibold text-[#04102b] capitalize">
{user.app_metadata?.provider === 'google' ? 'Google' : '이메일'}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-[#04102b]">
{new Date(user.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
</div>
</div>
</div>
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="font-bold text-[#04102b] mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-gradient-to-b from-blue-600 to-violet-600 rounded-full" />
</h2>
<div className="grid grid-cols-2 gap-3">
<Link href="/saju/input" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-[#dbe8ff] hover:border-blue-300 hover:bg-blue-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-blue-50 border border-blue-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#04102b]"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
</div>
</div>
</div>
)}
{/* 사주 기록 */}
{tab === 'saju' && (
<div>
{sajuRecords.length === 0 ? (
<EmptyState
icon="✨"
title="저장된 사주 기록이 없습니다"
desc="사주 분석 후 결과를 저장하면 여기서 다시 확인할 수 있습니다"
linkHref="/saju/input"
linkLabel="사주 분석 시작"
/>
) : (
<div className="grid md:grid-cols-2 gap-4">
{sajuRecords.map((rec) => (
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-xs text-slate-400 mb-1">{new Date(rec.created_at).toLocaleDateString('ko-KR')}</div>
<div className="font-bold text-[#04102b]">
{rec.saju_data?.birth_year ?? '?'}{' '}
{rec.saju_data?.birth_month ?? '?'}{' '}
{rec.saju_data?.birth_day ?? '?'}
</div>
<div className="text-sm text-slate-500 mt-0.5">
{rec.saju_data?.gender === 'male' ? '남성' : '여성'}
{rec.saju_data?.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
</div>
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${rec.is_paid ? 'bg-amber-50 text-amber-600 border border-amber-200' : 'bg-slate-100 text-slate-500'}`}>
{rec.is_paid ? '유료' : '무료'}
</span>
</div>
{rec.interpretation && (
<p className="text-xs text-slate-500 line-clamp-2 bg-slate-50 rounded-lg px-3 py-2 mb-3">
{rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
</p>
)}
<Link
href={buildSajuResultUrl(rec)}
className="block w-full text-center py-2 rounded-xl text-xs font-bold bg-gradient-to-r from-[#04102b] to-[#0a2060] text-white hover:from-[#0a1f5c] hover:to-[#1a3a7a] transition"
>
{rec.is_paid && rec.interpretation ? 'AI 해석 다시 보기 →' : '결과 보기 →'}
</Link>
</div>
))}
</div>
)}
</div>
)}
{/* 결제 내역 */}
{tab === 'payments' && (
<div>
{payments.length === 0 ? (
<EmptyState
icon="💳"
title="결제 내역이 없습니다"
desc="서비스 구매 후 결제 내역이 여기에 표시됩니다"
linkHref="/saju"
linkLabel="서비스 보기"
/>
) : (
<div className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f0f5ff] border-b border-[#dbe8ff]">
<tr>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
</tr>
</thead>
<tbody>
{payments.map((p, i) => (
<tr key={p.id} className={i % 2 === 0 ? '' : 'bg-slate-50/50'}>
<td className="px-5 py-3 font-medium text-[#04102b]">{p.product_name}</td>
<td className="px-5 py-3 text-[#04102b]">{p.amount?.toLocaleString()}</td>
<td className="px-5 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
p.status === 'paid' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'
}`}>
{p.status === 'paid' ? '결제완료' : p.status}
</span>
</td>
<td className="px-5 py-3 text-slate-500 text-xs">
{new Date(p.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* 의뢰 내역 */}
{tab === 'orders' && (
<div>
{orders.length === 0 ? (
<EmptyState
icon="📋"
title="의뢰 내역이 없습니다"
desc="외주 개발, 서비스 문의 내역이 여기에 표시됩니다"
linkHref="/freelance"
linkLabel="외주 의뢰하기"
/>
) : (
<div className="space-y-3">
{orders.map((o) => (
<div key={o.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<div className="flex items-start justify-between mb-2">
<div className="font-bold text-[#04102b]">{o.service}</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${
o.status === 'completed' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' :
o.status === 'in_progress' ? 'bg-blue-50 text-blue-600 border border-blue-200' :
'bg-slate-100 text-slate-500'
}`}>
{o.status === 'completed' ? '완료' : o.status === 'in_progress' ? '진행중' : '대기중'}
</span>
</div>
<p className="text-sm text-slate-600 line-clamp-2">{o.message}</p>
<div className="text-xs text-slate-400 mt-2">{new Date(o.created_at).toLocaleDateString('ko-KR')}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function EmptyState({
icon, title, desc, linkHref, linkLabel,
}: {
icon: string; title: string; desc: string; linkHref: string; linkLabel: string;
}) {
return (
<div className="text-center py-16 bg-white rounded-2xl border border-[#dbe8ff]">
<div className="text-5xl mb-4">{icon}</div>
<div className="font-bold text-[#04102b] text-lg mb-2">{title}</div>
<div className="text-slate-500 text-sm mb-6">{desc}</div>
<Link
href={linkHref}
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm hover:opacity-90 transition-all shadow-lg shadow-blue-600/20"
>
{linkLabel}
</Link>
</div>
);
}

View File

@@ -301,6 +301,47 @@ export default function Home() {
</div>
</div>
</Link>
{/* ─ AI 사주 분석 ─ */}
<Link href="/saju" className="service-card group bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden hover:border-[#7c3aed]/30 hover:shadow-xl hover:shadow-violet-100 md:col-span-2">
<div className="relative bg-gradient-to-br from-[#0d0a2e] via-[#1a0f5c] to-[#04102b] px-6 pt-7 pb-6 overflow-hidden">
<div className="absolute inset-0 opacity-[0.06]"
style={{ backgroundImage: 'radial-gradient(circle, #c4b5fd 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
<div className="absolute top-2 right-2 w-28 h-28 rounded-full bg-amber-400/10 blur-2xl" />
<span className="absolute top-4 right-4 bg-violet-600 text-white text-xs font-bold px-2 py-0.5 rounded-lg tracking-wide">NEW</span>
<div className="relative flex items-start gap-5">
<div className="w-11 h-11 rounded-xl bg-violet-400/15 border border-violet-400/25 flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</div>
<div>
<div className="text-amber-400/70 text-xs font-semibold mb-0.5 tracking-wide">AI SAJU ANALYTICS</div>
<h3 className="text-white text-xl font-extrabold">AI </h3>
<p className="text-violet-200/60 text-xs mt-1"> GPT-4o의 12 </p>
</div>
</div>
</div>
<div className="px-6 py-5">
<p className="text-slate-600 text-sm leading-relaxed mb-4"> / , ·, AI가 .</p>
<div className="space-y-2 mb-5">
{['전통 사주팔자 계산', 'AI 12가지 항목 해석', '무료 기본 · 유료 상세'].map(f => (
<div key={f} className="flex items-center gap-2 text-sm text-slate-700">
<div className="w-4 h-4 rounded-full bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0"><div className="w-1.5 h-1.5 rounded-full bg-violet-500" /></div>
{f}
</div>
))}
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-100">
<div>
<span className="text-[#04102b] font-extrabold text-lg"> / 4,900</span>
<span className="ml-2 text-xs bg-violet-50 border border-violet-200 text-violet-700 px-2 py-0.5 rounded-full font-medium">1</span>
</div>
<span className="text-[#7c3aed] text-sm font-semibold flex items-center gap-1"> </span>
</div>
</div>
</Link>
</div>
{/* ─ Freelance CTA ─ */}

62
app/payment/fail/page.tsx Normal file
View File

@@ -0,0 +1,62 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
function FailContent() {
const params = useSearchParams();
const message = params.get('message') ?? '결제가 취소되었거나 실패했습니다.';
const code = params.get('code') ?? '';
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-slate-100 border-2 border-slate-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="inline-block bg-slate-100 border border-slate-200 text-slate-600 text-xs font-bold px-3 py-1 rounded-full mb-4">
{code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2">
{code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
</h2>
<p className="text-slate-500 text-sm mb-8 max-w-xs mx-auto leading-relaxed">{message}</p>
<div className="flex justify-center gap-3 flex-wrap">
<button
onClick={() => window.history.back()}
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20"
>
</button>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentFailPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-[#04102b] to-[#0a1f5c] px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={<div className="py-20 text-center text-slate-400 text-sm"> ...</div>}>
<FailContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import Link from 'next/link';
function SuccessContent() {
const params = useSearchParams();
const router = useRouter();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMsg, setErrorMsg] = useState('');
const [productName, setProductName] = useState('');
useEffect(() => {
const paymentKey = params.get('paymentKey');
const orderId = params.get('orderId');
const amount = Number(params.get('amount'));
const returnUrl = params.get('returnUrl');
if (!paymentKey || !orderId || !amount) {
setStatus('error');
setErrorMsg('잘못된 접근입니다.');
return;
}
fetch('/api/payment/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentKey, orderId, amount }),
})
.then((res) => res.json())
.then((data) => {
if (data.success) {
setProductName(data.data?.orderName ?? '');
setStatus('success');
if (returnUrl) {
router.replace(returnUrl);
}
} else {
setStatus('error');
setErrorMsg(data.error || '결제 승인에 실패했습니다.');
}
})
.catch(() => {
setStatus('error');
setErrorMsg('서버 오류가 발생했습니다. 결제 내역을 확인해주세요.');
});
}, []);
if (status === 'loading') {
return (
<div className="text-center py-20 px-6">
<div className="w-12 h-12 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
);
}
if (status === 'error') {
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-red-50 border-2 border-red-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2"> </h2>
<p className="text-slate-500 text-sm mb-8">{errorMsg}</p>
<div className="flex justify-center gap-3">
<Link href="/mypage" className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-5 py-2.5 rounded-xl font-semibold text-sm hover:bg-slate-50 transition">
</Link>
<Link href="/" className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-5 py-2.5 rounded-xl font-semibold text-sm">
</Link>
</div>
</div>
);
}
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-emerald-50 border-2 border-emerald-400 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="inline-block bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full mb-4">
</div>
<h2 className="text-2xl font-extrabold text-[#04102b] mb-2"> !</h2>
{productName && (
<p className="text-slate-500 text-sm mb-1">{productName}</p>
)}
<p className="text-slate-400 text-sm mb-8">
.
</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
href="/mypage?tab=payments"
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-600 to-violet-600 text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20"
>
</Link>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-[#04102b] to-[#0a1f5c] px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={
<div className="py-20 text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
}>
<SuccessContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { lunarToSolar } from '@/lib/lunar-utils';
export default function SajuForm() {
const router = useRouter();
const [year, setYear] = useState('');
const [month, setMonth] = useState('');
const [day, setDay] = useState('');
const [hour, setHour] = useState('');
const [calendarType, setCalendarType] = useState<'solar' | 'lunar'>('solar');
const [gender, setGender] = useState<'male' | 'female'>('male');
const [isLeapMonth, setIsLeapMonth] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!year || !month || !day) {
alert('생년월일을 모두 입력해주세요.');
return;
}
let finalYear = year;
let finalMonth = month;
let finalDay = day;
// 음력인 경우 양력으로 변환
if (calendarType === 'lunar') {
const solar = lunarToSolar(
parseInt(year),
parseInt(month),
parseInt(day),
isLeapMonth
);
finalYear = solar.year.toString();
finalMonth = solar.month.toString();
finalDay = solar.day.toString();
}
// URL 파라미터로 전달
const params = new URLSearchParams({
year: finalYear,
month: finalMonth,
day: finalDay,
gender,
calendarType,
originalYear: year,
originalMonth: month,
originalDay: day,
});
if (hour) {
params.append('hour', hour);
}
if (calendarType === 'lunar') {
params.append('isLeapMonth', isLeapMonth.toString());
}
router.push(`/saju/result?${params.toString()}`);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* 생년월일 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-3 gap-3">
<input
type="number"
placeholder="년 (예: 1990)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1900"
max="2100"
value={year}
onChange={(e) => setYear(e.target.value)}
required
/>
<input
type="number"
placeholder="월 (1-12)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1"
max="12"
value={month}
onChange={(e) => setMonth(e.target.value)}
required
/>
<input
type="number"
placeholder="일 (1-31)"
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
min="1"
max="31"
value={day}
onChange={(e) => setDay(e.target.value)}
required
/>
</div>
</div>
{/* 태어난 시간 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
()
</label>
<select
className="w-full px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
value={hour}
onChange={(e) => setHour(e.target.value)}
>
<option value=""> / </option>
<option value="0"> () 23:00 - 01:00</option>
<option value="1"> () 01:00 - 03:00</option>
<option value="3"> () 03:00 - 05:00</option>
<option value="5"> () 05:00 - 07:00</option>
<option value="7"> () 07:00 - 09:00</option>
<option value="9"> () 09:00 - 11:00</option>
<option value="11"> () 11:00 - 13:00</option>
<option value="13"> () 13:00 - 15:00</option>
<option value="15"> () 15:00 - 17:00</option>
<option value="17"> () 17:00 - 19:00</option>
<option value="19"> () 19:00 - 21:00</option>
<option value="21"> () 21:00 - 23:00</option>
</select>
</div>
{/* 양력/음력 선택 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCalendarType('solar')}
className={`px-6 py-3 rounded-xl font-bold transition ${
calendarType === 'solar'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
<button
type="button"
onClick={() => setCalendarType('lunar')}
className={`px-6 py-3 rounded-xl font-bold transition ${
calendarType === 'lunar'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
</div>
{calendarType === 'lunar' && (
<div className="mt-3">
<label className="flex items-center justify-center gap-2 text-sm text-slate-500 cursor-pointer">
<input
type="checkbox"
checked={isLeapMonth}
onChange={(e) => setIsLeapMonth(e.target.checked)}
className="w-4 h-4 text-[#1a56db] border-gray-300 rounded focus:ring-[#1a56db]"
/>
<span></span>
</label>
</div>
)}
</div>
{/* 성별 선택 */}
<div>
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setGender('male')}
className={`px-6 py-3 rounded-xl font-bold transition ${
gender === 'male'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
<button
type="button"
onClick={() => setGender('female')}
className={`px-6 py-3 rounded-xl font-bold transition ${
gender === 'female'
? 'bg-[#1a56db] text-white shadow-lg'
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
}`}
>
</button>
</div>
</div>
{/* 제출 버튼 */}
<button
type="submit"
className="w-full bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
>
</button>
<p className="text-sm text-slate-500 text-center">
* .
</p>
</form>
);
}

43
app/saju/input/page.tsx Normal file
View File

@@ -0,0 +1,43 @@
import SajuForm from '../components/SajuForm';
export default function SajuInputPage() {
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* Hero */}
<div className="relative overflow-hidden bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-12">
<div className="absolute inset-0 opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '28px 28px' }} />
<div className="absolute right-0 top-0 w-72 h-72 rounded-full bg-violet-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="relative max-w-xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
AI ·
</div>
<h1 className="text-3xl md:text-4xl font-extrabold text-white leading-tight mb-3 tracking-tight">
</h1>
<p className="text-blue-200/60 text-sm leading-relaxed">
<br />
.
</p>
</div>
</div>
{/* Form 영역 */}
<div className="px-6 py-10 max-w-2xl mx-auto">
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 shadow-lg">
<div className="flex items-center gap-2 mb-6">
<div className="w-1 h-5 bg-gradient-to-b from-[#1a56db] to-[#7c3aed] rounded-full" />
<h2 className="font-bold text-[#04102b] text-base"> </h2>
</div>
<SajuForm />
</div>
<p className="text-center text-xs text-slate-400 mt-6">
.
</p>
</div>
</div>
);
}

341
app/saju/page.tsx Normal file
View File

@@ -0,0 +1,341 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import PaymentButton from '../components/PaymentButton';
import { createClient } from '@/lib/supabase/client';
const faqItems = [
{
q: '사주팔자란 무엇인가요?',
a: '사주팔자(四柱八字)는 태어난 년·월·일·시의 네 기둥(四柱)에 각각 천간과 지지 두 글자씩 총 여덟 글자(八字)로 이루어진 동양의 전통 운명 분석 체계입니다.',
},
{
q: 'AI 해석은 어떻게 동작하나요?',
a: '전통 명리학 계산 로직(오행, 신강/신약, 용신/희신 등)으로 산출된 데이터를 GPT-4o에 전달하여 12개 항목의 상세 해석을 생성합니다. 기본 원국 분석은 무료이며, AI 상세 해석은 유료(₩4,900)로 제공됩니다.',
},
{
q: '태어난 시간을 모르면 어떻게 하나요?',
a: '시간을 모르더라도 년·월·일 세 기둥(三柱)만으로 사주를 계산할 수 있습니다. 다만 시주가 빠지면 세부 분석 정확도가 다소 낮아집니다.',
},
{
q: '음력으로 입력할 수 있나요?',
a: '네, 양력과 음력 모두 지원합니다. 음력을 선택하면 내부적으로 양력으로 변환하여 정확한 사주를 계산합니다. 윤달도 별도 선택이 가능합니다.',
},
];
interface SajuRecord {
id: number;
created_at: string;
saju_data: {
birth_year: number;
birth_month: number;
birth_day: number;
birth_hour?: number;
gender: string;
};
interpretation: string | null;
is_paid: boolean;
}
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}`;
return url;
}
export default function SajuPage() {
const supabase = createClient();
const [paidRecords, setPaidRecords] = useState<SajuRecord[]>([]);
const [hasPaid, setHasPaid] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
async function fetchRecords() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) { setAuthChecked(true); return; }
const { data: records } = await supabase
.from('saju_records')
.select('*')
.eq('user_id', user.id)
.eq('is_paid', true)
.order('created_at', { ascending: false })
.limit(2);
if (records && records.length > 0) {
setPaidRecords(records);
setHasPaid(true);
}
setAuthChecked(true);
}
fetchRecords();
}, []);
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* ─── Hero ─── */}
<div className="relative overflow-hidden bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-14 lg:px-12">
<div className="absolute inset-0 opacity-[0.06]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '30px 30px' }} />
<div className="absolute right-0 top-0 w-96 h-96 rounded-full bg-violet-500/10 blur-3xl -translate-y-1/2 translate-x-1/3" />
<div className="absolute left-1/3 bottom-0 w-64 h-64 rounded-full bg-amber-400/8 blur-3xl translate-y-1/2" />
<div className="relative max-w-3xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-5 tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
× AI ·
</div>
<h1 className="text-4xl md:text-5xl font-extrabold text-white leading-tight mb-5 tracking-tight">
AI가 <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-[#c4b5fd] to-[#fbbf24]">
</span>
</h1>
<p className="text-blue-200/70 text-base md:text-lg leading-relaxed mb-8 max-w-xl mx-auto">
AI .<br />
12 .
</p>
{/* 이전 기록 있으면 분기 버튼, 없으면 단일 CTA */}
{authChecked && hasPaid ? (
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</Link>
<a
href="#past-records"
className="inline-flex items-center gap-2 bg-white/10 border border-white/20 text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all hover:bg-white/20"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</a>
</div>
) : (
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</Link>
)}
</div>
</div>
<div className="px-6 py-12 lg:px-12">
<div className="max-w-4xl mx-auto space-y-10">
{/* ─── 이전 기록 섹션 (구매한 유저만) ─── */}
{hasPaid && paidRecords.length > 0 && (
<div id="past-records">
<div className="text-center mb-6">
<p className="text-violet-600 text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
<h2 className="text-2xl font-extrabold text-[#04102b]"> AI </h2>
<p className="text-slate-500 text-sm mt-1"> </p>
</div>
<div className="grid md:grid-cols-2 gap-4">
{paidRecords.map((rec) => (
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5 hover:border-violet-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-xs text-slate-400 mb-1">
{new Date(rec.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
<div className="font-bold text-[#04102b] text-base">
{rec.saju_data.birth_year ?? '?'}{' '}
{rec.saju_data.birth_month ?? '?'}{' '}
{rec.saju_data.birth_day ?? '?'}
</div>
<div className="text-sm text-slate-500 mt-0.5">
{rec.saju_data.gender === 'male' ? '남성' : '여성'}
{rec.saju_data.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
</div>
</div>
<span className="text-xs font-bold px-2 py-1 rounded-lg bg-amber-50 text-amber-600 border border-amber-200 flex-shrink-0">
AI
</span>
</div>
{rec.interpretation && (
<p className="text-xs text-slate-500 bg-slate-50 rounded-lg px-3 py-2 mb-3 line-clamp-2">
{rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
</p>
)}
<Link
href={buildResultUrl(rec)}
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-gradient-to-r from-[#04102b] to-[#0a2060] text-white hover:from-[#0a1f5c] hover:to-[#1a3a7a] transition"
>
</Link>
</div>
))}
</div>
</div>
)}
{/* ─── 바로 시작하기 CTA ─── */}
<div className="bg-gradient-to-r from-[#04102b] via-[#0a1f5c] to-[#0d2d8a] rounded-2xl border border-[#1a3a7a] p-8 text-center relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.04]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '25px 25px' }} />
<div className="relative">
<div className="text-3xl mb-3"></div>
<h3 className="text-2xl font-extrabold text-white mb-2"> </h3>
<p className="text-blue-200/60 text-sm mb-6"> , </p>
<Link
href="/saju/input"
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] text-white px-8 py-3.5 rounded-xl font-semibold text-base hover:from-[#1e4fc2] hover:to-[#6d28d9] transition-all shadow-lg shadow-violet-900/40"
>
</Link>
</div>
</div>
{/* ─── 무료 vs 유료 비교표 ─── */}
<div>
<div className="text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b] tracking-tight"> vs </h2>
<p className="text-slate-500 text-sm mt-2"> , AI 4,900</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* 무료 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6 shadow-sm">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center">
<svg className="w-5 h-5 text-[#1a56db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wide">FREE</div>
<div className="text-lg font-extrabold text-[#04102b]"> </div>
</div>
</div>
<ul className="space-y-3">
{[
'사주팔자 원국 (년·월·일·시주)',
'천간·지지·지장간 표',
'십성 및 십이운성',
'오행 분포 차트',
'지지 상호작용 (합·충·형)',
'일간 분석 요약',
].map((item) => (
<li key={item} className="flex items-center gap-2.5 text-sm text-slate-700">
<div className="w-4 h-4 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-[#1a56db]" />
</div>
{item}
</li>
))}
</ul>
<div className="mt-6 pt-5 border-t border-slate-100">
<div className="text-2xl font-extrabold text-[#04102b]"></div>
<div className="text-xs text-slate-500 mt-1"> </div>
<Link
href="/saju/input"
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] hover:bg-blue-50 transition"
>
</Link>
</div>
</div>
{/* 유료 */}
<div className="bg-gradient-to-br from-[#04102b] to-[#0a2060] rounded-2xl border border-[#1a3a7a] p-6 shadow-lg relative overflow-hidden">
<div className="absolute top-4 right-4 bg-amber-400 text-[#04102b] text-xs font-bold px-2 py-0.5 rounded-lg">
4,900
</div>
<div className="absolute bottom-0 right-0 w-32 h-32 rounded-full bg-violet-500/10 blur-2xl" />
<div className="flex items-center gap-3 mb-5 relative">
<div className="w-10 h-10 rounded-xl bg-violet-500/20 border border-violet-400/30 flex items-center justify-center">
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<div className="text-xs font-bold text-violet-300 uppercase tracking-wide">AI PREMIUM</div>
<div className="text-lg font-extrabold text-white">AI </div>
</div>
</div>
<ul className="space-y-3 relative">
{[
'무료 기본 분석 전체 포함',
'신강/신약 정밀 판단',
'용신·희신·기신 추정',
'대운 (10년 주기) 분석',
'올해 세운 흐름',
'GPT-4o AI 12가지 상세 해석',
].map((item) => (
<li key={item} className="flex items-center gap-2.5 text-sm text-blue-200">
<div className="w-4 h-4 rounded-full bg-amber-400/20 border border-amber-400/40 flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400" />
</div>
{item}
</li>
))}
</ul>
<div className="mt-6 pt-5 border-t border-white/10 relative">
<div className="text-2xl font-extrabold text-amber-400">4,900</div>
<div className="text-xs text-blue-300/70 mt-1 mb-4">1 · </div>
{hasPaid ? (
<Link
href="/saju/input"
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
>
</Link>
) : (
<PaymentButton
productId="saju_detail"
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
>
AI
</PaymentButton>
)}
</div>
</div>
</div>
</div>
{/* ─── FAQ ─── */}
<div>
<div className="text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
<h2 className="text-2xl font-extrabold text-[#04102b]"> </h2>
</div>
<div className="space-y-4">
{faqItems.map((item, i) => (
<div key={i} className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-[#1a56db] text-xs font-bold">Q</span>
</div>
<div>
<p className="font-bold text-[#04102b] text-sm mb-2">{item.q}</p>
<p className="text-slate-600 text-sm leading-relaxed">{item.a}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import PaymentButton from '@/app/components/PaymentButton';
interface BirthKey {
birth_year: number;
birth_month: number;
birth_day: number;
birth_hour?: number;
gender: string;
}
interface SajuAISectionProps {
hasPaid: boolean;
savedInterpretation: string | null;
sajuData: object;
daeun: object | null;
daeunList: object[];
gender: string;
birthKey: BirthKey;
currentUrl: string;
}
export default function SajuAISection({
hasPaid,
savedInterpretation,
sajuData,
daeun,
daeunList,
gender,
birthKey,
currentUrl,
}: SajuAISectionProps) {
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>(
savedInterpretation ? 'done' : 'idle'
);
const [interpretation, setInterpretation] = useState(savedInterpretation ?? '');
const called = useRef(false);
useEffect(() => {
if (!hasPaid || savedInterpretation || called.current) return;
called.current = true;
setStatus('loading');
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender }),
})
.then((r) => r.json())
.then((data) => {
if (data.interpretation) {
setInterpretation(data.interpretation);
setStatus('done');
// birthKey 유효성 검사 후 저장 (NaN/null 방지)
const { birth_year, birth_month, birth_day } = birthKey;
if (
typeof birth_year === 'number' && !isNaN(birth_year) &&
typeof birth_month === 'number' && !isNaN(birth_month) &&
typeof birth_day === 'number' && !isNaN(birth_day)
) {
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'));
}, [hasPaid]);
// 미결제 상태
if (!hasPaid) {
return (
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
<div className="relative">
<div className="inline-flex items-center gap-2 bg-amber-400/10 border border-amber-400/25 text-amber-300 text-xs font-semibold px-3 py-1 rounded-full mb-3">
AI PREMIUM
</div>
<h3 className="text-xl font-extrabold text-white mb-2">AI (12 )</h3>
<p className="text-blue-200/60 text-sm mb-5">
, , , , , <br />
GPT-4o가 .
</p>
<PaymentButton
productId="saju_detail"
returnUrl={currentUrl}
className="inline-flex items-center gap-2 bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all shadow-lg"
>
AI · 4,900
</PaymentButton>
</div>
</div>
);
}
// AI 생성 중
if (status === 'loading') {
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 text-center">
<div className="w-10 h-10 border-2 border-violet-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm font-medium">AI가 ...</p>
<p className="text-slate-400 text-xs mt-1"> 20~30 </p>
</div>
);
}
// 오류
if (status === 'error') {
return (
<div className="bg-white rounded-2xl border border-red-200 p-6 text-center">
<p className="text-red-500 text-sm font-medium mb-3">AI .</p>
<button
onClick={() => { called.current = false; setStatus('idle'); }}
className="text-xs text-blue-600 underline"
>
</button>
</div>
);
}
// AI 해석 완료
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<div className="flex items-center gap-2 mb-5 pb-4 border-b border-slate-100">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-500 to-amber-500 flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h2 className="text-lg font-extrabold text-[#04102b]">AI </h2>
<span className="ml-auto text-xs bg-emerald-50 border border-emerald-200 text-emerald-700 font-bold px-2 py-0.5 rounded-full">
</span>
</div>
<div className="prose prose-sm max-w-none text-slate-700 leading-relaxed whitespace-pre-wrap">
{interpretation}
</div>
</div>
);
}

584
app/saju/result/page.tsx Normal file
View File

@@ -0,0 +1,584 @@
import { calculateSaju } from '@/lib/saju-calculator';
import Link from 'next/link';
import { calculateDaeun, getCurrentDaeun, getDaeunDescription } from '@/lib/daeun-calculator';
import { getCurrentSolarTerm, getSolarTermName, getSolarTermMonthBranch } from '@/lib/solar-terms';
import { EARTHLY_BRANCHES_KR, FIVE_ELEMENTS_KR, FIVE_ELEMENTS } from '@/lib/saju-calculator';
import { calculateElementScore, performFullAnalysis } from '@/lib/ai-interpretation';
import { createClient } from '@/lib/supabase/server';
import SajuAISection from './SajuAISection';
interface PageProps {
searchParams: Promise<{
year: string;
month: string;
day: string;
hour?: string;
gender: 'male' | 'female';
calendarType: 'solar' | 'lunar';
originalYear?: string;
originalMonth?: string;
originalDay?: string;
isLeapMonth?: string;
}>;
}
export default async function SajuResultPage({ searchParams }: PageProps) {
const params = await searchParams;
const {
year, month, day, hour, gender, calendarType,
originalYear, originalMonth, originalDay, isLeapMonth
} = params;
const yearNum = parseInt(year, 10);
const monthNum = parseInt(month, 10);
const dayNum = parseInt(day, 10);
const hourNum = hour ? parseInt(hour, 10) : null;
// 필수 파라미터 누락 시 안전한 기본값 (NaN 방지)
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center">
<div className="text-center py-20">
<p className="text-slate-500 text-sm mb-4"> . .</p>
<a href="/saju/input" className="text-blue-600 underline text-sm"> </a>
</div>
</div>
);
}
const inputYear = originalYear ? parseInt(originalYear) : yearNum;
const inputMonth = originalMonth ? parseInt(originalMonth) : monthNum;
const inputDay = originalDay ? parseInt(originalDay) : dayNum;
const isLunar = calendarType === 'lunar';
const isLeap = isLeapMonth === 'true';
const sajuData = calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
// 결제 여부 + 저장된 AI 해석 확인 (서버사이드)
let hasPaid = false;
let savedInterpretation: string | null = null;
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
const { data: order } = await supabase
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', 'saju_detail')
.eq('status', 'paid')
.maybeSingle();
hasPaid = !!order;
if (hasPaid) {
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
.from('saju_records')
.select('interpretation')
.eq('user_id', user.id)
.eq('is_paid', true)
.contains('saju_data', birthKey)
.maybeSingle();
savedInterpretation = record?.interpretation ?? null;
}
}
} catch {
// 인증 오류 시 무시 (미로그인)
}
// 절기 정보
const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
const solarTermName = getSolarTermName(solarTermIndex);
const monthBranchIndex = getSolarTermMonthBranch(yearNum, monthNum, dayNum);
const monthBranchName = EARTHLY_BRANCHES_KR[monthBranchIndex];
// 종합 분석 수행
const analysis = performFullAnalysis(sajuData);
const elementScores = analysis.elementScores;
// 대운 계산
const daeunList = calculateDaeun(
yearNum, monthNum, dayNum, gender,
sajuData.month.stem, sajuData.month.branch
);
const currentYear = new Date().getFullYear();
const currentDaeun = getCurrentDaeun(daeunList, currentYear);
// 오행 색상 매핑
const elementColors: { [key: string]: string } = {
'木': 'text-green-700', '火': 'text-red-600', '土': 'text-yellow-700',
'金': 'text-amber-600', '水': 'text-blue-700',
};
const elementBgColors: { [key: string]: string } = {
'木': 'bg-green-50 border-green-400', '火': 'bg-red-50 border-red-400',
'土': 'bg-yellow-50 border-yellow-400', '金': 'bg-amber-50 border-amber-400',
'水': 'bg-blue-50 border-blue-400',
};
// 띠 계산
const zodiacAnimals = ['쥐', '소', '호랑이', '토끼', '용', '뱀', '말', '양', '원숭이', '닭', '개', '돼지'];
const zodiacIndex = (yearNum - 4) % 12;
const zodiacAnimal = zodiacAnimals[zodiacIndex >= 0 ? zodiacIndex : zodiacIndex + 12];
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
</div>
<h1 className="text-3xl font-extrabold text-white mb-2"> </h1>
<p className="text-blue-200/60 text-sm"> AI </p>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
<div className="grid lg:grid-cols-[280px_1fr] gap-6">
{/* 사이드바 - 기본 정보 */}
<aside className="lg:sticky lg:top-6 h-fit">
<div className="bg-[#04102b] rounded-2xl p-6 text-white">
<h2 className="text-base font-bold mb-5 text-center pb-4 border-b border-white/10">
</h2>
<div className="space-y-4 text-sm">
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">
{isLunar ? (
<div>
<div> {inputYear}.{inputMonth}.{inputDay}{isLeap ? ' (윤달)' : ''}</div>
<div className="text-xs text-blue-300/50 mt-0.5"> {yearNum}.{monthNum}.{dayNum}</div>
</div>
) : (
<div>{yearNum}.{monthNum}.{dayNum}</div>
)}
</div>
</div>
{hourNum !== null && (
<div>
<div className="text-blue-300/60 mb-1"> </div>
<div className="font-bold">{hourNum}</div>
</div>
)}
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{gender === 'male' ? '남성' : '여성'}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{zodiacAnimal}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold text-2xl text-amber-400">
{sajuData.day.stem} ({sajuData.day.stemKr})
</div>
<div className="text-xs text-blue-300/60 mt-1">
{FIVE_ELEMENTS_KR[sajuData.day.element as keyof typeof FIVE_ELEMENTS_KR]}({sajuData.day.element})
</div>
</div>
</div>
<div className="mt-5 pt-5 border-t border-white/10 space-y-2">
<Link
href="/saju/input"
className="block w-full text-center bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition text-sm font-medium"
>
</Link>
<Link
href="/saju"
className="block w-full text-center bg-violet-500/20 hover:bg-violet-500/30 text-violet-300 px-4 py-2 rounded-lg transition text-sm font-medium"
>
</Link>
</div>
</div>
</aside>
{/* 메인 콘텐츠 */}
<main className="space-y-6">
{/* 사주팔자 표 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-xl font-extrabold text-[#04102b] mb-5 text-center"> ()</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-[#04102b] text-white">
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
{sajuData.hour && <th className="py-2.5 px-3 text-center font-bold text-xs"></th>}
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
</tr>
</thead>
<tbody>
{/* 천간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.stemKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.stemKr}</div>
<div className="text-xs text-amber-600 font-bold mt-0.5"></div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.stemKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.stemKr}</div>
</td>
</tr>
{/* 지지 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.branchKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.branchKr}</div>
</td>
</tr>
{/* 지장간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">
<div></div>
<div className="text-[10px] text-slate-400 font-normal"> </div>
</td>
{(() => {
const pillars = sajuData.hour
? [analysis.hiddenStems.find(h => h.pillar === '시주'), analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')]
: [analysis.hiddenStems.find(h => h.pillar === '일주'), analysis.hiddenStems.find(h => h.pillar === '월주'), analysis.hiddenStems.find(h => h.pillar === '년주')];
return pillars.map((h, idx) => (
<td key={idx} className={`py-2 px-2 text-center ${h?.pillar === '일주' ? 'bg-amber-50' : ''}`}>
{h && (
<div className="flex flex-wrap justify-center gap-1">
{h.stems.map((s, si) => (
<span
key={si}
className={`inline-block px-1.5 py-0.5 rounded text-xs font-semibold border ${elementBgColors[s.element] || 'bg-gray-100'}`}
title={s.role}
>
{s.stemKr}
</span>
))}
</div>
)}
</td>
));
})()}
</tr>
{/* 십성 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.tenGod}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.tenGod}</div>
</td>
</tr>
{/* 십이운성 */}
<tr>
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.fortune}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.fortune}</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* 지지 상호작용 */}
{analysis.branchInteractions.length > 0 && (
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-3 text-center"> </h3>
<div className="flex flex-wrap justify-center gap-2">
{analysis.branchInteractions.map((inter, idx) => {
const isPositive = inter.type.includes('합');
const isNegative = inter.type.includes('충') || inter.type.includes('형');
const colorClass = isPositive
? 'bg-emerald-50 border-emerald-400 text-emerald-800'
: isNegative
? 'bg-red-50 border-red-400 text-red-800'
: 'bg-amber-50 border-amber-400 text-amber-800';
return (
<span key={idx} className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold border ${colorClass}`}>
{inter.type} {inter.branchesKr.join('')}
{inter.resultElement && `${FIVE_ELEMENTS_KR[inter.resultElement as keyof typeof FIVE_ELEMENTS_KR]}`}
</span>
);
})}
</div>
</div>
)}
{/* 오행 균형 */}
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-4 text-center"> </h3>
<div className="grid grid-cols-5 gap-2">
{Object.entries(elementScores).map(([element, score]) => (
<div key={element} className="text-center">
<div className={`text-lg font-bold mb-1 ${elementColors[element] || ''}`}>{element}</div>
<div className="text-xs text-slate-500 mb-2">
{FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR]}
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5 mb-1">
<div
className={`h-1.5 rounded-full transition-all ${element === sajuData.day.element
? 'bg-gradient-to-r from-[#1a56db] to-[#7c3aed]'
: 'bg-slate-400'
}`}
style={{ width: `${Math.max(score, 5)}%` }}
/>
</div>
<div className="text-xs font-bold text-[#04102b]">{score}%</div>
</div>
))}
</div>
</div>
</div>
{/* 분석 카드 그리드 */}
<div className="grid md:grid-cols-2 gap-6">
{/* 신강/신약 + 용신 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> </h3>
<div className="flex items-center gap-3 mb-4">
<span className={`inline-block px-4 py-1.5 rounded-xl text-sm font-bold ${
analysis.dayMasterStrength.result === '신강'
? 'bg-red-100 text-red-700 border-2 border-red-400'
: analysis.dayMasterStrength.result === '신약'
? 'bg-blue-100 text-blue-700 border-2 border-blue-400'
: 'bg-green-100 text-green-700 border-2 border-green-400'
}`}>
{analysis.dayMasterStrength.result}
</span>
<span className="text-slate-500 text-xs">: {analysis.dayMasterStrength.score}</span>
</div>
<ul className="space-y-1 text-xs text-slate-500 mb-5">
{analysis.dayMasterStrength.reasons.map((r, i) => (
<li key={i} className="flex items-start">
<span className="text-amber-500 mr-1.5">-</span>
<span>{r}</span>
</li>
))}
</ul>
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2.5 text-sm"> / / </h4>
<div className="flex flex-wrap gap-2 mb-3">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.yongShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.yongShinKr}
</span>
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.heeShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.heeShinKr}
</span>
<span className="px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 border border-slate-300 text-slate-700">
: {analysis.yongShin.giShinKr}
</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">{analysis.yongShin.explanation}</p>
</div>
</div>
{/* 신살 + 공망 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> ()</h3>
{analysis.shinsal.length > 0 ? (
<div className="space-y-2 mb-5">
{analysis.shinsal.map((s, i) => (
<div key={i} className="flex items-start gap-2 p-3 rounded-xl bg-[#f0f5ff]">
<span className="inline-block px-2 py-0.5 bg-[#04102b] text-white rounded-lg text-xs font-bold whitespace-nowrap">
{s.name}
</span>
<div>
<div className="text-xs font-semibold text-[#04102b]">
{s.pillar} {s.branchKr}
</div>
<div className="text-xs text-slate-500 mt-0.5">{s.description}</div>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-xs mb-5"> .</p>
)}
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm"> ()</h4>
<div className="flex gap-2 mb-2">
{analysis.gongmang.branchesKr.map((bk, i) => (
<span key={i} className="px-2.5 py-1 bg-[#04102b] text-white rounded-lg text-xs font-bold">
{bk}
</span>
))}
</div>
<p className="text-xs text-slate-500 leading-relaxed">{analysis.gongmang.description}</p>
</div>
{/* 세운 정보 */}
<div className="border-t border-slate-100 pt-4 mt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm">
{analysis.seun.year}
</h4>
<div className="flex items-center gap-2 mb-2">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.seun.element] || 'bg-gray-100'}`}>
{analysis.seun.stemKr}{analysis.seun.branchKr}
</span>
<span className="text-xs text-slate-500">{analysis.seun.elementKr} </span>
</div>
{analysis.seun.interactions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{analysis.seun.interactions.map((si, i) => (
<span key={i} className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
si.type.includes('합') ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
}`}>
{si.type} {si.branchesKr.join('')}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* AI 상세 해석 섹션 */}
{(() => {
const birthKey = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender, ...(hourNum !== null ? { birth_hour: hourNum } : {}) };
const currentUrl = `/saju/result?year=${yearNum}&month=${monthNum}&day=${dayNum}${hourNum !== null ? `&hour=${hourNum}` : ''}&gender=${gender}&calendarType=${calendarType}${originalYear ? `&originalYear=${originalYear}&originalMonth=${originalMonth}&originalDay=${originalDay}` : ''}${isLeap ? '&isLeapMonth=true' : ''}`;
return (
<SajuAISection
hasPaid={hasPaid}
savedInterpretation={savedInterpretation}
sajuData={sajuData}
daeun={currentDaeun}
daeunList={daeunList}
gender={gender}
birthKey={birthKey}
currentUrl={currentUrl}
/>
);
})()}
{/* 대운 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-lg font-extrabold text-[#04102b] mb-5 text-center">
() 10
</h2>
{currentDaeun && (
<div className="bg-gradient-to-r from-[#04102b] to-[#0a2060] rounded-2xl p-5 mb-5 text-white">
<h3 className="text-sm font-bold mb-3 text-center text-blue-300"> </h3>
<div className="text-center mb-3">
<div className="text-3xl font-bold mb-1">
{currentDaeun.stem}{currentDaeun.branch}
</div>
<div className="text-base text-blue-200">
{currentDaeun.stemKr}{currentDaeun.branchKr}
</div>
<div className="text-xs text-blue-300/70 mt-1">
{currentDaeun.age} ~ {currentDaeun.age + 9} ({currentDaeun.startYear} ~ {currentDaeun.endYear})
</div>
</div>
<p className="text-center leading-relaxed text-xs text-blue-200/80">
{getDaeunDescription(currentDaeun, sajuData.day.stem)}
</p>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{daeunList.map((daeun, index) => {
const isCurrent = currentDaeun &&
daeun.startYear === currentDaeun.startYear &&
daeun.endYear === currentDaeun.endYear;
return (
<div
key={index}
className={`rounded-xl p-3 border-2 transition ${isCurrent
? 'bg-amber-50 border-amber-400'
: 'bg-white border-[#dbe8ff]'
}`}
>
<div className="text-center">
<div className="text-xl font-bold text-[#04102b] mb-0.5">
{daeun.stem}{daeun.branch}
</div>
<div className="text-xs text-slate-500 mb-1.5">
{daeun.stemKr}{daeun.branchKr}
</div>
<div className="text-xs text-slate-400">
{daeun.age} ~ {daeun.age + 9}
</div>
<div className="text-xs text-slate-400">
{daeun.startYear} ~ {daeun.endYear}
</div>
{isCurrent && (
<div className="mt-1.5">
<span className="inline-block bg-[#04102b] text-white text-xs px-2.5 py-0.5 rounded-full font-semibold">
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</main>
</div>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '../../components/ContactModal';
import PaymentButton from '../../components/PaymentButton';
const CHECKLIST = [
'구독 플랜 선택 (기본 / 프리미엄 / 연간)',
@@ -24,6 +25,7 @@ const plans = [
'이메일 발송',
],
highlight: false,
productId: 'lotto_basic',
},
{
name: '프리미엄 플랜',
@@ -38,6 +40,7 @@ const plans = [
'이메일 + 텔레그램 알림',
],
highlight: true,
productId: 'lotto_premium',
},
{
name: '연간 플랜',
@@ -51,6 +54,7 @@ const plans = [
'2개월 무료 혜택',
],
highlight: false,
productId: 'lotto_annual',
},
];
@@ -210,14 +214,14 @@ export default function LottoPage() {
</li>
))}
</ul>
<button
onClick={() => openModal(`로또 번호 추천 - ${plan.name}`)}
<PaymentButton
productId={plan.productId}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-amber-400 text-[#04102b] hover:bg-amber-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</button>
</PaymentButton>
</div>
))}
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '../../components/ContactModal';
import PaymentButton from '../../components/PaymentButton';
const CHECKLIST = [
'사용 중인 증권사 확인 (키움증권 / 한국투자증권 권장)',
@@ -29,6 +30,7 @@ const plans = [
desc: '1개 종목 자동 매매',
features: ['1개 종목 모니터링', '텔레그램 매매 알림', '기본 기술적 분석 전략', '손절/익절 자동 설정', '월간 손익 리포트'],
highlight: false,
installProductId: 'stock_starter_install',
},
{
name: '프로',
@@ -37,6 +39,7 @@ const plans = [
desc: '최대 5개 종목 + 전략 커스터마이징',
features: ['최대 5개 종목 동시 운영', '전략 파라미터 커스터마이징', '다중 기술적 지표 조합', '실시간 포트폴리오 현황', '주간 성과 분석 리포트', '1개월 무상 기술 지원'],
highlight: true,
installProductId: 'stock_pro_install',
},
{
name: '엔터프라이즈',
@@ -45,6 +48,7 @@ const plans = [
desc: '무제한 종목 + 맞춤 전략 개발',
features: ['종목 제한 없음', '완전 맞춤 전략 개발', '백테스팅 리포트 제공', '전용 서버 구성 가능', '24시간 모니터링', '전담 유지보수 계약'],
highlight: false,
installProductId: null,
},
];
@@ -206,14 +210,23 @@ export default function StockPage() {
</li>
))}
</ul>
<button
onClick={() => openModal(`주식 자동 매매 - ${plan.name}`)}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-emerald-400 text-[#011225] hover:bg-emerald-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</button>
{plan.installProductId ? (
<PaymentButton
productId={plan.installProductId}
className={`block w-full text-center py-3 rounded-xl text-sm font-bold transition ${
plan.highlight ? 'bg-emerald-400 text-[#011225] hover:bg-emerald-300' : 'bg-[#04102b] text-white hover:bg-[#0a1f5c]'
}`}
>
</PaymentButton>
) : (
<button
onClick={() => openModal(`주식 자동 매매 - ${plan.name}`)}
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-[#04102b] text-white hover:bg-[#0a1f5c]"
>
</button>
)}
</div>
))}
</div>

386
lib/ai-interpretation.ts Normal file
View File

@@ -0,0 +1,386 @@
import {
SajuData, FIVE_ELEMENTS, HEAVENLY_STEMS,
getHiddenStems, getAllHiddenStems,
analyzeBranchInteractions, calculateShinsal, calculateGongmang,
getYearGanzi, FIVE_ELEMENTS_KR, EARTHLY_BRANCHES_KR, EARTHLY_BRANCHES,
BranchInteraction, Shinsal,
} from './saju-calculator';
import { DaeunPillar } from './daeun-calculator';
// ============================================================
// 오행 밸런스 정밀 분석 (가중치 적용)
// ============================================================
export interface ElementBalance {
: number;
: number;
: number;
: number;
: number;
}
/**
* 가중치 적용 오행 점수 계산
* - 천간: 1.0
* - 지지 본기(정기): 1.0
* - 지장간 중기: 0.5
* - 지장간 여기: 0.3
*/
export function calculateDetailedElementBalance(saju: SajuData): ElementBalance {
const balance: ElementBalance = { : 0, : 0, : 0, : 0, : 0 };
// 천간 오행 (각 1.0)
const stems = [saju.year.stem, saju.month.stem, saju.day.stem];
if (saju.hour) stems.push(saju.hour.stem);
for (const stem of stems) {
const elem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
if (elem) balance[elem] += 1.0;
}
// 지지 지장간 (본기 1.0, 중기 0.5, 여기 0.3)
const branches = [saju.year.branch, saju.month.branch, saju.day.branch];
if (saju.hour) branches.push(saju.hour.branch);
const weights = [1.0, 0.5, 0.3];
for (const branch of branches) {
const hidden = getHiddenStems(branch);
for (let i = 0; i < hidden.length; i++) {
const elem = FIVE_ELEMENTS[hidden[i] as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
if (elem) balance[elem] += weights[i] || 0.3;
}
}
// 소수점 둘째 자리로 반올림
for (const key of Object.keys(balance) as (keyof ElementBalance)[]) {
balance[key] = Math.round(balance[key] * 100) / 100;
}
return balance;
}
/**
* 오행 비율(%) 계산
*/
export function calculateElementScore(saju: SajuData): { [key: string]: number } {
const balance = calculateDetailedElementBalance(saju);
const total = Object.values(balance).reduce((a, b) => a + b, 0);
const scores: { [key: string]: number } = {};
for (const [element, value] of Object.entries(balance)) {
scores[element] = total > 0 ? Math.round((value / total) * 100) : 0;
}
return scores;
}
// ============================================================
// 신강/신약 자동 판단
// ============================================================
export interface DayMasterStrength {
result: '신강' | '신약' | '중화';
score: number;
reasons: string[];
}
const PRODUCE_MAP: { [key: string]: string } = {
'木': '火', '火': '土', '土': '金', '金': '水', '水': '木',
};
function getProducingElement(elem: string): string {
for (const [k, v] of Object.entries(PRODUCE_MAP)) {
if (v === elem) return k;
}
return '';
}
/**
* 신강/신약 판단
*/
export function analyzeDayMasterStrength(saju: SajuData): DayMasterStrength {
const dayStem = saju.dayStem;
const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
const producingElement = getProducingElement(dayElement);
const reasons: string[] = [];
let score = 0;
// 1. 월령 득령 확인
const monthBranch = saju.month.branch;
const monthHidden = getHiddenStems(monthBranch);
const monthMainElement = FIVE_ELEMENTS[monthHidden[0] as keyof typeof FIVE_ELEMENTS];
if (monthMainElement === dayElement) {
score += 3;
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간과 같은 ${FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR]}으로 강한 힘을 받음`);
} else if (monthMainElement === producingElement) {
score += 2;
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 생하는 ${FIVE_ELEMENTS_KR[producingElement as keyof typeof FIVE_ELEMENTS_KR]}으로 힘을 받음`);
} else {
score -= 2;
reasons.push(`월령 실령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 돕지 않음`);
}
// 2. 통근 확인
const allBranches = [saju.year.branch, saju.month.branch, saju.day.branch];
if (saju.hour) allBranches.push(saju.hour.branch);
let rootCount = 0;
for (const branch of allBranches) {
const hidden = getHiddenStems(branch);
for (const h of hidden) {
const hElem = FIVE_ELEMENTS[h as keyof typeof FIVE_ELEMENTS];
if (hElem === dayElement || hElem === producingElement) {
rootCount++;
break;
}
}
}
if (rootCount >= 3) {
score += 2;
reasons.push(`통근 강함: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
} else if (rootCount >= 2) {
score += 1;
reasons.push(`통근 보통: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
} else {
score -= 1;
reasons.push(`통근 약함: ${rootCount}개 지지에서만 일간의 뿌리를 찾음`);
}
// 3. 투출 확인
const allStems = [saju.year.stem, saju.month.stem];
if (saju.hour) allStems.push(saju.hour.stem);
let helpingStemCount = 0;
for (const stem of allStems) {
const stemElem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS];
if (stemElem === dayElement || stemElem === producingElement) {
helpingStemCount++;
}
}
if (helpingStemCount >= 2) {
score += 2;
reasons.push(`투출 강함: 천간에 비겁/인성이 ${helpingStemCount}개 있어 일간을 도움`);
} else if (helpingStemCount === 1) {
score += 1;
reasons.push(`투출 보통: 천간에 비겁/인성이 1개 있음`);
} else {
score -= 1;
reasons.push(`투출 없음: 천간에 일간을 돕는 비겁/인성이 없음`);
}
// 4. 오행 비율 기반 조력 분석
const balance = calculateDetailedElementBalance(saju);
const helpingScore = balance[dayElement as keyof ElementBalance] + balance[producingElement as keyof ElementBalance];
const drainingScore = Object.entries(balance)
.filter(([k]) => k !== dayElement && k !== producingElement)
.reduce((sum, [, v]) => sum + v, 0);
if (helpingScore > drainingScore * 1.3) {
score += 1;
reasons.push(`오행 비율: 비겁+인성(${helpingScore.toFixed(1)}) > 식상+재관(${drainingScore.toFixed(1)}) → 일간 세력 우세`);
} else if (drainingScore > helpingScore * 1.3) {
score -= 1;
reasons.push(`오행 비율: 식상+재관(${drainingScore.toFixed(1)}) > 비겁+인성(${helpingScore.toFixed(1)}) → 일간 세력 열세`);
}
let result: '신강' | '신약' | '중화';
if (score >= 3) result = '신강';
else if (score <= -2) result = '신약';
else result = '중화';
return { result, score, reasons };
}
// ============================================================
// 용신 (用神) 추정
// ============================================================
export interface YongShinResult {
yongShin: string;
yongShinKr: string;
heeShin: string;
heeShinKr: string;
giShin: string;
giShinKr: string;
explanation: string;
}
const OVERCOME_MAP: { [key: string]: string } = {
'木': '土', '火': '金', '土': '水', '金': '木', '水': '火',
};
function getOvercomingMe(elem: string): string {
for (const [k, v] of Object.entries(OVERCOME_MAP)) {
if (v === elem) return k;
}
return '';
}
export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): YongShinResult {
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
const balance = calculateDetailedElementBalance(saju);
const producingMe = getProducingElement(dayElement); // 인성
const myProduct = PRODUCE_MAP[dayElement]; // 식상
const myOvercome = OVERCOME_MAP[dayElement]; // 재성
const overcomeMe = getOvercomingMe(dayElement); // 관살
const kr = (e: string) => FIVE_ELEMENTS_KR[e as keyof typeof FIVE_ELEMENTS_KR] || e;
if (strength.result === '신강') {
const candidates = [
{ elem: myProduct, score: balance[myProduct as keyof ElementBalance], name: '식상' },
{ elem: myOvercome, score: balance[myOvercome as keyof ElementBalance], name: '재성' },
{ elem: overcomeMe, score: balance[overcomeMe as keyof ElementBalance], name: '관살' },
];
candidates.sort((a, b) => a.score - b.score);
const yong = candidates[0];
const hee = candidates[1];
return {
yongShin: yong.elem, yongShinKr: kr(yong.elem),
heeShin: hee.elem, heeShinKr: kr(hee.elem),
giShin: dayElement, giShinKr: kr(dayElement),
explanation: `신강한 사주로 일간의 힘이 넘치므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
};
} else if (strength.result === '신약') {
const candidates = [
{ 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);
const yong = candidates[0];
const hee = candidates[1];
return {
yongShin: yong.elem, yongShinKr: kr(yong.elem),
heeShin: hee.elem, heeShinKr: kr(hee.elem),
giShin: overcomeMe, giShinKr: kr(overcomeMe),
explanation: `신약한 사주로 일간의 힘이 부족하므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
};
} else {
const entries = Object.entries(balance) as [string, number][];
entries.sort((a, b) => a[1] - b[1]);
const yong = entries[0];
const hee = entries[1];
const gi = entries[entries.length - 1];
return {
yongShin: yong[0], yongShinKr: kr(yong[0]),
heeShin: hee[0], heeShinKr: kr(hee[0]),
giShin: gi[0], giShinKr: kr(gi[0]),
explanation: `중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. 가장 부족한 ${kr(yong[0])}(${yong[0]}) 기운을 보충하면 더욱 좋아집니다.`,
};
}
}
// ============================================================
// 세운 (歲運) 계산
// ============================================================
export interface SeunInfo {
stem: string;
branch: string;
stemKr: string;
branchKr: string;
element: string;
elementKr: string;
year: number;
interactions: BranchInteraction[];
}
export function calculateSeun(year: number, saju: SajuData): SeunInfo {
const ganzi = getYearGanzi(year);
const element = FIVE_ELEMENTS[ganzi.stem as keyof typeof FIVE_ELEMENTS];
const seunBranch = ganzi.branch;
const seunBranchKr = ganzi.branchKr;
const interactions: BranchInteraction[] = [];
const pillarBranches = [
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
}
const CHUNG: [string, string][] = [
['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
];
for (const [a, b] of CHUNG) {
for (const pb of pillarBranches) {
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
interactions.push({
type: '충(沖)', branches: [seunBranch, pb.branch],
branchesKr: [seunBranchKr, pb.branchKr],
pillars: ['세운', pb.pillar],
description: `세운 ${seunBranchKr}${pb.pillar} ${pb.branchKr}가 충 → 해당 영역에 변동과 변화가 예상됨.`,
});
}
}
}
const YUKAP: [string, string, string][] = [
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
];
for (const [a, b, elem] of YUKAP) {
for (const pb of pillarBranches) {
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
interactions.push({
type: '합(合)', branches: [seunBranch, pb.branch],
branchesKr: [seunBranchKr, pb.branchKr],
pillars: ['세운', pb.pillar],
description: `세운 ${seunBranchKr}${pb.pillar} ${pb.branchKr}가 합 → 해당 영역에 조화와 좋은 인연이 기대됨.`,
resultElement: elem,
});
}
}
}
return {
stem: ganzi.stem, branch: ganzi.branch,
stemKr: ganzi.stemKr, branchKr: ganzi.branchKr,
element, elementKr: FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR],
year, interactions,
};
}
// ============================================================
// 종합 분석 데이터 구조체
// ============================================================
export interface SajuAnalysis {
elementBalance: ElementBalance;
elementScores: { [key: string]: number };
dayMasterStrength: DayMasterStrength;
yongShin: YongShinResult;
branchInteractions: BranchInteraction[];
shinsal: Shinsal[];
gongmang: { branches: string[]; branchesKr: string[]; description: string };
seun: SeunInfo;
hiddenStems: { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[];
}
export function performFullAnalysis(saju: SajuData, currentYear: number = new Date().getFullYear()): SajuAnalysis {
const elementBalance = calculateDetailedElementBalance(saju);
const elementScores = calculateElementScore(saju);
const dayMasterStrength = analyzeDayMasterStrength(saju);
const yongShin = estimateYongShin(saju, dayMasterStrength);
const branchInteractions = analyzeBranchInteractions(saju);
const shinsal = calculateShinsal(saju);
const gongmang = calculateGongmang(saju.dayStem, saju.day.branch);
const seun = calculateSeun(currentYear, saju);
const hiddenStems = getAllHiddenStems(saju);
return {
elementBalance, elementScores, dayMasterStrength, yongShin,
branchInteractions, shinsal, gongmang, seun, hiddenStems,
};
}

188
lib/daeun-calculator.ts Normal file
View File

@@ -0,0 +1,188 @@
import { HEAVENLY_STEMS, EARTHLY_BRANCHES, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES_KR } from './saju-calculator';
/**
* 대운 (大運) 정보
*/
export interface DaeunPillar {
age: number; // 시작 나이
startYear: number; // 시작 년도
endYear: number; // 끝 년도
stem: string; // 천간
branch: string; // 지지
stemKr: string; // 천간 한글
branchKr: string; // 지지 한글
}
/**
* 대운 시작 나이 정밀 계산
* @param birthYear 생년
* @param birthMonth 생월
* @param birthDay 생일
* @param gender 성별
* @param isYangYear 양년 여부
* @returns 대운 시작 나이
*/
function calculateDaeunStartAge(
birthYear: number,
birthMonth: number,
birthDay: number,
gender: 'male' | 'female',
isYangYear: boolean
): number {
const { getDaysToNextSolarTerm, getCurrentSolarTerm, getSolarTermDate } = require('./solar-terms');
// 양남음녀는 순행 (다음 절기까지), 음남양녀는 역행 (이전 절기부터)
let days: number;
if ((gender === 'male' && isYangYear) || (gender === 'female' && !isYangYear)) {
// 순행: 생일부터 다음 절기까지의 일수
days = getDaysToNextSolarTerm(birthYear, birthMonth, birthDay);
} else {
// 역행: 이전 절기부터 생일까지의 일수
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);
const birthDateObj = new Date(birthYear, birthMonth - 1, birthDay);
const diffTime = birthDateObj.getTime() - termDateObj.getTime();
days = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
// 3일 = 1세 (대운수)
// 정확히는 3일당 1세이지만, 일수를 3으로 나눈 몫
const startAge = Math.floor(days / 3);
// 최소 1세, 최대 10세로 제한
return Math.max(1, Math.min(10, startAge));
}
/**
* 대운 계산
* @param birthYear 생년
* @param birthMonth 생월
* @param birthDay 생일
* @param gender 성별
* @param monthStem 월주 천간 인덱스
* @param monthBranch 월주 지지 인덱스
* @returns 대운 배열 (10년 단위)
*/
export function calculateDaeun(
birthYear: number,
birthMonth: number,
birthDay: number,
gender: 'male' | 'female',
monthStem: string,
monthBranch: string
): DaeunPillar[] {
const monthStemIndex = HEAVENLY_STEMS.indexOf(monthStem as any);
const monthBranchIndex = EARTHLY_BRANCHES.indexOf(monthBranch as any);
if (monthStemIndex === -1 || monthBranchIndex === -1) {
return [];
}
// 양남음녀(陽男陰女)는 순행, 음남양녀(陰男陽女)는 역행
const yearStemIndex = (birthYear - 1900 + 6) % 10;
const isYangYear = yearStemIndex % 2 === 0; // 양년
let isForward: boolean;
if (gender === 'male') {
isForward = isYangYear; // 양남: 순행, 음남: 역행
} else {
isForward = !isYangYear; // 양녀: 역행, 음녀: 순행
}
// 대운 시작 나이 정밀 계산 (절기 기준)
const startAge = calculateDaeunStartAge(birthYear, birthMonth, birthDay, gender, isYangYear);
const daeunList: DaeunPillar[] = [];
for (let i = 0; i < 8; i++) {
const age = startAge + (i * 10);
const startYear = birthYear + age;
const endYear = startYear + 9;
let stemIndex: number;
let branchIndex: number;
if (isForward) {
// 순행: 월주에서 증가
stemIndex = (monthStemIndex + i + 1) % 10;
branchIndex = (monthBranchIndex + i + 1) % 12;
} else {
// 역행: 월주에서 감소
stemIndex = (monthStemIndex - i - 1 + 100) % 10;
branchIndex = (monthBranchIndex - i - 1 + 120) % 12;
}
daeunList.push({
age,
startYear,
endYear,
stem: HEAVENLY_STEMS[stemIndex],
branch: EARTHLY_BRANCHES[branchIndex],
stemKr: HEAVENLY_STEMS_KR[stemIndex],
branchKr: EARTHLY_BRANCHES_KR[branchIndex]
});
}
return daeunList;
}
/**
* 현재 대운 찾기
* @param daeunList 대운 목록
* @param currentYear 현재 년도
*/
export function getCurrentDaeun(daeunList: DaeunPillar[], currentYear: number): DaeunPillar | null {
for (const daeun of daeunList) {
if (currentYear >= daeun.startYear && currentYear <= daeun.endYear) {
return daeun;
}
}
return null;
}
/**
* 대운 해석
* @param daeun 대운 정보
* @param dayStem 일간
*/
export function getDaeunDescription(daeun: DaeunPillar, dayStem: string): string {
const age = daeun.age;
const ganzi = `${daeun.stem}${daeun.branch}`;
let description = `${age}세부터 ${age + 9}세까지의 10년은 ${daeun.stemKr}${daeun.branchKr}(${ganzi}) 대운입니다. `;
// 대운 천간과 일간의 관계에 따른 기본 해석
const stemIndex = HEAVENLY_STEMS.indexOf(daeun.stem as any);
if (age < 20) {
description += '청소년기로 학업과 기초를 다지는 시기입니다. ';
} else if (age < 40) {
description += '성장과 발전의 시기로 사회활동이 왕성한 때입니다. ';
} else if (age < 60) {
description += '안정과 성숙의 시기로 경험이 쌓이는 때입니다. ';
} else {
description += '원숙한 시기로 인생의 지혜를 나누는 때입니다. ';
}
if (stemIndex % 2 === 0) {
description += '적극적이고 외향적인 활동이 유리합니다.';
} else {
description += '차분하고 내실을 다지는 것이 좋습니다.';
}
return description;
}

97
lib/lunar-utils.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* 음력-양력 변환 유틸리티
*/
interface LunarDate {
year: number;
month: number;
day: number;
isLeap: boolean;
}
interface SolarDate {
year: number;
month: number;
day: number;
}
/**
* 음력을 양력으로 변환
* @param lunarYear 음력 년
* @param lunarMonth 음력 월
* @param lunarDay 음력 일
* @param isLeapMonth 윤달 여부
*/
export function lunarToSolar(
lunarYear: number,
lunarMonth: number,
lunarDay: number,
isLeapMonth: boolean = false
): SolarDate {
try {
const lunar = require('lunar-calendar');
const result = lunar.lunarToSolar(lunarYear, lunarMonth, lunarDay, isLeapMonth);
return {
year: result.year,
month: result.month,
day: result.day
};
} catch (error) {
console.error('음력 변환 오류:', error);
// 변환 실패시 입력값 그대로 반환
return {
year: lunarYear,
month: lunarMonth,
day: lunarDay
};
}
}
/**
* 양력을 음력으로 변환
* @param solarYear 양력 년
* @param solarMonth 양력 월
* @param solarDay 양력 일
*/
export function solarToLunar(
solarYear: number,
solarMonth: number,
solarDay: number
): LunarDate {
try {
const lunar = require('lunar-calendar');
const result = lunar.solarToLunar(solarYear, solarMonth, solarDay);
return {
year: result.year,
month: result.month,
day: result.day,
isLeap: result.isLeap || false
};
} catch (error) {
console.error('양력 변환 오류:', error);
// 변환 실패시 입력값 그대로 반환
return {
year: solarYear,
month: solarMonth,
day: solarDay,
isLeap: false
};
}
}
/**
* 음력 날짜를 문자열로 변환
*/
export function formatLunarDate(lunar: LunarDate): string {
const leapText = lunar.isLeap ? '윤' : '';
return `음력 ${lunar.year}${leapText}${lunar.month}${lunar.day}`;
}
/**
* 양력 날짜를 문자열로 변환
*/
export function formatSolarDate(solar: SolarDate): string {
return `양력 ${solar.year}${solar.month}${solar.day}`;
}

66
lib/products.ts Normal file
View File

@@ -0,0 +1,66 @@
export interface Product {
id: string;
name: string;
price: number;
type: 'one_time' | 'monthly' | 'annual';
description: string;
}
export const PRODUCTS: Record<string, Product> = {
lotto_basic: {
id: 'lotto_basic',
name: '로또 기본 플랜',
price: 4900,
type: 'monthly',
description: '매주 5개 번호 조합 이메일 제공',
},
lotto_premium: {
id: 'lotto_premium',
name: '로또 프리미엄 플랜',
price: 9900,
type: 'monthly',
description: '매주 3회 번호 + 텔레그램 알림',
},
lotto_annual: {
id: 'lotto_annual',
name: '로또 연간 플랜',
price: 89900,
type: 'annual',
description: '프리미엄 12개월 (2개월 무료)',
},
stock_starter_install: {
id: 'stock_starter_install',
name: '주식 스타터 설치',
price: 99000,
type: 'one_time',
description: '1개 종목 자동 매매 설치',
},
stock_pro_install: {
id: 'stock_pro_install',
name: '주식 프로 설치',
price: 199000,
type: 'one_time',
description: '5개 종목 + 전략 커스터마이징 설치',
},
stock_starter_monthly: {
id: 'stock_starter_monthly',
name: '주식 스타터 월 유지비',
price: 29000,
type: 'monthly',
description: '스타터 월 유지보수 비용',
},
stock_pro_monthly: {
id: 'stock_pro_monthly',
name: '주식 프로 월 유지비',
price: 49000,
type: 'monthly',
description: '프로 월 유지보수 비용',
},
saju_detail: {
id: 'saju_detail',
name: 'AI 사주 상세 리포트',
price: 4900,
type: 'one_time',
description: 'AI 12가지 항목 상세 해석',
},
};

223
lib/saju-ai-prompt.ts Normal file
View File

@@ -0,0 +1,223 @@
import { SajuData, FIVE_ELEMENTS, FIVE_ELEMENTS_KR, HEAVENLY_STEMS_KR } from './saju-calculator';
import { DaeunPillar } from './daeun-calculator';
import { SajuAnalysis } from './ai-interpretation';
export function createSajuPrompt(
saju: SajuData,
currentDaeun: DaeunPillar | null,
gender: 'male' | 'female',
analysis: SajuAnalysis,
daeunList: DaeunPillar[] = []
): string {
const genderStr = gender === 'male' ? '남성' : '여성';
const birthDate = `${saju.birthDate.year}${saju.birthDate.month}${saju.birthDate.day}${saju.birthDate.hour ? saju.birthDate.hour + '시' : '시간 모름'}`;
const dayStemKr = saju.day.stemKr;
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
const dayElementKr = FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR];
// ── 사주 원국 ──
const pillars = [
`년주: ${saju.year.stem}${saju.year.branch} (${saju.year.stemKr}${saju.year.branchKr}) | 천간십성: ${saju.year.tenGod} | 십이운성: ${saju.year.fortune}`,
`월주: ${saju.month.stem}${saju.month.branch} (${saju.month.stemKr}${saju.month.branchKr}) | 천간십성: ${saju.month.tenGod} | 십이운성: ${saju.month.fortune}`,
`일주: ${saju.day.stem}${saju.day.branch} (${saju.day.stemKr}${saju.day.branchKr}) | 일간(日干) | 십이운성: ${saju.day.fortune}`,
saju.hour
? `시주: ${saju.hour.stem}${saju.hour.branch} (${saju.hour.stemKr}${saju.hour.branchKr}) | 천간십성: ${saju.hour.tenGod} | 십이운성: ${saju.hour.fortune}`
: '시주: 정보 없음',
].join('\n');
// ── 지장간 ──
const hiddenStemsStr = analysis.hiddenStems.map(h => {
const stemsDetail = h.stems.map(s => `${s.stemKr}(${s.stem}, ${FIVE_ELEMENTS_KR[s.element as keyof typeof FIVE_ELEMENTS_KR]}, ${s.role})`).join(', ');
return `${h.pillar} ${h.branchKr}(${h.branch}): [${stemsDetail}]`;
}).join('\n');
// ── 오행 분석 ──
const eb = analysis.elementBalance;
const es = analysis.elementScores;
const elementStr = Object.entries(eb).map(([k, v]) => {
return `${FIVE_ELEMENTS_KR[k as keyof typeof FIVE_ELEMENTS_KR]}(${k}): ${v}점 (${es[k]}%)`;
}).join(' | ');
// ── 신강/신약 ──
const strength = analysis.dayMasterStrength;
const strengthStr = `판정: ${strength.result} (점수: ${strength.score})\n근거:\n${strength.reasons.map(r => `- ${r}`).join('\n')}`;
// ── 용신/희신/기신 ──
const ys = analysis.yongShin;
const yongShinStr = `용신: ${ys.yongShinKr}(${ys.yongShin}) | 희신: ${ys.heeShinKr}(${ys.heeShin}) | 기신: ${ys.giShinKr}(${ys.giShin})\n설명: ${ys.explanation}`;
// ── 지지 상호작용 ──
const interactionsStr = analysis.branchInteractions.length > 0
? analysis.branchInteractions.map(i => `- ${i.type}: ${i.branchesKr.join('')} (${i.pillars.join('↔')}) → ${i.description}`).join('\n')
: '- 특별한 합/충/형/파/해 없음';
// ── 신살 ──
const shinsalStr = analysis.shinsal.length > 0
? analysis.shinsal.map(s => `- ${s.name}(${s.nameHanja}): ${s.pillar} ${s.branchKr}(${s.branch}) → ${s.description}`).join('\n')
: '- 특별한 신살 없음';
// ── 공망 ──
const gongmangStr = analysis.gongmang.description;
// ── 세운 ──
const seun = analysis.seun;
const seunStr = `${seun.year}${seun.stemKr}${seun.branchKr}(${seun.stem}${seun.branch})년 | 오행: ${seun.elementKr}(${seun.element})`;
const seunInteractions = seun.interactions.length > 0
? seun.interactions.map(i => `- ${i.type}: ${i.description}`).join('\n')
: '- 세운과 원국 사이에 특별한 충/합 없음';
// ── 대운 ──
const daeunInfo = currentDaeun
? `현재 대운: ${currentDaeun.stemKr}${currentDaeun.branchKr}(${currentDaeun.stem}${currentDaeun.branch}) 대운 | ${currentDaeun.age}세~${currentDaeun.age + 9}세 (${currentDaeun.startYear}~${currentDaeun.endYear}년)`
: '현재 대운 정보 없음';
const allDaeunStr = daeunList.length > 0
? daeunList.map(d => `${d.stemKr}${d.branchKr}(${d.age}세~${d.age + 9}세, ${d.startYear}~${d.endYear}년)`).join(' → ')
: '';
const systemPrompt = `당신은 따뜻하고 유머러스한 사주 상담사예요. 마치 오랜 친구처럼 편하게, 하지만 놀라울 정도로 정확하게 사주를 읽어주는 사람이에요. 딱딱한 전문 용어 대신 비유와 이야기로 풀어내는 게 당신의 스타일이에요.
[핵심 원칙 - 반드시 지켜주세요]
- 아래 제공된 계산 데이터를 바탕으로 해석하되, 전문 용어는 최소화하고 비유와 스토리텔링으로 풀어주세요.
- "~요" 체의 친근한 말투를 사용하세요. (예: "~이에요", "~거든요", "~잖아요", "~인 거죠")
- 각 섹션 제목은 창의적인 비유나 은유를 사용한 감성적 제목으로 만드세요. (예: "얼음 속에 숨겨진 불꽃", "당신 안의 숨은 보석")
- 사주 데이터에 근거하되, "당신은 마치 ~같은 사람이에요"처럼 생생한 비유로 설명하세요.
- 때로는 따끔한 조언도 섞어주세요. 친구가 해주는 솔직한 충고처럼요. (예: "솔직히 말하면... 그거 완벽주의 아니고 그냥 겁이 많은 거예요 😅")
- 각 항목 최소 5~8문장으로 깊이 있게, 하지만 술술 읽히게 작성하세요.
- 이 사람만을 위한 개인화된 분석이어야 해요. 일반론 절대 금지!
- 중간중간 공감 포인트를 넣어주세요. (예: "혹시 이런 경험 있지 않나요?", "맞죠?")
- 마지막에 진심 어린 응원 한마디를 꼭 넣어주세요.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[사용자 정보]
- 성별: ${genderStr}
- 생년월일시: ${birthDate}
- 일간: ${dayStemKr}(${saju.dayStem}) → ${dayElementKr}(${dayElement})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[사주 원국]
${pillars}
[지장간]
${hiddenStemsStr}
[오행 점수 (가중치 적용)]
${elementStr}
총점: ${Object.values(eb).reduce((a, b) => a + b, 0).toFixed(1)}
[신강/신약]
${strengthStr}
[용신/희신/기신]
${yongShinStr}
[지지 상호작용]
${interactionsStr}
[신살]
${shinsalStr}
[공망]
${gongmangStr}
[대운]
${daeunInfo}
전체 흐름: ${allDaeunStr}
[세운 - 올해]
${seunStr}
${seunInteractions}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[분석 요구사항 - 12개 항목]
위 데이터를 바탕으로 아래 12개 항목을 작성하세요.
각 항목은 반드시 "## " 로 시작하는 헤더를 사용하세요.
헤더 제목은 번호 + 창의적인 비유/은유 제목으로 만드세요. (아래는 예시일 뿐, 사주 내용에 맞게 자유롭게 창작하세요)
## 1. [타고난 기질 - 창의적 제목]
예시 제목: "차가운 호수 아래 숨겨진 용의 심장" / "봄바람처럼 자유로운 영혼"
- ${dayStemKr}${saju.day.branchKr}일주의 핵심 성격을 비유로 풀어주세요
- "당신은 마치 ~같은 사람이에요" 패턴 활용
- 겉으로 보이는 모습 vs 진짜 내면을 대비시켜 흥미롭게
- 강점은 확 칭찬하고, 약점은 "솔직히 말하면..." 패턴으로 따끔하지만 사랑스럽게
## 2. [오행 밸런스 & 개운법 - 창의적 제목]
예시 제목: "당신에게 부족한 한 조각, 그걸 채우는 법" / "운을 끌어당기는 나만의 비밀 무기"
- 오행 데이터를 인용하되, "당신의 에너지 밸런스를 보면..." 식으로 쉽게
- 용신(${ys.yongShinKr}) 기운을 강화하는 실생활 팁: 색상, 방향, 숫자, 음식, 행동
- 기신(${ys.giShinKr}) 기운 피하는 법도 구체적으로
- "오늘부터 당장 ~해보세요!" 같은 실천 가능한 조언
## 3. [지지 상호작용 - 창의적 제목]
예시 제목: "당신 안에서 벌어지는 보이지 않는 전쟁" / "운명이 엮어준 특별한 인연의 실타래"
- 합/충/형 데이터를 바탕으로 실생활 영향을 이야기로 풀어주세요
- 어려운 용어 대신 "쉽게 말하면..." 패턴 활용
## 4. [신살의 영향 - 창의적 제목]
예시 제목: "당신이 타고난 숨겨진 초능력" / "조심해야 할 함정, 그리고 날개"
- 각 신살을 흥미로운 비유로 설명 (역마살 → "여행자의 별", 도화살 → "매력의 별" 등)
- 긍정 신살은 신나게, 주의 신살은 걱정 말라는 톤으로
## 5. [재물운 - 창의적 제목]
예시 제목: "돈이 당신을 찾아오는 방식" / "통장이 웃는 시기, 우는 시기"
- 편재/정재 위치와 강도를 쉬운 비유로
- 돈 버는 스타일 (한방 vs 꾸준히 vs 투자형 등)
- 주의할 시기와 기회의 시기를 구체적으로
## 6. [직업 적성 - 창의적 제목]
예시 제목: "당신이 빛나는 무대는 따로 있어요" / "타고난 프로의 DNA"
- 적합한 분야를 구체적으로 추천 (추상적 말고 직업명까지)
- 조직형 vs 프리랜서/사업형 판단
- ${genderStr}의 특성 고려
## 7. [애정운 - 창의적 제목]
예시 제목: "사랑이 찾아오는 계절" / "당신의 이상형, 사주가 말해주는 진짜 궁합"
- ${genderStr === '남성' ? '재성' : '관성'} 기반 배우자 복 분석을 로맨틱하게
- 연애 스타일, 배우자 상을 재미있게 묘사
- 결혼 적령기를 부드럽게 안내
## 8. [건강운 - 창의적 제목]
예시 제목: "몸이 보내는 작은 신호들" / "100세까지 건강한 나를 위한 처방전"
- 오행 과부족 → 주의할 건강 포인트를 걱정 안 되게 부드럽게
- 구체적인 생활 습관 조언 (음식, 운동, 스트레스 관리)
## 9. [현재 대운 - 창의적 제목]
예시 제목: "지금 당신 앞에 펼쳐진 10년의 지도" / "인생의 봄이 오고 있어요"
- ${daeunInfo}를 바탕으로 현재 10년의 의미를 이야기로
- 지금 집중해야 할 것, 조심할 것을 친구처럼 조언
## 10. [올해의 운세 - 창의적 제목] (${seun.year}년)
예시 제목: "올해, 당신에게 찾아올 세 가지 기회" / "${seun.year}년은 당신의 해예요"
- 세운 데이터 바탕으로 올해 키워드를 뽑아 설명
- 상반기 vs 하반기 흐름
- "이것만은 꼭!" 하는 핵심 조언
## 11. [인생의 황금기 - 창의적 제목]
예시 제목: "인생에서 가장 빛나는 순간이 다가오고 있어요" / "대박 터지는 그 시기"
- 전체 대운 흐름에서 최고의 시기를 콕 집어서
- 그 시기에 어떤 기회가 오는지 구체적이고 설레게
- "그때를 위해 지금 준비할 것" 조언
## 12. [종합 조언 - 창의적 제목]
예시 제목: "당신이라는 별에게 보내는 편지" / "마지막으로 꼭 전하고 싶은 말"
- 이 사주의 핵심 강점과 약점을 한 문장으로 요약
- 용신(${ys.yongShinKr}) 활용 일상 팁
- 진심 어린 응원과 철학적 메시지로 마무리
- 마지막 문장은 감동적인 한 줄로 끝내주세요
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[톤앤매너 - 가장 중요!!]
- "~요" 체 친근한 말투 (절대 "~이다/한다" 체 사용 금지)
- 전문 용어 최소화. 꼭 필요하면 비유로 풀어서 설명
- 비유와 은유를 적극 활용 ("마치 ~처럼", "당신은 ~같은 사람이에요")
- 중간중간 이모지를 자연스럽게 사용 (과하지 않게, 섹션당 1~2개)
- 따끔한 조언 + 따뜻한 응원의 밸런스
- "혹시 ~한 적 있지 않나요?" 같은 공감형 질문으로 몰입감 유도
- Markdown 형식: ## 헤더, **볼드**, 리스트 활용
- 각 섹션 제목은 반드시 번호 포함 (## 1. ~ ## 12.)
- 읽는 사람이 "와, 이거 진짜 내 얘기다!" 하고 느끼게 만들어주세요`;
return systemPrompt;
}

723
lib/saju-calculator.ts Normal file
View File

@@ -0,0 +1,723 @@
// 천간 (天干) - 10개
export const HEAVENLY_STEMS = ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'] as const;
export const HEAVENLY_STEMS_KR = ['갑', '을', '병', '정', '무', '기', '경', '신', '임', '계'] as const;
// 지지 (地支) - 12개
export const EARTHLY_BRANCHES = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'] as const;
export const EARTHLY_BRANCHES_KR = ['자', '축', '인', '묘', '진', '사', '오', '미', '신', '유', '술', '해'] as const;
// 오행 (五行)
export const FIVE_ELEMENTS = {
'甲': '木', '乙': '木',
'丙': '火', '丁': '火',
'戊': '土', '己': '土',
'庚': '金', '辛': '金',
'壬': '水', '癸': '水',
'寅': '木', '卯': '木',
'巳': '火', '午': '火',
'辰': '土', '戌': '土', '丑': '土', '未': '土',
'申': '金', '酉': '金',
'子': '水', '亥': '水',
} as const;
export const FIVE_ELEMENTS_KR = {
'木': '목', '火': '화', '土': '토', '金': '금', '水': '수'
} as const;
// 십성 (十星)
export const TEN_GODS = {
same: { yang: '비견', yin: '겁재' }, // 같은 오행
produce: { yang: '식신', yin: '상관' }, // 내가 생하는 오행
overcome: { yang: '편재', yin: '정재' }, // 내가 극하는 오행
overcome_me: { yang: '편관', yin: '정관' }, // 나를 극하는 오행
produce_me: { yang: '편인', yin: '정인' } // 나를 생하는 오행
} as const;
// 십이운성 (十二運星)
export const TWELVE_FORTUNES = [
'장생', '목욕', '관대', '건록', '제왕', '쇠', '병', '사', '묘', '절', '태', '양'
] as const;
// 간지 계산을 위한 기준일 (1900년 1월 1일 = 경자년 정축월 병인일)
const BASE_YEAR = 1900;
const BASE_YEAR_STEM = 6; // 庚
const BASE_YEAR_BRANCH = 0; // 子
/**
* 년도의 간지를 계산
*/
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;
return {
stem: HEAVENLY_STEMS[stemIndex],
branch: EARTHLY_BRANCHES[branchIndex],
stemKr: HEAVENLY_STEMS_KR[stemIndex],
branchKr: EARTHLY_BRANCHES_KR[branchIndex]
};
}
/**
* 월의 간지를 계산 (절기 기준)
*/
export function getMonthGanzi(year: number, month: number, day: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
// 절기 기준으로 월 지지 계산
const { getSolarTermMonthBranch } = require('./solar-terms');
const branchIndex = getSolarTermMonthBranch(year, month, day);
// 월 천간 계산 (년간에 따라 달라짐)
const yearStem = getYearGanzi(year).stem;
const yearStemIndex = HEAVENLY_STEMS.indexOf(yearStem as any);
// 월 천간 공식: (년간 * 2 + 월지지) % 10
const stemIndex = (yearStemIndex * 2 + branchIndex) % 10;
return {
stem: HEAVENLY_STEMS[stemIndex],
branch: EARTHLY_BRANCHES[branchIndex],
stemKr: HEAVENLY_STEMS_KR[stemIndex],
branchKr: EARTHLY_BRANCHES_KR[branchIndex]
};
}
/**
* 일의 간지를 계산 (만세력 기준)
*/
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));
// 1900-01-01 = 丙寅일
const baseDayStem = 2; // 丙
const baseDayBranch = 2; // 寅
const stemIndex = (baseDayStem + daysDiff) % 10;
const branchIndex = (baseDayBranch + daysDiff) % 12;
return {
stem: HEAVENLY_STEMS[stemIndex < 0 ? stemIndex + 10 : stemIndex],
branch: EARTHLY_BRANCHES[branchIndex < 0 ? branchIndex + 12 : branchIndex],
stemKr: HEAVENLY_STEMS_KR[stemIndex < 0 ? stemIndex + 10 : stemIndex],
branchKr: EARTHLY_BRANCHES_KR[branchIndex < 0 ? branchIndex + 12 : branchIndex]
};
}
/**
* 시의 간지를 계산
*/
export function getHourGanzi(dayGanzi: { stem: string }, hour: number): { stem: string; branch: string; stemKr: string; branchKr: string } {
// 시 지지: 자시(23-01)=0, 축시(01-03)=1, ...
let branchIndex: number;
if (hour >= 23 || hour < 1) branchIndex = 0; // 子
else if (hour >= 1 && hour < 3) branchIndex = 1; // 丑
else if (hour >= 3 && hour < 5) branchIndex = 2; // 寅
else if (hour >= 5 && hour < 7) branchIndex = 3; // 卯
else if (hour >= 7 && hour < 9) branchIndex = 4; // 辰
else if (hour >= 9 && hour < 11) branchIndex = 5; // 巳
else if (hour >= 11 && hour < 13) branchIndex = 6; // 午
else if (hour >= 13 && hour < 15) branchIndex = 7; // 未
else if (hour >= 15 && hour < 17) branchIndex = 8; // 申
else if (hour >= 17 && hour < 19) branchIndex = 9; // 酉
else if (hour >= 19 && hour < 21) branchIndex = 10; // 戌
else branchIndex = 11; // 亥
// 시 천간 계산 (일간에 따라 달라짐)
const dayStemIndex = HEAVENLY_STEMS.indexOf(dayGanzi.stem as any);
const stemIndex = (dayStemIndex * 2 + branchIndex) % 10;
return {
stem: HEAVENLY_STEMS[stemIndex],
branch: EARTHLY_BRANCHES[branchIndex],
stemKr: HEAVENLY_STEMS_KR[stemIndex],
branchKr: EARTHLY_BRANCHES_KR[branchIndex]
};
}
/**
* 십성 계산
*/
export function getTenGod(dayStem: string, targetStem: string, isYang: boolean): string {
const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
const targetElement = FIVE_ELEMENTS[targetStem as keyof typeof FIVE_ELEMENTS];
// 같은 오행
if (dayElement === targetElement) {
return isYang ? '비견' : '겁재';
}
// 오행 상생/상극 관계 확인
const produceMap: { [key: string]: string } = {
'木': '火', '火': '土', '土': '金', '金': '水', '水': '木'
};
const overcomeMap: { [key: string]: string } = {
'木': '土', '火': '金', '土': '水', '金': '木', '水': '火'
};
// 내가 생하는 오행
if (produceMap[dayElement] === targetElement) {
return isYang ? '식신' : '상관';
}
// 내가 극하는 오행
if (overcomeMap[dayElement] === targetElement) {
return isYang ? '편재' : '정재';
}
// 나를 극하는 오행
if (overcomeMap[targetElement] === dayElement) {
return isYang ? '편관' : '정관';
}
// 나를 생하는 오행
if (produceMap[targetElement] === dayElement) {
return isYang ? '편인' : '정인';
}
return '비견';
}
/**
* 십이운성 계산
*/
export function getTwelveFortune(dayStem: string, branch: string): string {
// 간단한 십이운성 계산 (실제로는 더 복잡함)
const fortuneMap: { [key: string]: { [key: string]: number } } = {
'甲': { '亥': 11, '子': 0, '丑': 1, '寅': 2, '卯': 3, '辰': 4, '巳': 5, '午': 6, '未': 7, '申': 8, '酉': 9, '戌': 10 },
'乙': { '午': 11, '未': 0, '申': 1, '酉': 2, '戌': 3, '亥': 4, '子': 5, '丑': 6, '寅': 7, '卯': 8, '辰': 9, '巳': 10 },
'丙': { '寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10 },
'丁': { '酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10 },
'戊': { '寅': 11, '卯': 0, '辰': 1, '巳': 2, '午': 3, '未': 4, '申': 5, '酉': 6, '戌': 7, '亥': 8, '子': 9, '丑': 10 },
'己': { '酉': 11, '戌': 0, '亥': 1, '子': 2, '丑': 3, '寅': 4, '卯': 5, '辰': 6, '巳': 7, '午': 8, '未': 9, '申': 10 },
'庚': { '巳': 11, '午': 0, '未': 1, '申': 2, '酉': 3, '戌': 4, '亥': 5, '子': 6, '丑': 7, '寅': 8, '卯': 9, '辰': 10 },
'辛': { '子': 11, '丑': 0, '寅': 1, '卯': 2, '辰': 3, '巳': 4, '午': 5, '未': 6, '申': 7, '酉': 8, '戌': 9, '亥': 10 },
'壬': { '申': 11, '酉': 0, '戌': 1, '亥': 2, '子': 3, '丑': 4, '寅': 5, '卯': 6, '辰': 7, '巳': 8, '午': 9, '未': 10 },
'癸': { '卯': 11, '辰': 0, '巳': 1, '午': 2, '未': 3, '申': 4, '酉': 5, '戌': 6, '亥': 7, '子': 8, '丑': 9, '寅': 10 }
};
const index = fortuneMap[dayStem as keyof typeof fortuneMap]?.[branch as keyof typeof fortuneMap['甲']] ?? 0;
return TWELVE_FORTUNES[index];
}
/**
* 사주팔자 전체 계산
*/
export interface SajuData {
year: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
month: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
day: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
hour?: { stem: string; branch: string; stemKr: string; branchKr: string; element: string; tenGod: string; fortune: string };
dayStem: string;
birthDate: { year: number; month: number; day: number; hour?: number };
gender: 'male' | 'female';
}
export function calculateSaju(
year: number,
month: number,
day: number,
hour: number | null,
gender: 'male' | 'female'
): SajuData {
const yearGanzi = getYearGanzi(year);
const monthGanzi = getMonthGanzi(year, month, day);
const dayGanzi = getDayGanzi(year, month, day);
const hourGanzi = hour !== null ? getHourGanzi(dayGanzi, hour) : null;
const dayStem = dayGanzi.stem;
const dayStemIndex = HEAVENLY_STEMS.indexOf(dayStem as any);
const isDayYang = dayStemIndex % 2 === 0;
const result: SajuData = {
year: {
...yearGanzi,
element: FIVE_ELEMENTS[yearGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, yearGanzi.stem, (HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, yearGanzi.branch)
},
month: {
...monthGanzi,
element: FIVE_ELEMENTS[monthGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, monthGanzi.stem, (HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, monthGanzi.branch)
},
day: {
...dayGanzi,
element: FIVE_ELEMENTS[dayGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: '일간',
fortune: getTwelveFortune(dayStem, dayGanzi.branch)
},
dayStem,
birthDate: { year, month, day, hour: hour ?? undefined },
gender
};
if (hourGanzi) {
result.hour = {
...hourGanzi,
element: FIVE_ELEMENTS[hourGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, hourGanzi.stem, (HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, hourGanzi.branch)
};
}
return result;
}
// ============================================================
// 지장간 (藏干) - 각 지지에 숨어있는 천간
// ============================================================
export const HIDDEN_STEMS: { [key: string]: string[] } = {
'子': ['癸'],
'丑': ['己', '癸', '辛'],
'寅': ['甲', '丙', '戊'],
'卯': ['乙'],
'辰': ['戊', '乙', '癸'],
'巳': ['丙', '庚', '戊'],
'午': ['丁', '己'],
'未': ['己', '丁', '乙'],
'申': ['庚', '壬', '戊'],
'酉': ['辛'],
'戌': ['戊', '辛', '丁'],
'亥': ['壬', '甲'],
};
/**
* 지지의 지장간(숨은 천간) 반환
*/
export function getHiddenStems(branch: string): string[] {
return HIDDEN_STEMS[branch] || [];
}
/**
* 4주 전체의 지장간 정보 반환
*/
export function getAllHiddenStems(saju: SajuData): { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[] {
const pillars = [
{ pillar: '년주', branch: saju.year.branch, branchKr: saju.year.branchKr },
{ pillar: '월주', branch: saju.month.branch, branchKr: saju.month.branchKr },
{ pillar: '일주', branch: saju.day.branch, branchKr: saju.day.branchKr },
];
if (saju.hour) {
pillars.push({ pillar: '시주', branch: saju.hour.branch, branchKr: saju.hour.branchKr });
}
return pillars.map(p => {
const hidden = getHiddenStems(p.branch);
return {
...p,
stems: hidden.map((stem, idx) => {
const stemIndex = HEAVENLY_STEMS.indexOf(stem as any);
const role = idx === 0 ? '정기(본기)' : idx === 1 ? '중기' : '여기';
return {
stem,
stemKr: HEAVENLY_STEMS_KR[stemIndex],
element: FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS],
role,
};
}),
};
});
}
// ============================================================
// 지지 상호작용 (合/沖/刑/破/害)
// ============================================================
export interface BranchInteraction {
type: string; // 육합, 삼합, 방합, 충, 형, 파, 해
branches: string[]; // 관련 지지 (한자)
branchesKr: string[]; // 관련 지지 (한글)
pillars: string[]; // 관련 기둥 (년주, 월주 등)
description: string;
resultElement?: string; // 합의 결과 오행 (해당 시)
}
const YUKAP_PAIRS: [string, string, string][] = [
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
];
const SAMHAP_GROUPS: [string, string, string, string][] = [
['申', '子', '辰', '水'], ['亥', '卯', '未', '木'],
['寅', '午', '戌', '火'], ['巳', '酉', '丑', '金'],
];
const BANGHAP_GROUPS: [string, string, string, string][] = [
['寅', '卯', '辰', '木'], ['巳', '午', '未', '火'],
['申', '酉', '戌', '金'], ['亥', '子', '丑', '水'],
];
const CHUNG_PAIRS: [string, string][] = [
['子', '午'], ['丑', '未'], ['寅', '申'],
['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
];
const HYUNG_GROUPS: { branches: string[]; name: string }[] = [
{ branches: ['寅', '巳', '申'], name: '무은지형(無恩之刑)' },
{ branches: ['丑', '戌', '未'], name: '지세지형(恃勢之刑)' },
{ branches: ['子', '卯'], name: '무례지형(無禮之刑)' },
];
const JAHYUNG_BRANCHES = ['辰', '午', '酉', '亥'];
const PA_PAIRS: [string, string][] = [
['子', '酉'], ['丑', '辰'], ['寅', '亥'],
['卯', '午'], ['巳', '申'], ['未', '戌'],
];
const HAE_PAIRS: [string, string][] = [
['子', '未'], ['丑', '午'], ['寅', '巳'],
['卯', '辰'], ['申', '亥'], ['酉', '戌'],
];
const ELEMENT_NAMES_KR: { [key: string]: string } = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
/**
* 지지 상호작용 분석
*/
export function analyzeBranchInteractions(saju: SajuData): BranchInteraction[] {
const interactions: BranchInteraction[] = [];
// 기둥별 지지 수집
const pillarBranches: { branch: string; pillar: string; branchKr: string }[] = [
{ branch: saju.year.branch, pillar: '년주', branchKr: saju.year.branchKr },
{ branch: saju.month.branch, pillar: '월주', branchKr: saju.month.branchKr },
{ branch: saju.day.branch, pillar: '일주', branchKr: saju.day.branchKr },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, pillar: '시주', branchKr: saju.hour.branchKr });
}
const branches = pillarBranches.map(p => p.branch);
// 육합 (六合) 검사
for (const [a, b, elem] of YUKAP_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '육합(六合)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 육합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 기운 생성. 조화와 화합의 관계.`,
resultElement: elem,
});
}
}
// 삼합 (三合) 검사
for (const [a, b, c, elem] of SAMHAP_GROUPS) {
const found = [a, b, c].filter(x => branches.includes(x));
if (found.length >= 2) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
const isComplete = found.length === 3;
interactions.push({
type: isComplete ? '삼합(三合)' : '반삼합(半三合)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} ${isComplete ? '삼합' : '반삼합'}${ELEMENT_NAMES_KR[elem]}(${elem})국. ${isComplete ? '강력한 합의 기운.' : '삼합의 기운이 부분적으로 작용.'}`,
resultElement: elem,
});
}
}
// 방합 (方合) 검사
for (const [a, b, c, elem] of BANGHAP_GROUPS) {
const found = [a, b, c].filter(x => branches.includes(x));
if (found.length === 3) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
interactions.push({
type: '방합(方合)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} 방합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 방국. 매우 강한 오행 기운.`,
resultElement: elem,
});
}
}
// 충 (沖) 검사
for (const [a, b] of CHUNG_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '충(沖)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 충 → 변동, 갈등, 변화의 에너지. ${pillarBranches[idxA].pillar}${pillarBranches[idxB].pillar} 사이의 긴장 관계.`,
});
}
}
// 형 (刑) 검사
for (const group of HYUNG_GROUPS) {
const found = group.branches.filter(x => branches.includes(x));
if (found.length >= 2) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
interactions.push({
type: '형(刑)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} ${group.name} → 시련과 갈등의 기운. 주의가 필요한 관계.`,
});
}
}
// 자형 (自刑) 검사
for (const jb of JAHYUNG_BRANCHES) {
const count = branches.filter(x => x === jb).length;
if (count >= 2) {
const brKr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(jb as any)];
interactions.push({
type: '자형(自刑)',
branches: [jb, jb],
branchesKr: [brKr, brKr],
pillars: pillarBranches.filter(p => p.branch === jb).map(p => p.pillar),
description: `${brKr}${brKr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.`,
});
}
}
// 파 (破) 검사
for (const [a, b] of PA_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '파(破)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 파 → 관계의 균열, 계획의 차질 가능성.`,
});
}
}
// 해 (害) 검사
for (const [a, b] of HAE_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '해(害)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 해 → 은근한 방해, 원망의 기운.`,
});
}
}
return interactions;
}
// ============================================================
// 신살 (神煞) 계산
// ============================================================
export interface Shinsal {
name: string;
nameHanja: string;
branch: string;
branchKr: string;
pillar: string;
description: string;
}
// 일지 삼합국 기준 신살 매핑
const SAMHAP_GROUP_MAP: { [key: string]: string } = {
'申': '申子辰', '子': '申子辰', '辰': '申子辰',
'寅': '寅午戌', '午': '寅午戌', '戌': '寅午戌',
'巳': '巳酉丑', '酉': '巳酉丑', '丑': '巳酉丑',
'亥': '亥卯未', '卯': '亥卯未', '未': '亥卯未',
};
const YEOKMA_MAP: { [key: string]: string } = {
'申子辰': '寅', '寅午戌': '申', '巳酉丑': '亥', '亥卯未': '巳',
};
const DOHWA_MAP: { [key: string]: string } = {
'申子辰': '酉', '寅午戌': '卯', '巳酉丑': '午', '亥卯未': '子',
};
const HWAGAE_MAP: { [key: string]: string } = {
'申子辰': '辰', '寅午戌': '戌', '巳酉丑': '丑', '亥卯未': '未',
};
// 천을귀인 (天乙貴人) - 일간 기준
const CHEONUL_MAP: { [key: string]: string[] } = {
'甲': ['丑', '未'], '乙': ['子', '申'], '丙': ['亥', '酉'], '丁': ['亥', '酉'],
'戊': ['丑', '未'], '己': ['子', '申'], '庚': ['丑', '未'], '辛': ['寅', '午'],
'壬': ['卯', '巳'], '癸': ['卯', '巳'],
};
// 문창귀인 (文昌貴人) - 일간 기준
const MUNCHANG_MAP: { [key: string]: string } = {
'甲': '巳', '乙': '午', '丙': '申', '丁': '酉',
'戊': '申', '己': '酉', '庚': '亥', '辛': '子',
'壬': '寅', '癸': '卯',
};
// 천덕귀인 (天德貴人) - 월지 기준
const CHEONDUK_MAP: { [key: string]: string } = {
'寅': '丁', '卯': '申', '辰': '壬', '巳': '辛',
'午': '亥', '未': '甲', '申': '癸', '酉': '寅',
'戌': '丙', '亥': '乙', '子': '巳', '丑': '庚',
};
/**
* 신살 계산
*/
export function calculateShinsal(saju: SajuData): Shinsal[] {
const result: Shinsal[] = [];
const dayBranch = saju.day.branch;
const dayStem = saju.dayStem;
const monthBranch = saju.month.branch;
// 4주의 지지 수집
const pillarBranches: { branch: string; branchKr: string; pillar: string }[] = [
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
}
const group = SAMHAP_GROUP_MAP[dayBranch];
// 역마살
if (group) {
const yeokma = YEOKMA_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === yeokma && pb.pillar !== '일주') {
result.push({
name: '역마살', nameHanja: '驛馬殺', branch: yeokma,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
});
}
}
// 도화살
const dohwa = DOHWA_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === dohwa && pb.pillar !== '일주') {
result.push({
name: '도화살', nameHanja: '桃花殺', branch: dohwa,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
});
}
}
// 화개살
const hwagae = HWAGAE_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === hwagae && pb.pillar !== '일주') {
result.push({
name: '화개살', nameHanja: '華蓋殺', branch: hwagae,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
});
}
}
}
// 천을귀인
const cheonulBranches = CHEONUL_MAP[dayStem] || [];
for (const pb of pillarBranches) {
if (cheonulBranches.includes(pb.branch) && pb.pillar !== '일주') {
result.push({
name: '천을귀인', nameHanja: '天乙貴人', branch: pb.branch,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
});
}
}
// 문창귀인
const munchangBranch = MUNCHANG_MAP[dayStem];
if (munchangBranch) {
for (const pb of pillarBranches) {
if (pb.branch === munchangBranch && pb.pillar !== '일주') {
result.push({
name: '문창귀인', nameHanja: '文昌貴人', branch: pb.branch,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
});
}
}
}
// 천덕귀인 (월지 기준, 천간에서 확인)
const cheondukStem = CHEONDUK_MAP[monthBranch];
if (cheondukStem) {
const allStems = [
{ stem: saju.year.stem, pillar: '년주' },
{ stem: saju.day.stem, pillar: '일주' },
];
if (saju.hour) allStems.push({ stem: saju.hour.stem, pillar: '시주' });
for (const ps of allStems) {
if (ps.stem === cheondukStem) {
result.push({
name: '천덕귀인', nameHanja: '天德貴人', branch: monthBranch,
branchKr: EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(monthBranch as any)],
pillar: ps.pillar,
description: '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
});
}
}
}
return result;
}
// ============================================================
// 공망 (空亡) 계산
// ============================================================
/**
* 60갑자에서 일주의 순(旬)을 찾아 공망 지지 2개를 반환
*/
export function calculateGongmang(dayStem: string, dayBranch: string): { branches: string[]; branchesKr: string[]; description: string } {
const stemIdx = HEAVENLY_STEMS.indexOf(dayStem as any);
const branchIdx = EARTHLY_BRANCHES.indexOf(dayBranch as any);
// 60갑자에서 해당 순(旬)의 시작점 = 천간이 甲인 지점
// 순의 시작 지지 인덱스 = (branchIdx - stemIdx + 12) % 12
const startBranchIdx = (branchIdx - stemIdx + 120) % 12;
// 공망 = 순에 포함되지 않는 2개의 지지
// 순은 10개의 간지 → 10개의 지지 사용, 2개가 남음
const gongmang1Idx = (startBranchIdx + 10) % 12;
const gongmang2Idx = (startBranchIdx + 11) % 12;
const branch1 = EARTHLY_BRANCHES[gongmang1Idx];
const branch2 = EARTHLY_BRANCHES[gongmang2Idx];
const branchKr1 = EARTHLY_BRANCHES_KR[gongmang1Idx];
const branchKr2 = EARTHLY_BRANCHES_KR[gongmang2Idx];
return {
branches: [branch1, branch2],
branchesKr: [branchKr1, branchKr2],
description: `${branchKr1}(${branch1}${branchKr2}(${branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.`,
};
}

249
lib/solar-terms.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* 24절기 계산
* 사주 계산에서 월주는 절기를 기준으로 합니다.
*/
// 24절기 (입춘부터 시작)
export const SOLAR_TERMS = [
'입춘', '우수', '경칩', '춘분', '청명', '곡우',
'입하', '소만', '망종', '하지', '소서', '대서',
'입추', '처서', '백로', '추분', '한로', '상강',
'입동', '소설', '대설', '동지', '소한', '대한'
] as const;
// 월 절기 (홀수 인덱스: 입X, 짝수 인덱스: X분/X지)
export const MONTH_SOLAR_TERMS = [
'입춘', // 1월 (인월)
'경칩', // 2월 (묘월)
'청명', // 3월 (진월)
'입하', // 4월 (사월)
'망종', // 5월 (오월)
'소서', // 6월 (미월)
'입추', // 7월 (신월)
'백로', // 8월 (유월)
'한로', // 9월 (술월)
'입동', // 10월 (해월)
'대설', // 11월 (자월)
'소한', // 12월 (축월)
] as const;
interface SolarTermDate {
year: number;
month: number;
day: number;
hour: number;
minute: number;
}
/**
* 정밀한 절기 계산 (천문학적 계산 기반)
* solarlunar 라이브러리 사용
*/
export function getSolarTermDate(year: number, termIndex: number): SolarTermDate {
try {
const solarLunar = require('solarlunar');
// 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
};
}
}
/**
* 주어진 날짜가 어느 절기 이후인지 확인
* @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();
// 각 절기 날짜 확인
for (let i = 23; i >= 0; i--) {
const termDate = getSolarTermDate(year, i);
let termYear = termDate.year;
let termMonth = termDate.month;
// 대한, 소한은 이전 해 처리
if (i >= 22 && month >= 2) {
termYear = year;
} else if (i >= 22) {
termYear = year - 1;
}
const term = new Date(termYear, termMonth - 1, termDate.day);
if (dateValue >= term.getTime()) {
return i;
}
}
// 입춘 이전이면 전년도 대한 이후
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, // 대한 -> 축월
];
return monthBranches[termIndex];
}
/**
* 절기명 가져오기
*/
export function getSolarTermName(termIndex: number): string {
return SOLAR_TERMS[termIndex];
}
/**
* 다음 절기까지 남은 일수 계산
*/
export function getDaysToNextSolarTerm(year: number, month: number, day: number): number {
const currentDate = new Date(year, month - 1, day);
const currentTerm = getCurrentSolarTerm(year, month, day);
const nextTermIndex = (currentTerm + 1) % 24;
let nextYear = year;
if (currentTerm === 23) {
nextYear = year + 1;
}
const nextTerm = getSolarTermDate(nextYear, nextTermIndex);
const nextDate = new Date(nextTerm.year, nextTerm.month - 1, nextTerm.day);
const diffTime = nextDate.getTime() - currentDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}

7
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co';
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-key';
return createBrowserClient(url, key);
}

27
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,27 @@
import { createServerClient, type CookieMethodsServer } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
const cookieMethods: CookieMethodsServer = {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Server Component에서 호출된 경우 무시 (미들웨어가 세션 갱신)
}
},
};
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: cookieMethods }
);
}

12
middleware.ts Normal file
View File

@@ -0,0 +1,12 @@
import { type NextRequest } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

1671
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,17 @@
"lint": "eslint"
},
"dependencies": {
"@supabase/ssr": "^0.5.2",
"@supabase/supabase-js": "^2.99.0",
"@tosspayments/tosspayments-sdk": "^2.6.0",
"next": "16.1.6",
"openai": "^6.21.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"resend": "^6.9.1"
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"resend": "^6.9.1",
"solarlunar": "^2.0.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

23
saju-engine/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.12-slim
# 시스템 패키지 (ephem 빌드에 필요)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 의존성 먼저 복사 (레이어 캐시 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 소스 복사
COPY . .
# 비루트 사용자 실행
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

View File

View File

@@ -0,0 +1,109 @@
"""
대운 (大運) 계산 모듈
양남음녀 순행, 음남양녀 역행, 절기 기준 대운 시작 나이
"""
from calculator.saju_calculator import HEAVENLY_STEMS, EARTHLY_BRANCHES, HEAVENLY_STEMS_KR, EARTHLY_BRANCHES_KR
from calculator.solar_terms import get_days_to_next_solar_term, get_days_from_prev_solar_term
def _calculate_daeun_start_age(
birth_year: int,
birth_month: int,
birth_day: int,
gender: str,
is_yang_year: bool,
) -> int:
"""절기 기준 대운 시작 나이 계산"""
is_forward = (gender == 'male' and is_yang_year) or (gender == 'female' and not is_yang_year)
if is_forward:
# 순행: 생일부터 다음 절기까지의 일수
days = get_days_to_next_solar_term(birth_year, birth_month, birth_day)
else:
# 역행: 이전 절기부터 생일까지의 일수
days = get_days_from_prev_solar_term(birth_year, birth_month, birth_day)
# 3일 = 1세
start_age = days // 3
return max(1, min(10, start_age))
def calculate_daeun(
birth_year: int,
birth_month: int,
birth_day: int,
gender: str,
month_stem: str,
month_branch: str,
) -> list[dict]:
"""대운 계산 (10년 단위, 8개 대운)"""
if month_stem not in HEAVENLY_STEMS or month_branch not in EARTHLY_BRANCHES:
return []
month_stem_idx = HEAVENLY_STEMS.index(month_stem)
month_branch_idx = EARTHLY_BRANCHES.index(month_branch)
year_stem_idx = (birth_year - 1900 + 6) % 10
is_yang_year = year_stem_idx % 2 == 0
is_forward = (gender == 'male' and is_yang_year) or (gender == 'female' and not is_yang_year)
start_age = _calculate_daeun_start_age(birth_year, birth_month, birth_day, gender, is_yang_year)
daeun_list = []
for i in range(8):
age = start_age + (i * 10)
start_year = birth_year + age
end_year = start_year + 9
if is_forward:
stem_idx = (month_stem_idx + i + 1) % 10
branch_idx = (month_branch_idx + i + 1) % 12
else:
stem_idx = (month_stem_idx - i - 1 + 100) % 10
branch_idx = (month_branch_idx - i - 1 + 120) % 12
daeun_list.append({
'age': age,
'startYear': start_year,
'endYear': end_year,
'stem': HEAVENLY_STEMS[stem_idx],
'branch': EARTHLY_BRANCHES[branch_idx],
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
})
return daeun_list
def get_current_daeun(daeun_list: list[dict], current_year: int) -> dict | None:
"""현재 대운 찾기"""
for daeun in daeun_list:
if daeun['startYear'] <= current_year <= daeun['endYear']:
return daeun
return None
def get_daeun_description(daeun: dict, day_stem: str) -> str:
"""대운 기본 해석"""
age = daeun['age']
ganzi = f"{daeun['stem']}{daeun['branch']}"
desc = f"{age}세부터 {age + 9}세까지의 10년은 {daeun['stemKr']}{daeun['branchKr']}({ganzi}) 대운입니다. "
if age < 20:
desc += '청소년기로 학업과 기초를 다지는 시기입니다. '
elif age < 40:
desc += '성장과 발전의 시기로 사회활동이 왕성한 때입니다. '
elif age < 60:
desc += '안정과 성숙의 시기로 경험이 쌓이는 때입니다. '
else:
desc += '원숙한 시기로 인생의 지혜를 나누는 때입니다. '
stem_idx = HEAVENLY_STEMS.index(daeun['stem'])
if stem_idx % 2 == 0:
desc += '적극적이고 외향적인 활동이 유리합니다.'
else:
desc += '차분하고 내실을 다지는 것이 좋습니다.'
return desc

View File

@@ -0,0 +1,267 @@
"""
사주 기반 로또 번호 생성 모듈
오행 균형, 천간/지지 고유 숫자, 신살 등을 반영
"""
import hashlib
import random
from typing import Optional
from calculator.saju_calculator import FIVE_ELEMENTS
# 오행별 로또 번호 후보
_ELEMENT_NUMBERS: dict[str, list[int]] = {
'': [1, 2, 11, 12, 21, 22, 31, 32, 41, 42],
'': [3, 4, 13, 14, 23, 24, 33, 34, 43, 44],
'': [5, 6, 15, 16, 25, 26, 35, 36, 45],
'': [7, 8, 17, 18, 27, 28, 37, 38],
'': [9, 10, 19, 20, 29, 30, 39, 40],
}
# 천간 고유 숫자 (각 천간에 대응하는 행운 숫자)
_STEM_NUMBERS: dict[str, list[int]] = {
'': [1, 11, 21, 31, 41],
'': [2, 12, 22, 32, 42],
'': [3, 13, 23, 33, 43],
'': [4, 14, 24, 34, 44],
'': [5, 15, 25, 35, 45],
'': [6, 16, 26, 36],
'': [7, 17, 27, 37],
'': [8, 18, 28, 38],
'': [9, 19, 29, 39],
'': [10, 20, 30, 40],
}
# 지지 고유 숫자
_BRANCH_NUMBERS: dict[str, list[int]] = {
'': [9, 19, 29, 39],
'': [6, 15, 25, 36],
'': [1, 11, 31, 41],
'': [2, 12, 22, 42],
'': [5, 16, 26, 35],
'': [3, 14, 24, 43],
'': [4, 13, 23, 44],
'': [6, 16, 26, 45],
'': [7, 18, 27, 37],
'': [8, 17, 28, 38],
'': [5, 15, 25, 35],
'': [10, 20, 30, 40],
}
# 신살별 보너스 숫자
_SHINSAL_BONUS: dict[str, list[int]] = {
'역마살': [7, 17, 27, 37],
'도화살': [3, 13, 23, 33, 43],
'화개살': [11, 22, 33, 44],
'천을귀인': [1, 7, 14, 21, 28, 35, 42],
'문창귀인': [4, 16, 25, 36],
'천덕귀인': [6, 12, 24, 36],
}
def _seed_from_saju(saju: dict) -> str:
"""사주 데이터에서 결정론적 시드 생성"""
bd = saju.get('birthDate', {})
key = (
f"{bd.get('year')}-{bd.get('month')}-{bd.get('day')}-"
f"{bd.get('hour', 'X')}-{saju.get('gender', 'X')}"
)
return hashlib.sha256(key.encode()).hexdigest()
def _get_dominant_elements(saju: dict) -> list[str]:
"""사주에서 강한 오행 추출 (빈도 기준 정렬)"""
count: dict[str, int] = {'': 0, '': 0, '': 0, '': 0, '': 0}
pillars = ['year', 'month', 'day', 'hour']
for p in pillars:
pillar = saju.get(p)
if not pillar:
continue
for key in ['stem', 'branch']:
char = pillar.get(key, '')
elem = FIVE_ELEMENTS.get(char)
if elem:
count[elem] = count.get(elem, 0) + 1
return sorted(count, key=lambda e: count[e], reverse=True)
def generate_lotto_numbers(
saju: dict,
shinsal: Optional[list[dict]] = None,
count: int = 6,
) -> dict:
"""
사주 기반 로또 번호 생성
Returns:
{
'numbers': [int, ...], # 추천 번호 (오름차순)
'basis': str, # 생성 근거 설명
'elementBalance': dict, # 오행별 번호 분포
}
"""
seed_hex = _seed_from_saju(saju)
rng = random.Random(int(seed_hex, 16) % (2**32))
# 1. 후보 풀 구성 (우선순위 점수)
scores: dict[int, float] = {n: 0.0 for n in range(1, 46)}
# 오행 비중 (강한 오행 우선)
dominant_elements = _get_dominant_elements(saju)
for rank, elem in enumerate(dominant_elements):
weight = 5.0 - rank # 1위=5점, 2위=4점, ...
for n in _ELEMENT_NUMBERS.get(elem, []):
if n in scores:
scores[n] += weight
# 일간 비중
day_stem = saju.get('dayStem', '')
for n in _STEM_NUMBERS.get(day_stem, []):
if n in scores:
scores[n] += 4.0
# 일지 비중
day_branch = saju.get('day', {}).get('branch', '')
for n in _BRANCH_NUMBERS.get(day_branch, []):
if n in scores:
scores[n] += 3.0
# 월지 비중
month_branch = saju.get('month', {}).get('branch', '')
for n in _BRANCH_NUMBERS.get(month_branch, []):
if n in scores:
scores[n] += 2.0
# 신살 보너스
shinsal_names = []
if shinsal:
for s in shinsal:
name = s.get('name', '')
shinsal_names.append(name)
for n in _SHINSAL_BONUS.get(name, []):
if n in scores:
scores[n] += 2.5
# 2. 점수 기반 확률 가중 샘플링
numbers_pool = list(scores.keys())
weights = [scores[n] + 1.0 for n in numbers_pool] # 최소 1.0 보장
selected: list[int] = []
remaining_pool = list(zip(numbers_pool, weights))
while len(selected) < count and remaining_pool:
total = sum(w for _, w in remaining_pool)
pick = rng.uniform(0, total)
cumulative = 0
picked_n = None
for n, w in remaining_pool:
cumulative += w
if pick <= cumulative:
picked_n = n
break
if picked_n is None:
picked_n = remaining_pool[-1][0]
selected.append(picked_n)
remaining_pool = [(n, w) for n, w in remaining_pool if n != picked_n]
selected.sort()
# 3. 오행 분포 계산
def _number_to_element(n: int) -> str:
for elem, nums in _ELEMENT_NUMBERS.items():
if n in nums:
return elem
return ''
element_balance = {}
for n in selected:
elem = _number_to_element(n)
element_balance[elem] = element_balance.get(elem, [])
element_balance[elem].append(n)
# 4. 근거 설명 생성
basis_parts = [
f"일간 {saju.get('dayStem', '')}({day_stem}) 기반",
f"강한 오행: {', '.join(dominant_elements[:2])}",
]
if shinsal_names:
basis_parts.append(f"신살 반영: {', '.join(set(shinsal_names))}")
basis = ' / '.join(basis_parts)
return {
'numbers': selected,
'basis': basis,
'elementBalance': element_balance,
}
def generate_multiple_sets(
saju: dict,
shinsal: Optional[list[dict]] = None,
sets: int = 5,
) -> list[dict]:
"""여러 세트의 로또 번호 생성 (시드 변형)"""
results = []
seed_hex = _seed_from_saju(saju)
base_seed = int(seed_hex, 16) % (2**32)
for i in range(sets):
# 세트별 시드 변형
modified_saju = dict(saju)
modified_saju['_set_index'] = i # 내부 변형용
rng = random.Random(base_seed + i * 997)
dominant_elements = _get_dominant_elements(saju)
# 각 세트는 조금씩 다른 오행 강조
scores: dict[int, float] = {n: rng.random() * 2 for n in range(1, 46)}
elem_to_emphasize = dominant_elements[i % len(dominant_elements)]
for n in _ELEMENT_NUMBERS.get(elem_to_emphasize, []):
if n in scores:
scores[n] += 5.0
day_stem = saju.get('dayStem', '')
for n in _STEM_NUMBERS.get(day_stem, []):
if n in scores:
scores[n] += 3.0
if shinsal:
for s in shinsal:
for n in _SHINSAL_BONUS.get(s.get('name', ''), []):
if n in scores:
scores[n] += 2.0
pool = list(scores.keys())
weights = [scores[n] for n in pool]
selected: list[int] = []
remaining = list(zip(pool, weights))
while len(selected) < 6 and remaining:
total = sum(w for _, w in remaining)
pick = rng.uniform(0, total)
cumulative = 0.0
picked_n = None
for n, w in remaining:
cumulative += w
if pick <= cumulative:
picked_n = n
break
if picked_n is None:
picked_n = remaining[-1][0]
selected.append(picked_n)
remaining = [(n, w) for n, w in remaining if n != picked_n]
selected.sort()
results.append({
'set': i + 1,
'numbers': selected,
'emphasis': elem_to_emphasize,
})
return results

View File

@@ -0,0 +1,596 @@
"""
사주팔자 계산 모듈
천간, 지지, 오행, 십성, 십이운성, 신살, 공망, 지장간, 지지 상호작용
"""
from datetime import date, datetime
from typing import Optional
from calculator.solar_terms import get_solar_term_month_branch
# ============================================================
# 기본 상수
# ============================================================
HEAVENLY_STEMS = ['', '', '', '', '', '', '', '', '', '']
HEAVENLY_STEMS_KR = ['', '', '', '', '', '', '', '', '', '']
EARTHLY_BRANCHES = ['', '', '', '', '', '', '', '', '', '', '', '']
EARTHLY_BRANCHES_KR = ['', '', '', '', '', '', '', '', '', '', '', '']
FIVE_ELEMENTS: dict[str, str] = {
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': '',
'': '', '': '', '': '', '': '',
'': '', '': '',
'': '', '': '',
}
FIVE_ELEMENTS_KR = {'': '', '': '', '': '', '': '', '': ''}
TWELVE_FORTUNES = ['장생', '목욕', '관대', '건록', '제왕', '', '', '', '', '', '', '']
# 기준년: 1900 = 庚子년
BASE_YEAR = 1900
BASE_YEAR_STEM = 6 # 庚
BASE_YEAR_BRANCH = 0 # 子
# 기준일: 1900-01-01 = 丙寅일
BASE_DAY_STEM = 2 # 丙
BASE_DAY_BRANCH = 2 # 寅
# ============================================================
# 간지 계산
# ============================================================
def get_year_ganzi(year: int) -> dict:
year_diff = year - BASE_YEAR
stem_idx = (BASE_YEAR_STEM + year_diff) % 10
branch_idx = (BASE_YEAR_BRANCH + year_diff) % 12
return {
'stem': HEAVENLY_STEMS[stem_idx],
'branch': EARTHLY_BRANCHES[branch_idx],
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
}
def get_month_ganzi(year: int, month: int, day: int) -> dict:
branch_idx = get_solar_term_month_branch(year, month, day)
year_stem = get_year_ganzi(year)['stem']
year_stem_idx = HEAVENLY_STEMS.index(year_stem)
stem_idx = (year_stem_idx * 2 + branch_idx) % 10
return {
'stem': HEAVENLY_STEMS[stem_idx],
'branch': EARTHLY_BRANCHES[branch_idx],
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
}
def get_day_ganzi(year: int, month: int, day: int) -> dict:
base = date(1900, 1, 1)
target = date(year, month, day)
days_diff = (target - base).days
stem_idx = (BASE_DAY_STEM + days_diff) % 10
branch_idx = (BASE_DAY_BRANCH + days_diff) % 12
# 음수 처리 (1900년 이전)
if stem_idx < 0:
stem_idx += 10
if branch_idx < 0:
branch_idx += 12
return {
'stem': HEAVENLY_STEMS[stem_idx],
'branch': EARTHLY_BRANCHES[branch_idx],
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
}
def get_hour_ganzi(day_stem: str, hour: int) -> dict:
if hour >= 23 or hour < 1:
branch_idx = 0 # 子
elif hour < 3:
branch_idx = 1 # 丑
elif hour < 5:
branch_idx = 2 # 寅
elif hour < 7:
branch_idx = 3 # 卯
elif hour < 9:
branch_idx = 4 # 辰
elif hour < 11:
branch_idx = 5 # 巳
elif hour < 13:
branch_idx = 6 # 午
elif hour < 15:
branch_idx = 7 # 未
elif hour < 17:
branch_idx = 8 # 申
elif hour < 19:
branch_idx = 9 # 酉
elif hour < 21:
branch_idx = 10 # 戌
else:
branch_idx = 11 # 亥
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
stem_idx = (day_stem_idx * 2 + branch_idx) % 10
return {
'stem': HEAVENLY_STEMS[stem_idx],
'branch': EARTHLY_BRANCHES[branch_idx],
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'branchKr': EARTHLY_BRANCHES_KR[branch_idx],
}
# ============================================================
# 십성 계산
# ============================================================
_PRODUCE_MAP = {'': '', '': '', '': '', '': '', '': ''}
_OVERCOME_MAP = {'': '', '': '', '': '', '': '', '': ''}
def get_ten_god(day_stem: str, target_stem: str, is_yang: bool) -> str:
day_elem = FIVE_ELEMENTS.get(day_stem, '')
target_elem = FIVE_ELEMENTS.get(target_stem, '')
if day_elem == target_elem:
return '비견' if is_yang else '겁재'
if _PRODUCE_MAP.get(day_elem) == target_elem:
return '식신' if is_yang else '상관'
if _OVERCOME_MAP.get(day_elem) == target_elem:
return '편재' if is_yang else '정재'
if _OVERCOME_MAP.get(target_elem) == day_elem:
return '편관' if is_yang else '정관'
if _PRODUCE_MAP.get(target_elem) == day_elem:
return '편인' if is_yang else '정인'
return '비견'
# ============================================================
# 십이운성 계산
# ============================================================
_FORTUNE_MAP: dict[str, dict[str, int]] = {
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
'': {'': 11, '': 0, '': 1, '': 2, '': 3, '': 4, '': 5, '': 6, '': 7, '': 8, '': 9, '': 10},
}
def get_twelve_fortune(day_stem: str, branch: str) -> str:
idx = _FORTUNE_MAP.get(day_stem, {}).get(branch, 0)
return TWELVE_FORTUNES[idx]
# ============================================================
# 지장간 (藏干)
# ============================================================
HIDDEN_STEMS: dict[str, list[str]] = {
'': [''],
'': ['', '', ''],
'': ['', '', ''],
'': [''],
'': ['', '', ''],
'': ['', '', ''],
'': ['', ''],
'': ['', '', ''],
'': ['', '', ''],
'': [''],
'': ['', '', ''],
'': ['', ''],
}
_ROLE_NAMES = ['정기(본기)', '중기', '여기']
def get_hidden_stems(branch: str) -> list[str]:
return HIDDEN_STEMS.get(branch, [])
def get_all_hidden_stems(saju: dict) -> list[dict]:
pillars = [
{'pillar': '년주', 'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr']},
{'pillar': '월주', 'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr']},
{'pillar': '일주', 'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr']},
]
if saju.get('hour'):
pillars.append({'pillar': '시주', 'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr']})
result = []
for p in pillars:
hidden = get_hidden_stems(p['branch'])
stems_info = []
for idx, stem in enumerate(hidden):
stem_idx = HEAVENLY_STEMS.index(stem)
stems_info.append({
'stem': stem,
'stemKr': HEAVENLY_STEMS_KR[stem_idx],
'element': FIVE_ELEMENTS.get(stem, ''),
'role': _ROLE_NAMES[idx] if idx < len(_ROLE_NAMES) else '여기',
})
result.append({
'pillar': p['pillar'],
'branch': p['branch'],
'branchKr': p['branchKr'],
'stems': stems_info,
})
return result
# ============================================================
# 지지 상호작용
# ============================================================
_YUKAP_PAIRS = [
('', '', ''), ('', '', ''), ('', '', ''),
('', '', ''), ('', '', ''), ('', '', ''),
]
_SAMHAP_GROUPS = [
('', '', '', ''), ('', '', '', ''),
('', '', '', ''), ('', '', '', ''),
]
_BANGHAP_GROUPS = [
('', '', '', ''), ('', '', '', ''),
('', '', '', ''), ('', '', '', ''),
]
_CHUNG_PAIRS = [
('', ''), ('', ''), ('', ''),
('', ''), ('', ''), ('', ''),
]
_HYUNG_GROUPS = [
{'branches': ['', '', ''], 'name': '무은지형(無恩之刑)'},
{'branches': ['', '', ''], 'name': '지세지형(恃勢之刑)'},
{'branches': ['', ''], 'name': '무례지형(無禮之刑)'},
]
_JAHYUNG_BRANCHES = ['', '', '', '']
_PA_PAIRS = [
('', ''), ('', ''), ('', ''),
('', ''), ('', ''), ('', ''),
]
_HAE_PAIRS = [
('', ''), ('', ''), ('', ''),
('', ''), ('', ''), ('', ''),
]
_ELEM_KR = {'': '', '': '', '': '', '': '', '': ''}
def analyze_branch_interactions(saju: dict) -> list[dict]:
pillar_branches = [
{'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr'], 'pillar': '년주'},
{'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr'], 'pillar': '월주'},
{'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr'], 'pillar': '일주'},
]
if saju.get('hour'):
pillar_branches.append({'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr'], 'pillar': '시주'})
branches = [p['branch'] for p in pillar_branches]
interactions = []
# 육합
for a, b, elem in _YUKAP_PAIRS:
if a in branches and b in branches:
idx_a = branches.index(a)
idx_b = branches.index(b)
interactions.append({
'type': '육합(六合)',
'branches': [a, b],
'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 육합 → {_ELEM_KR.get(elem, '')}({elem}) 기운 생성. 조화와 화합의 관계.",
'resultElement': elem,
})
# 삼합
for a, b, c, elem in _SAMHAP_GROUPS:
found = [x for x in [a, b, c] if x in branches]
if len(found) >= 2:
found_pillars = [pillar_branches[branches.index(x)] for x in found]
is_complete = len(found) == 3
interactions.append({
'type': '삼합(三合)' if is_complete else '반삼합(半三合)',
'branches': found,
'branchesKr': [p['branchKr'] for p in found_pillars],
'pillars': [p['pillar'] for p in found_pillars],
'description': f"{''.join(p['branchKr'] for p in found_pillars)} {'삼합' if is_complete else '반삼합'}{_ELEM_KR.get(elem, '')}({elem})국.",
'resultElement': elem,
})
# 방합
for a, b, c, elem in _BANGHAP_GROUPS:
found = [x for x in [a, b, c] if x in branches]
if len(found) == 3:
found_pillars = [pillar_branches[branches.index(x)] for x in found]
interactions.append({
'type': '방합(方合)',
'branches': found,
'branchesKr': [p['branchKr'] for p in found_pillars],
'pillars': [p['pillar'] for p in found_pillars],
'description': f"{''.join(p['branchKr'] for p in found_pillars)} 방합 → {_ELEM_KR.get(elem, '')}({elem}) 방국. 매우 강한 오행 기운.",
'resultElement': elem,
})
# 충
for a, b in _CHUNG_PAIRS:
if a in branches and b in branches:
idx_a = branches.index(a)
idx_b = branches.index(b)
interactions.append({
'type': '충(沖)',
'branches': [a, b],
'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 충 → 변동, 갈등, 변화의 에너지.",
})
# 형
for group in _HYUNG_GROUPS:
found = [x for x in group['branches'] if x in branches]
if len(found) >= 2:
found_pillars = [pillar_branches[branches.index(x)] for x in found]
interactions.append({
'type': '형(刑)',
'branches': found,
'branchesKr': [p['branchKr'] for p in found_pillars],
'pillars': [p['pillar'] for p in found_pillars],
'description': f"{''.join(p['branchKr'] for p in found_pillars)} {group['name']} → 시련과 갈등의 기운.",
})
# 자형
for jb in _JAHYUNG_BRANCHES:
count = branches.count(jb)
if count >= 2:
br_kr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(jb)]
interactions.append({
'type': '자형(自刑)',
'branches': [jb, jb],
'branchesKr': [br_kr, br_kr],
'pillars': [p['pillar'] for p in pillar_branches if p['branch'] == jb],
'description': f'{br_kr}{br_kr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.',
})
# 파
for a, b in _PA_PAIRS:
if a in branches and b in branches:
idx_a = branches.index(a)
idx_b = branches.index(b)
interactions.append({
'type': '파(破)',
'branches': [a, b],
'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 파 → 관계의 균열, 계획의 차질 가능성.",
})
# 해
for a, b in _HAE_PAIRS:
if a in branches and b in branches:
idx_a = branches.index(a)
idx_b = branches.index(b)
interactions.append({
'type': '해(害)',
'branches': [a, b],
'branchesKr': [pillar_branches[idx_a]['branchKr'], pillar_branches[idx_b]['branchKr']],
'pillars': [pillar_branches[idx_a]['pillar'], pillar_branches[idx_b]['pillar']],
'description': f"{pillar_branches[idx_a]['branchKr']}{pillar_branches[idx_b]['branchKr']} 해 → 은근한 방해, 원망의 기운.",
})
return interactions
# ============================================================
# 신살 (神煞)
# ============================================================
_SAMHAP_GROUP_MAP: dict[str, str] = {
'': '申子辰', '': '申子辰', '': '申子辰',
'': '寅午戌', '': '寅午戌', '': '寅午戌',
'': '巳酉丑', '': '巳酉丑', '': '巳酉丑',
'': '亥卯未', '': '亥卯未', '': '亥卯未',
}
_YEOKMA_MAP = {'申子辰': '', '寅午戌': '', '巳酉丑': '', '亥卯未': ''}
_DOHWA_MAP = {'申子辰': '', '寅午戌': '', '巳酉丑': '', '亥卯未': ''}
_HWAGAE_MAP = {'申子辰': '', '寅午戌': '', '巳酉丑': '', '亥卯未': ''}
_CHEONUL_MAP: dict[str, list[str]] = {
'': ['', ''], '': ['', ''], '': ['', ''], '': ['', ''],
'': ['', ''], '': ['', ''], '': ['', ''], '': ['', ''],
'': ['', ''], '': ['', ''],
}
_MUNCHANG_MAP: dict[str, str] = {
'': '', '': '', '': '', '': '',
'': '', '': '', '': '', '': '',
'': '', '': '',
}
_CHEONDUK_MAP: dict[str, str] = {
'': '', '': '', '': '', '': '',
'': '', '': '', '': '', '': '',
'': '', '': '', '': '', '': '',
}
def calculate_shinsal(saju: dict) -> list[dict]:
result = []
day_branch = saju['day']['branch']
day_stem = saju['dayStem']
month_branch = saju['month']['branch']
pillar_branches = [
{'branch': saju['year']['branch'], 'branchKr': saju['year']['branchKr'], 'pillar': '년주'},
{'branch': saju['month']['branch'], 'branchKr': saju['month']['branchKr'], 'pillar': '월주'},
{'branch': saju['day']['branch'], 'branchKr': saju['day']['branchKr'], 'pillar': '일주'},
]
if saju.get('hour'):
pillar_branches.append({'branch': saju['hour']['branch'], 'branchKr': saju['hour']['branchKr'], 'pillar': '시주'})
group = _SAMHAP_GROUP_MAP.get(day_branch)
if group:
# 역마살
yeokma = _YEOKMA_MAP[group]
for pb in pillar_branches:
if pb['branch'] == yeokma and pb['pillar'] != '일주':
result.append({
'name': '역마살', 'nameHanja': '驛馬殺',
'branch': yeokma, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
'description': '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
})
# 도화살
dohwa = _DOHWA_MAP[group]
for pb in pillar_branches:
if pb['branch'] == dohwa and pb['pillar'] != '일주':
result.append({
'name': '도화살', 'nameHanja': '桃花殺',
'branch': dohwa, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
'description': '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
})
# 화개살
hwagae = _HWAGAE_MAP[group]
for pb in pillar_branches:
if pb['branch'] == hwagae and pb['pillar'] != '일주':
result.append({
'name': '화개살', 'nameHanja': '華蓋殺',
'branch': hwagae, 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
'description': '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
})
# 천을귀인
cheonul_branches = _CHEONUL_MAP.get(day_stem, [])
for pb in pillar_branches:
if pb['branch'] in cheonul_branches and pb['pillar'] != '일주':
result.append({
'name': '천을귀인', 'nameHanja': '天乙貴人',
'branch': pb['branch'], 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
'description': '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
})
# 문창귀인
munchang_branch = _MUNCHANG_MAP.get(day_stem)
if munchang_branch:
for pb in pillar_branches:
if pb['branch'] == munchang_branch and pb['pillar'] != '일주':
result.append({
'name': '문창귀인', 'nameHanja': '文昌貴人',
'branch': pb['branch'], 'branchKr': pb['branchKr'], 'pillar': pb['pillar'],
'description': '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
})
# 천덕귀인 (월지 기준, 천간에서 확인)
cheonduk_stem = _CHEONDUK_MAP.get(month_branch)
if cheonduk_stem:
all_stems = [
{'stem': saju['year']['stem'], 'pillar': '년주'},
{'stem': saju['day']['stem'], 'pillar': '일주'},
]
if saju.get('hour'):
all_stems.append({'stem': saju['hour']['stem'], 'pillar': '시주'})
for ps in all_stems:
if ps['stem'] == cheonduk_stem:
result.append({
'name': '천덕귀인', 'nameHanja': '天德貴人',
'branch': month_branch,
'branchKr': EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.index(month_branch)],
'pillar': ps['pillar'],
'description': '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
})
return result
# ============================================================
# 공망 (空亡)
# ============================================================
def calculate_gongmang(day_stem: str, day_branch: str) -> dict:
stem_idx = HEAVENLY_STEMS.index(day_stem)
branch_idx = EARTHLY_BRANCHES.index(day_branch)
start_branch_idx = (branch_idx - stem_idx + 120) % 12
gm1 = (start_branch_idx + 10) % 12
gm2 = (start_branch_idx + 11) % 12
branch1 = EARTHLY_BRANCHES[gm1]
branch2 = EARTHLY_BRANCHES[gm2]
br_kr1 = EARTHLY_BRANCHES_KR[gm1]
br_kr2 = EARTHLY_BRANCHES_KR[gm2]
return {
'branches': [branch1, branch2],
'branchesKr': [br_kr1, br_kr2],
'description': f'{br_kr1}({branch1}{br_kr2}({branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.',
}
# ============================================================
# 사주팔자 전체 계산
# ============================================================
def calculate_saju(
year: int,
month: int,
day: int,
hour: Optional[int],
gender: str,
) -> dict:
year_ganzi = get_year_ganzi(year)
month_ganzi = get_month_ganzi(year, month, day)
day_ganzi = get_day_ganzi(year, month, day)
hour_ganzi = get_hour_ganzi(day_ganzi['stem'], hour) if hour is not None else None
day_stem = day_ganzi['stem']
day_stem_idx = HEAVENLY_STEMS.index(day_stem)
is_day_yang = day_stem_idx % 2 == 0
def enrich(ganzi: dict, is_day_pillar: bool = False) -> dict:
stem = ganzi['stem']
branch = ganzi['branch']
stem_idx = HEAVENLY_STEMS.index(stem)
is_yang = (stem_idx % 2 == 0) == is_day_yang
return {
**ganzi,
'element': FIVE_ELEMENTS.get(stem, ''),
'tenGod': '일간' if is_day_pillar else get_ten_god(day_stem, stem, is_yang),
'fortune': get_twelve_fortune(day_stem, branch),
}
saju: dict = {
'year': enrich(year_ganzi),
'month': enrich(month_ganzi),
'day': enrich(day_ganzi, is_day_pillar=True),
'dayStem': day_stem,
'birthDate': {'year': year, 'month': month, 'day': day, 'hour': hour},
'gender': gender,
}
if hour_ganzi:
saju['hour'] = enrich(hour_ganzi)
return saju

View File

@@ -0,0 +1,191 @@
"""
24절기 계산 모듈
ephem 라이브러리를 사용한 정밀한 절기 날짜 계산
"""
import ephem
import math
from datetime import datetime, date, timedelta
from typing import Optional
# 24절기 이름 (한글)
SOLAR_TERMS = [
'입춘', '우수', '경칩', '춘분', '청명', '곡우',
'입하', '소만', '망종', '하지', '소서', '대서',
'입추', '처서', '백로', '추분', '한로', '상강',
'입동', '소설', '대설', '동지', '소한', '대한'
]
# 각 절기에 대응하는 태양황경 (도)
SOLAR_TERM_ANGLES = [
315, 330, 345, 0, 15, 30,
45, 60, 75, 90, 105, 120,
135, 150, 165, 180, 195, 210,
225, 240, 255, 270, 285, 300
]
# 절기별 대략적인 월
SOLAR_TERM_BASE_MONTHS = [
2, 2, 3, 3, 4, 4,
5, 5, 6, 6, 7, 7,
8, 8, 9, 9, 10, 10,
11, 11, 12, 12, 1, 1
]
# 절기별 대략적인 일
SOLAR_TERM_BASE_DAYS = [
4, 19, 5, 20, 4, 20,
5, 21, 6, 21, 7, 23,
7, 23, 8, 23, 8, 23,
7, 22, 7, 22, 5, 20
]
# 절기 → 월지지 인덱스 매핑
# 입춘(0) → 인월(2), 우수(1) → 인월(2), ...
TERM_TO_MONTH_BRANCH = [
2, # 입춘 → 인월
2, # 우수 → 인월
3, # 경칩 → 묘월
3, # 춘분 → 묘월
4, # 청명 → 진월
4, # 곡우 → 진월
5, # 입하 → 사월
5, # 소만 → 사월
6, # 망종 → 오월
6, # 하지 → 오월
7, # 소서 → 미월
7, # 대서 → 미월
8, # 입추 → 신월
8, # 처서 → 신월
9, # 백로 → 유월
9, # 추분 → 유월
10, # 한로 → 술월
10, # 상강 → 술월
11, # 입동 → 해월
11, # 소설 → 해월
0, # 대설 → 자월
0, # 동지 → 자월
1, # 소한 → 축월
1, # 대한 → 축월
]
def _get_solar_longitude(dt: datetime) -> float:
"""주어진 날짜시간의 태양황경 계산 (ephem 사용)"""
sun = ephem.Sun()
sun.compute(dt.strftime('%Y/%m/%d %H:%M:%S'))
ecl = ephem.Ecliptic(sun)
return math.degrees(ecl.lon) % 360
def _get_solar_term_date_ephem(year: int, term_index: int) -> Optional[date]:
"""ephem을 사용해 특정 절기 날짜 계산"""
target_angle = SOLAR_TERM_ANGLES[term_index]
base_month = SOLAR_TERM_BASE_MONTHS[term_index]
base_day = SOLAR_TERM_BASE_DAYS[term_index]
# 소한(22), 대한(23)은 1월이지만 기준 년도에서 검색
search_year = year if term_index < 22 else year
try:
start_day = max(1, base_day - 5)
start_dt = datetime(search_year, base_month, start_day)
except ValueError:
start_dt = datetime(search_year, base_month, 1)
# 20일 범위에서 절기 날짜 탐색
prev_diff = None
for i in range(20):
check_dt = start_dt + timedelta(days=i)
lon = _get_solar_longitude(check_dt)
# 황경 차이 계산 (0° 교차 처리)
diff = (lon - target_angle + 360) % 360
if diff > 180:
diff -= 360
if abs(diff) < 2.0:
return check_dt.date()
# 부호가 바뀌면 직전 날짜가 절기
if prev_diff is not None and prev_diff * diff < 0:
return (check_dt - timedelta(days=1)).date()
prev_diff = diff
return None
def get_solar_term_date(year: int, term_index: int) -> date:
"""특정 년도의 특정 절기 날짜 반환"""
try:
result = _get_solar_term_date_ephem(year, term_index)
if result:
return result
except Exception:
pass
# 폴백: 근사값 사용
base_month = SOLAR_TERM_BASE_MONTHS[term_index]
base_day = SOLAR_TERM_BASE_DAYS[term_index]
try:
return date(year, base_month, base_day)
except ValueError:
return date(year, base_month, min(28, base_day))
def get_current_solar_term(year: int, month: int, day: int) -> int:
"""주어진 날짜가 어느 절기 이후인지 반환 (0~23)"""
target = date(year, month, day)
# 역순으로 확인 (가장 최근 절기 찾기)
for i in range(23, -1, -1):
term_date = get_solar_term_date(year, i)
# 소한, 대한의 경우 년도 조정
if i >= 22:
if month >= 2:
term_date = date(year, term_date.month, term_date.day)
else:
term_date = date(year - 1, term_date.month, term_date.day)
if target >= term_date:
return i
return 23 # 입춘 이전 → 전년도 대한 이후
def get_solar_term_month_branch(year: int, month: int, day: int) -> int:
"""절기 기준 월주 지지 인덱스 계산 (0=자, 1=축, 2=인, ...)"""
term_index = get_current_solar_term(year, month, day)
return TERM_TO_MONTH_BRANCH[term_index]
def get_days_to_next_solar_term(year: int, month: int, day: int) -> int:
"""다음 절기까지 남은 일수 계산"""
current_term = get_current_solar_term(year, month, day)
next_term_index = (current_term + 1) % 24
next_year = year + 1 if current_term == 23 else year
next_term_date = get_solar_term_date(next_year, next_term_index)
current_date = date(year, month, day)
diff = (next_term_date - current_date).days
return max(1, diff)
def get_days_from_prev_solar_term(year: int, month: int, day: int) -> int:
"""이전 절기부터 주어진 날짜까지의 일수 계산"""
current_term = get_current_solar_term(year, month, day)
term_date = get_solar_term_date(year, current_term)
# 소한, 대한 년도 조정
if current_term >= 22:
if month >= 2:
term_date = date(year, term_date.month, term_date.day)
else:
term_date = date(year - 1, term_date.month, term_date.day)
target = date(year, month, day)
diff = (target - term_date).days
return max(1, diff)

View File

@@ -0,0 +1,23 @@
version: '3.9'
services:
saju-engine:
build: .
container_name: saju-engine
restart: unless-stopped
ports:
- "8000:8000"
environment:
- API_SECRET=${API_SECRET}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-*}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- ENV=${ENV:-production}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
# NAS에서 메모리 제한 권장 (선택)
# mem_limit: 512m
# cpus: '1.0'

274
saju-engine/main.py Normal file
View File

@@ -0,0 +1,274 @@
"""
사주 계산 엔진 API
FastAPI + ephem 기반 사주팔자 계산 서비스
환경변수:
API_SECRET: X-API-Secret 헤더 검증용 시크릿
ALLOWED_ORIGINS: CORS 허용 오리진 (쉼표 구분, 기본값: *)
LOG_LEVEL: 로그 레벨 (기본값: INFO)
"""
import os
import logging
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from pydantic import BaseModel, Field, field_validator
from dotenv import load_dotenv
from calculator.saju_calculator import (
calculate_saju,
analyze_branch_interactions,
calculate_shinsal,
calculate_gongmang,
get_all_hidden_stems,
HEAVENLY_STEMS,
EARTHLY_BRANCHES,
)
from calculator.daeun_calculator import calculate_daeun, get_current_daeun
from calculator.lotto_generator import generate_lotto_numbers, generate_multiple_sets
load_dotenv()
# ============================================================
# 설정
# ============================================================
API_SECRET = os.getenv('API_SECRET', '')
ALLOWED_ORIGINS = os.getenv('ALLOWED_ORIGINS', '*').split(',')
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
)
logger = logging.getLogger('saju-engine')
# ============================================================
# Rate Limiter
# ============================================================
limiter = Limiter(key_func=get_remote_address)
# ============================================================
# FastAPI 앱
# ============================================================
app = FastAPI(
title='사주 계산 엔진',
description='NAS Docker 기반 사주팔자 계산 API',
version='1.0.0',
docs_url='/docs' if os.getenv('ENV', 'development') == 'development' else None,
redoc_url=None,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=['GET', 'POST'],
allow_headers=['Content-Type', 'X-API-Secret'],
)
# ============================================================
# 인증 의존성
# ============================================================
def verify_secret(request: Request):
if not API_SECRET:
return # 시크릿 미설정 시 스킵 (개발 환경)
secret = request.headers.get('X-API-Secret', '')
if secret != API_SECRET:
logger.warning(f'Unauthorized request from {request.client.host if request.client else "unknown"}')
raise HTTPException(status_code=401, detail='Unauthorized')
# ============================================================
# 요청/응답 스키마
# ============================================================
class SajuRequest(BaseModel):
year: int = Field(..., ge=1900, le=2100, description='생년')
month: int = Field(..., ge=1, le=12, description='생월')
day: int = Field(..., ge=1, le=31, description='생일')
hour: Optional[int] = Field(None, ge=0, le=23, description='생시 (없으면 null)')
gender: str = Field(..., pattern='^(male|female)$', description='성별')
calendar_type: str = Field('solar', pattern='^(solar|lunar)$', description='양력/음력')
@field_validator('year')
@classmethod
def validate_year(cls, v: int) -> int:
if v < 1900 or v > 2100:
raise ValueError('년도는 1900~2100 범위여야 합니다')
return v
class LottoRequest(BaseModel):
year: int = Field(..., ge=1900, le=2100)
month: int = Field(..., ge=1, le=12)
day: int = Field(..., ge=1, le=31)
hour: Optional[int] = Field(None, ge=0, le=23)
gender: str = Field(..., pattern='^(male|female)$')
sets: int = Field(5, ge=1, le=10, description='생성할 번호 세트 수')
# ============================================================
# 헬스체크
# ============================================================
@app.get('/health')
async def health_check():
return {'status': 'ok', 'timestamp': datetime.utcnow().isoformat()}
# ============================================================
# 사주 계산 엔드포인트
# ============================================================
@app.post('/saju/calculate', dependencies=[Depends(verify_secret)])
@limiter.limit('30/minute')
async def calculate_saju_api(request: Request, body: SajuRequest):
"""
사주팔자 전체 계산
- 사주팔자 (천간/지지/오행/십성/십이운성)
- 대운 (8개)
- 현재 대운
- 지지 상호작용 (합/충/형/파/해)
- 신살
- 공망
- 지장간
"""
try:
logger.info(f'사주 계산 요청: {body.year}/{body.month}/{body.day} {body.gender}')
# 음력 변환 (필요 시)
year, month, day = body.year, body.month, body.day
if body.calendar_type == 'lunar':
try:
import korean_lunar_calendar
calendar = korean_lunar_calendar.KoreanLunarCalendar()
calendar.setLunarDate(year, month, day, False)
solar = calendar.SolarIsoFormat().split('-')
year, month, day = int(solar[0]), int(solar[1]), int(solar[2])
except Exception as e:
logger.warning(f'음력 변환 실패, 양력으로 처리: {e}')
# 사주팔자 계산
saju = calculate_saju(year, month, day, body.hour, body.gender)
# 대운 계산
daeun_list = calculate_daeun(
year, month, day,
body.gender,
saju['month']['stem'],
saju['month']['branch'],
)
# 현재 대운
current_year = datetime.now().year
current_daeun = get_current_daeun(daeun_list, current_year)
# 지지 상호작용
interactions = analyze_branch_interactions(saju)
# 신살
shinsal = calculate_shinsal(saju)
# 공망
gongmang = calculate_gongmang(saju['dayStem'], saju['day']['branch'])
# 지장간
hidden_stems = get_all_hidden_stems(saju)
return {
'saju': saju,
'daeunList': daeun_list,
'currentDaeun': current_daeun,
'interactions': interactions,
'shinsal': shinsal,
'gongmang': gongmang,
'hiddenStems': hidden_stems,
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f'사주 계산 오류: {e}', exc_info=True)
raise HTTPException(status_code=500, detail='사주 계산 중 오류가 발생했습니다')
# ============================================================
# 로또 번호 생성 엔드포인트
# ============================================================
@app.post('/saju/lotto', dependencies=[Depends(verify_secret)])
@limiter.limit('10/minute')
async def generate_lotto_api(request: Request, body: LottoRequest):
"""
사주 기반 로또 번호 생성
- 오행 균형 반영
- 신살 보너스 반영
- 복수 세트 생성
"""
try:
logger.info(f'로또 번호 생성 요청: {body.year}/{body.month}/{body.day} {body.gender}')
saju = calculate_saju(body.year, body.month, body.day, body.hour, body.gender)
shinsal = calculate_shinsal(saju)
# 단일 추천 번호
main_numbers = generate_lotto_numbers(saju, shinsal)
# 복수 세트
multiple_sets = generate_multiple_sets(saju, shinsal, sets=body.sets)
return {
'main': main_numbers,
'sets': multiple_sets,
'dayStem': saju['dayStem'],
'dayBranch': saju['day']['branch'],
}
except Exception as e:
logger.error(f'로또 번호 생성 오류: {e}', exc_info=True)
raise HTTPException(status_code=500, detail='로또 번호 생성 중 오류가 발생했습니다')
# ============================================================
# 절기 정보 엔드포인트
# ============================================================
@app.get('/solar-terms/{year}', dependencies=[Depends(verify_secret)])
@limiter.limit('20/minute')
async def get_solar_terms_api(request: Request, year: int):
"""특정 년도의 24절기 날짜 목록 반환"""
if year < 1900 or year > 2100:
raise HTTPException(status_code=400, detail='년도는 1900~2100 범위여야 합니다')
from calculator.solar_terms import get_solar_term_date, SOLAR_TERMS
terms = []
for i, name in enumerate(SOLAR_TERMS):
d = get_solar_term_date(year, i)
terms.append({
'index': i,
'name': name,
'date': d.isoformat(),
})
return {'year': year, 'terms': terms}
if __name__ == '__main__':
import uvicorn
port = int(os.getenv('PORT', '8000'))
uvicorn.run('main:app', host='0.0.0.0', port=port, reload=False)

View File

@@ -0,0 +1,6 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
ephem==4.1.6
slowapi==0.1.9
python-dotenv==1.0.1
pydantic==2.10.3

121
supabase/schema.sql Normal file
View File

@@ -0,0 +1,121 @@
-- ============================================================
-- 쟁승메이드 Supabase 스키마
-- Supabase SQL Editor에서 순서대로 실행하세요
-- ============================================================
-- ① profiles (유저 프로필 - auth.users와 연결)
create table public.profiles (
id uuid references auth.users(id) on delete cascade primary key,
email text,
full_name text,
avatar_url text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 신규 가입 시 profiles 자동 생성 트리거
create or replace function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, email, full_name, avatar_url)
values (
new.id,
new.email,
new.raw_user_meta_data->>'full_name',
new.raw_user_meta_data->>'avatar_url'
);
return new;
end;
$$ language plpgsql security definer;
create or replace trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- RLS
alter table public.profiles enable row level security;
create policy "본인 프로필 조회" on public.profiles for select using (auth.uid() = id);
create policy "본인 프로필 수정" on public.profiles for update using (auth.uid() = id);
-- ② saju_records (사주 분석 결과 저장)
create table public.saju_records (
id bigint generated by default as identity primary key,
user_id uuid references public.profiles(id) on delete cascade,
saju_data jsonb not null, -- SajuData JSON
interpretation text, -- AI 해석 Markdown
is_paid boolean default false, -- 유료 결제 여부
created_at timestamptz default now()
);
alter table public.saju_records enable row level security;
create policy "본인 사주 기록 조회" on public.saju_records for select using (auth.uid() = user_id);
create policy "본인 사주 기록 생성" on public.saju_records for insert with check (auth.uid() = user_id);
-- ③ products (판매 상품 정의)
create table public.products (
id text primary key, -- 예: 'saju_basic', 'saju_detail'
name text not null, -- 상품명
description text,
price integer not null, -- 원 단위
category text not null, -- 'saju' | 'lotto' | 'subscription'
is_active boolean default true,
created_at timestamptz default now()
);
-- 초기 상품 데이터
insert into public.products (id, name, description, price, category) values
('saju_detail', 'AI 사주 상세 리포트', '신강/신약, 용신, 대운, AI 12가지 항목 해석', 4900, 'saju'),
('lotto_premium', '로또 프리미엄 구독', '매주 프리미엄 번호 5조합 + 통계', 4900, 'lotto');
-- ④ orders (주문 - 결제 전 생성)
create table public.orders (
id uuid default gen_random_uuid() primary key,
user_id uuid references public.profiles(id) on delete set null,
product_id text references public.products(id),
amount integer not null,
status text default 'pending', -- 'pending' | 'paid' | 'failed' | 'cancelled'
metadata jsonb, -- 추가 정보 (saju_record_id 등)
created_at timestamptz default now(),
updated_at timestamptz default now()
);
alter table public.orders enable row level security;
create policy "본인 주문 조회" on public.orders for select using (auth.uid() = user_id);
create policy "본인 주문 생성" on public.orders for insert with check (auth.uid() = user_id);
-- ⑤ payments (결제 완료 내역 - 서버에서 검증 후 저장)
create table public.payments (
id uuid default gen_random_uuid() primary key,
user_id uuid references public.profiles(id) on delete set null,
order_id uuid references public.orders(id),
product_name text not null,
amount integer not null,
status text default 'paid', -- 'paid' | 'refunded' | 'partial_refund'
pg_provider text, -- 'toss' | 'portone'
pg_payment_key text unique, -- PG사 결제 키
created_at timestamptz default now()
);
alter table public.payments enable row level security;
create policy "본인 결제 내역 조회" on public.payments for select using (auth.uid() = user_id);
-- ⑥ contact_requests (외주/서비스 문의 내역)
create table public.contact_requests (
id uuid default gen_random_uuid() primary key,
user_id uuid references public.profiles(id) on delete set null,
email text not null,
name text,
service text not null, -- 문의 서비스 종류
message text not null,
status text default 'pending', -- 'pending' | 'in_progress' | 'completed'
created_at timestamptz default now()
);
alter table public.contact_requests enable row level security;
create policy "본인 의뢰 내역 조회" on public.contact_requests for select using (auth.uid() = user_id);
create policy "누구나 의뢰 생성" on public.contact_requests for insert with check (true);

View File

@@ -0,0 +1,35 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createServerClient, type CookieMethodsServer } from '@supabase/ssr';
export async function updateSession(request: NextRequest) {
// Supabase 환경변수가 없으면 그냥 통과
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
return NextResponse.next({ request });
}
let supabaseResponse = NextResponse.next({ request });
const cookieMethods: CookieMethodsServer = {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
};
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
{ cookies: cookieMethods }
);
// 세션 갱신 (IMPORTANT: getUser()는 반드시 호출해야 함)
await supabase.auth.getUser();
return supabaseResponse;
}