사주 기능 이식 & 로그인, 유저 페이지 Supabase 연동 & 토스 페이먼츠 결제 연동 & 사주 심층 분석을 위한 기능 분리
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
82
app/api/payment/confirm/route.ts
Normal file
82
app/api/payment/confirm/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
121
app/api/saju/analyze/route.ts
Normal file
121
app/api/saju/analyze/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
app/api/saju/calculate/route.ts
Normal file
41
app/api/saju/calculate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
41
app/api/saju/lotto/route.ts
Normal file
41
app/api/saju/lotto/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
49
app/api/saju/save-interpretation/route.ts
Normal file
49
app/api/saju/save-interpretation/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
26
app/auth/callback/route.ts
Normal file
26
app/auth/callback/route.ts
Normal 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`);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
93
app/components/PaymentButton.tsx
Normal file
93
app/components/PaymentButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
{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">쟁토리</div>
|
||||
<div className="text-slate-500 text-xs">시니어 백엔드 개발자</div>
|
||||
<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" title="온라인" />
|
||||
<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="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>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
213
app/login/page.tsx
Normal file
213
app/login/page.tsx
Normal 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
390
app/mypage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/page.tsx
41
app/page.tsx
@@ -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
62
app/payment/fail/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
app/payment/success/page.tsx
Normal file
138
app/payment/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
app/saju/components/SajuForm.tsx
Normal file
220
app/saju/components/SajuForm.tsx
Normal 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
43
app/saju/input/page.tsx
Normal 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
341
app/saju/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
app/saju/result/SajuAISection.tsx
Normal file
148
app/saju/result/SajuAISection.tsx
Normal 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
584
app/saju/result/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}
|
||||
{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
386
lib/ai-interpretation.ts
Normal 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
188
lib/daeun-calculator.ts
Normal 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
97
lib/lunar-utils.ts
Normal 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
66
lib/products.ts
Normal 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
223
lib/saju-ai-prompt.ts
Normal 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
723
lib/saju-calculator.ts
Normal 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
249
lib/solar-terms.ts
Normal 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
7
lib/supabase/client.ts
Normal 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
27
lib/supabase/server.ts
Normal 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
12
middleware.ts
Normal 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
1671
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
23
saju-engine/Dockerfile
Normal 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"]
|
||||
0
saju-engine/calculator/__init__.py
Normal file
0
saju-engine/calculator/__init__.py
Normal file
109
saju-engine/calculator/daeun_calculator.py
Normal file
109
saju-engine/calculator/daeun_calculator.py
Normal 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
|
||||
267
saju-engine/calculator/lotto_generator.py
Normal file
267
saju-engine/calculator/lotto_generator.py
Normal 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
|
||||
596
saju-engine/calculator/saju_calculator.py
Normal file
596
saju-engine/calculator/saju_calculator.py
Normal 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
|
||||
191
saju-engine/calculator/solar_terms.py
Normal file
191
saju-engine/calculator/solar_terms.py
Normal 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)
|
||||
23
saju-engine/docker-compose.yml
Normal file
23
saju-engine/docker-compose.yml
Normal 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
274
saju-engine/main.py
Normal 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)
|
||||
6
saju-engine/requirements.txt
Normal file
6
saju-engine/requirements.txt
Normal 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
121
supabase/schema.sql
Normal 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);
|
||||
35
utils/supabase/middleware.ts
Normal file
35
utils/supabase/middleware.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user