사주 기능 이식 & 로그인, 유저 페이지 Supabase 연동 & 토스 페이먼츠 결제 연동 & 사주 심층 분석을 위한 기능 분리
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user