사주 기능 이식 & 로그인, 유저 페이지 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

23
saju-engine/Dockerfile Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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