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

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
export async function POST(request: NextRequest) {
if (!SAJU_ENGINE_URL) {
return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
}
try {
const body = await request.json();
const response = await fetch(`${SAJU_ENGINE_URL}/saju/calculate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000), // 15초 타임아웃
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || '사주 계산 실패' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'TimeoutError') {
return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
}
console.error('사주 계산 프록시 오류:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}

View File

@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
export async function POST(request: NextRequest) {
if (!SAJU_ENGINE_URL) {
return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
}
try {
const body = await request.json();
const response = await fetch(`${SAJU_ENGINE_URL}/saju/lotto`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || '로또 번호 생성 실패' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'TimeoutError') {
return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
}
console.error('로또 번호 생성 프록시 오류:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}

View File

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