Files
web-page-backend/backend/app/main.py
gahusb c9737b380f 로또 종합 추론 API 추가 (5가지 통계 기법 가중 투표)
- analyzer.py: generate_combined_recommendation() 함수 추가
  빈도Z(25%)·조합지문(30%)·갭(20%)·공동출현(15%)·다양성(10%) 가중 투표
- main.py: GET /api/lotto/recommend/combined 엔드포인트 추가
  결과를 태그 "종합추론"으로 recommendations 테이블에 저장
- main.py: GET /api/lotto/recommend/combined/history 엔드포인트 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:40:53 +09:00

1061 lines
35 KiB
Python

import os
import time
from typing import Optional, List, Dict, Any, Tuple
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from apscheduler.schedulers.background import BackgroundScheduler
from .db import (
init_db, get_draw, get_latest_draw, get_all_draw_numbers,
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
update_recommendation,
# 시뮬레이션 관련
get_best_picks, get_simulation_runs, get_simulation_candidates,
# todos
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
# blog
get_all_posts, create_post, update_post, delete_post,
# realestate
get_all_complexes, get_complex, create_complex, update_complex, delete_complex,
# subscription
get_all_subscription_items, create_subscription_item,
update_subscription_item, delete_subscription_item,
get_subscription_profile, upsert_subscription_profile,
# 성과 통계
get_recommendation_performance,
# Phase 2: 구매 이력
add_purchase, get_purchases, update_purchase, delete_purchase, get_purchase_stats,
# Phase 2: 주간 리포트 캐시
save_weekly_report, get_weekly_report_list, get_weekly_report,
# Phase 2: 개인 패턴 분석
get_all_recommendation_numbers,
)
from .recommender import recommend_numbers, recommend_with_heatmap
from .collector import sync_latest, sync_ensure_all
from .generator import run_simulation, generate_smart_recommendations
from .checker import check_results_for_draw
from .utils import calc_metrics, calc_recent_overlap
from .analyzer import get_statistical_report, generate_weekly_report, analyze_personal_patterns, generate_combined_recommendation
app = FastAPI()
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
# ── 성과 통계 인메모리 캐시 ───────────────────────────────────────────────────
# 채점 데이터는 하루 2번 스케줄러 실행 시에만 갱신되므로 인메모리 캐시로 충분
_PERF_CACHE: Dict[str, Any] = {"data": None, "at": 0.0}
_PERF_CACHE_TTL = 3600 # 1시간 (스케줄러 미실행 상황 대비 폴백)
def _refresh_perf_cache() -> None:
_PERF_CACHE["data"] = get_recommendation_performance()
_PERF_CACHE["at"] = time.time()
print("[PerfCache] 성과 통계 캐시 갱신")
@app.on_event("startup")
def on_startup():
init_db()
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
def _sync_and_check():
res = sync_latest(LATEST_URL)
if res["was_new"]:
check_results_for_draw(res["drawNo"])
_refresh_perf_cache() # 새 채점 결과 반영 → 즉시 갱신
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
# 2. 몬테카를로 시뮬레이션 (하루 6회: 0, 4, 8, 12, 16, 20시)
# 20,000개 후보 생성 → 스코어링 → 상위 100개 저장 → best_picks 교체
def _run_simulation_job():
run_simulation(n_candidates=20000, top_k=100, best_n=20)
scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=5)
# 3. 토요일 오전 9시 — 다음 회차 공략 리포트 자동 캐싱
def _save_weekly_report_job():
import json as _json
draws = get_all_draw_numbers()
latest = get_latest_draw()
if not draws or not latest:
return
target = latest["drw_no"] + 1
report = generate_weekly_report(draws, target)
save_weekly_report(target, _json.dumps(report, ensure_ascii=False))
print(f"[WeeklyReport] {target}회차 리포트 저장 완료")
scheduler.add_job(_save_weekly_report_job, "cron", day_of_week="sat", hour=9, minute=0)
scheduler.start()
@app.get("/health")
def health():
return {"ok": True}
@app.get("/api/lotto/latest")
def api_latest():
row = get_latest_draw()
if not row:
raise HTTPException(status_code=404, detail="No data yet")
return {
"drawNo": row["drw_no"],
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
}
@app.get("/api/lotto/{drw_no:int}")
def api_draw(drw_no: int):
row = get_draw(drw_no)
if not row:
raise HTTPException(status_code=404, detail="Not found")
return {
"drwNo": row["drw_no"],
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
}
@app.post("/api/admin/sync_latest")
def admin_sync_latest():
res = sync_latest(LATEST_URL)
if res["was_new"]:
check_results_for_draw(res["drawNo"])
return res
@app.post("/api/admin/auto_gen")
def admin_auto_gen(count: int = 10):
"""기존 호환 유지: 소규모 시뮬레이션 수동 트리거"""
n = generate_smart_recommendations(count)
return {"generated": n}
@app.post("/api/admin/simulate")
def admin_simulate(n_candidates: int = 20000, top_k: int = 100, best_n: int = 20):
"""
몬테카를로 시뮬레이션 수동 트리거.
백그라운드 스케줄과 동일한 동작을 즉시 실행.
"""
result = run_simulation(
n_candidates=max(1000, min(n_candidates, 50000)),
top_k=max(10, min(top_k, 500)),
best_n=max(10, min(best_n, 50)),
)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return result
@app.get("/api/lotto/stats")
def api_stats():
sync_ensure_all(LATEST_URL, ALL_URL)
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
frequency = {n: 0 for n in range(1, 46)}
total_draws = len(draws)
for _, nums in draws:
for n in nums:
frequency[n] += 1
stats = [
{"number": n, "count": frequency[n]}
for n in range(1, 46)
]
return {
"total_draws": total_draws,
"frequency": stats,
}
# ── 추천 성과 통계 (Phase 1, 인메모리 캐시) ──────────────────────────────────
@app.get("/api/lotto/stats/performance")
def api_performance_stats():
"""
채점된 추천 이력 기반 성과 통계 (캐시 반환).
캐시 갱신 시점: 새 회차 채점 직후 | TTL 1시간 만료 시
"""
if _PERF_CACHE["data"] is None or time.time() - _PERF_CACHE["at"] > _PERF_CACHE_TTL:
_refresh_perf_cache()
return _PERF_CACHE["data"]
# ── 회차 공략 리포트 (Phase 1) ────────────────────────────────────────────────
@app.get("/api/lotto/report/latest")
def api_report_latest():
"""
다음 회차 공략 리포트 (최신 회차 기준으로 자동 계산).
- 과출현/냉각/오버듀 번호 분석
- 최근 3회 패턴
- 3가지 전략별 추천 번호
- AI 신뢰도 점수
"""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
target = latest["drw_no"] + 1
return generate_weekly_report(draws, target)
@app.get("/api/lotto/report/history")
def api_report_history(limit: int = 10):
"""저장된 주간 리포트 목록 (자동 저장된 것만)"""
return {"reports": get_weekly_report_list(limit=min(limit, 52))}
@app.get("/api/lotto/report/{drw_no}")
def api_report_by_draw(drw_no: int):
"""
특정 회차 공략 리포트 (캐시 우선, 없으면 실시간 생성).
"""
cached = get_weekly_report(drw_no)
if cached:
return {**cached, "cached": True}
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
base_draws = [(no, nums) for no, nums in draws if no < drw_no]
if not base_draws:
raise HTTPException(status_code=400, detail=f"{drw_no}회차 이전 데이터가 없습니다")
return {**generate_weekly_report(base_draws, drw_no), "cached": False}
# ── 개인 패턴 분석 (Phase 2) ─────────────────────────────────────────────────
@app.get("/api/lotto/analysis/personal")
def api_personal_analysis():
"""
저장된 추천 이력 기반 개인 패턴 분석.
- 자주 선택한 번호 TOP 10 / 한 번도 선택 안 한 번호
- 홀짝 비율, 합계, 범위, 연속번호 포함률
- 구간별 분포, 역대 당첨번호 평균과 비교
"""
all_numbers = get_all_recommendation_numbers()
draws = get_all_draw_numbers()
return analyze_personal_patterns(all_numbers, draws)
# ── 구매 이력 API (Phase 2) ───────────────────────────────────────────────────
class PurchaseCreate(BaseModel):
draw_no: int
amount: int
sets: int = 1
prize: int = 0
note: str = ""
class PurchaseUpdate(BaseModel):
draw_no: Optional[int] = None
amount: Optional[int] = None
sets: Optional[int] = None
prize: Optional[int] = None
note: Optional[str] = None
@app.get("/api/lotto/purchase/stats")
def api_purchase_stats():
"""투자 수익률 통계 (총 투자금, 총 당첨금, 수익률 등)"""
return get_purchase_stats()
@app.get("/api/lotto/purchase")
def api_purchase_list(draw_no: Optional[int] = None, days: Optional[int] = None):
"""구매 이력 조회 (draw_no, days 필터 선택)"""
return {"records": get_purchases(draw_no=draw_no, days=days)}
@app.post("/api/lotto/purchase", status_code=201)
def api_purchase_create(body: PurchaseCreate):
"""구매 이력 추가"""
return add_purchase(body.draw_no, body.amount, body.sets, body.prize, body.note)
@app.put("/api/lotto/purchase/{purchase_id}")
def api_purchase_update(purchase_id: int, body: PurchaseUpdate):
"""구매 이력 수정 (당첨금 업데이트 등)"""
updated = update_purchase(purchase_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Purchase not found")
return updated
@app.delete("/api/lotto/purchase/{purchase_id}")
def api_purchase_delete(purchase_id: int):
"""구매 이력 삭제"""
if not delete_purchase(purchase_id):
raise HTTPException(status_code=404, detail="Purchase not found")
return {"ok": True}
# ── 통계 분석 리포트 ────────────────────────────────────────────────────────
@app.get("/api/lotto/analysis")
def api_analysis():
"""
5가지 통계 기법 기반 분석 리포트.
- 번호별 빈도, Z-score, 갭
- 핫/콜드/오버듀 번호
- 역대 합계 분포, 홀짝 분포
"""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
return get_statistical_report(draws)
# ── 시뮬레이션 best_picks (메인 추천 엔드포인트) ────────────────────────────
@app.get("/api/lotto/best")
def api_best_picks(limit: int = 20):
"""
시뮬레이션을 통해 선별된 최적 번호 조합 반환 (기본 20쌍).
하루 6회 시뮬레이션 후 자동 갱신됨.
각 조합에 점수 및 메트릭 포함.
"""
limit = max(1, min(limit, 50))
picks = get_best_picks(limit=limit)
if not picks:
raise HTTPException(
status_code=404,
detail="시뮬레이션 결과가 없습니다. /api/admin/simulate로 먼저 실행하세요.",
)
draws = get_all_draw_numbers()
result = []
for p in picks:
nums = p["numbers"]
result.append({
"rank": p["rank_in_run"],
"numbers": nums,
"score_total": p["score_total"],
"based_on_draw": p["based_on_draw"],
"simulation_run_id": p["source_run_id"],
"created_at": p["created_at"],
"metrics": calc_metrics(nums),
})
latest = get_latest_draw()
return {
"based_on_draw": latest["drw_no"] if latest else None,
"count": len(result),
"items": result,
}
# ── 시뮬레이션 전체 결과 조회 (상세 API) ────────────────────────────────────
@app.get("/api/lotto/simulation")
def api_simulation(run_id: Optional[int] = None, runs_limit: int = 5):
"""
시뮬레이션 실행 기록 및 상위 후보 상세 조회.
run_id 미지정 시: 최근 runs_limit개 실행 기록 + 가장 최근 run의 후보 반환.
run_id 지정 시: 해당 run의 후보만 반환.
"""
runs = get_simulation_runs(limit=runs_limit)
if not runs:
raise HTTPException(status_code=404, detail="시뮬레이션 기록이 없습니다.")
target_run_id = run_id if run_id is not None else runs[0]["id"]
candidates = get_simulation_candidates(target_run_id, limit=100)
# 후보에 메트릭 추가
enriched = []
for c in candidates:
enriched.append({
**c,
"metrics": calc_metrics(c["numbers"]),
})
return {
"runs": runs,
"selected_run_id": target_run_id,
"candidates_count": len(enriched),
"candidates": enriched,
}
# ── 종합 추론 추천 ───────────────────────────────────────────────────────────
@app.get("/api/lotto/recommend/combined")
def api_recommend_combined():
"""5가지 통계 기법 종합 추론 추천 — 결과를 이력에 저장한다."""
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data")
latest = get_latest_draw()
result = generate_combined_recommendation(draws)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
# 추천 이력 저장 (태그: 종합추론)
params = {"method": "combined"}
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
result["final_numbers"],
params,
)
if saved["saved"]:
update_recommendation(saved["id"], tags=["종합추론"])
return {
**result,
"id": saved["id"],
"saved": saved["saved"],
"deduped": saved["deduped"],
"based_on_latest_draw": latest["drw_no"] if latest else None,
}
@app.get("/api/lotto/recommend/combined/history")
def api_combined_history(limit: int = 30):
"""종합추론 추천 이력 조회."""
items = list_recommendations_ex(limit=limit, tag="종합추론", sort="id_desc")
return {"items": items, "total": len(items)}
# ── 기존 수동 추천 API (하위 호환 유지) ─────────────────────────────────────
@app.get("/api/lotto/recommend")
def api_recommend(
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
sum_min: Optional[int] = None,
sum_max: Optional[int] = None,
odd_min: Optional[int] = None,
odd_max: Optional[int] = None,
range_min: Optional[int] = None,
range_max: Optional[int] = None,
max_overlap_latest: Optional[int] = None,
max_try: int = 200,
):
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
params = {
"recent_window": recent_window,
"recent_weight": float(recent_weight),
"avoid_recent_k": avoid_recent_k,
"sum_min": sum_min,
"sum_max": sum_max,
"odd_min": odd_min,
"odd_max": odd_max,
"range_min": range_min,
"range_max": range_max,
"max_overlap_latest": max_overlap_latest,
"max_try": int(max_try),
}
def _accept(nums: List[int]) -> bool:
m = calc_metrics(nums)
if sum_min is not None and m["sum"] < sum_min:
return False
if sum_max is not None and m["sum"] > sum_max:
return False
if odd_min is not None and m["odd"] < odd_min:
return False
if odd_max is not None and m["odd"] > odd_max:
return False
if range_min is not None and m["range"] < range_min:
return False
if range_max is not None and m["range"] > range_max:
return False
if max_overlap_latest is not None:
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
if ov["repeats"] > max_overlap_latest:
return False
return True
chosen = None
explain = None
tries = 0
while tries < max_try:
tries += 1
result = recommend_numbers(
draws,
recent_window=recent_window,
recent_weight=recent_weight,
avoid_recent_k=avoid_recent_k,
)
nums = result["numbers"]
if _accept(nums):
chosen = nums
explain = result["explain"]
break
if chosen is None:
raise HTTPException(
status_code=400,
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
)
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
chosen,
params,
)
metrics = calc_metrics(chosen)
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
return {
"id": saved["id"],
"saved": saved["saved"],
"deduped": saved["deduped"],
"based_on_latest_draw": latest["drw_no"] if latest else None,
"numbers": chosen,
"explain": explain,
"params": params,
"metrics": metrics,
"recent_overlap": overlap,
"tries": tries,
}
# ── 히트맵 기반 추천 (하위 호환 유지) ────────────────────────────────────────
@app.get("/api/lotto/recommend/heatmap")
def api_recommend_heatmap(
heatmap_window: int = 20,
heatmap_weight: float = 1.5,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
sum_min: Optional[int] = None,
sum_max: Optional[int] = None,
odd_min: Optional[int] = None,
odd_max: Optional[int] = None,
range_min: Optional[int] = None,
range_max: Optional[int] = None,
max_overlap_latest: Optional[int] = None,
max_try: int = 200,
):
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
latest = get_latest_draw()
params = {
"heatmap_window": heatmap_window,
"heatmap_weight": float(heatmap_weight),
"recent_window": recent_window,
"recent_weight": float(recent_weight),
"avoid_recent_k": avoid_recent_k,
"sum_min": sum_min,
"sum_max": sum_max,
"odd_min": odd_min,
"odd_max": odd_max,
"range_min": range_min,
"range_max": range_max,
"max_overlap_latest": max_overlap_latest,
"max_try": int(max_try),
}
def _accept(nums: List[int]) -> bool:
m = calc_metrics(nums)
if sum_min is not None and m["sum"] < sum_min:
return False
if sum_max is not None and m["sum"] > sum_max:
return False
if odd_min is not None and m["odd"] < odd_min:
return False
if odd_max is not None and m["odd"] > odd_max:
return False
if range_min is not None and m["range"] < range_min:
return False
if range_max is not None and m["range"] > range_max:
return False
if max_overlap_latest is not None:
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
if ov["repeats"] > max_overlap_latest:
return False
return True
chosen = None
explain = None
tries = 0
while tries < max_try:
tries += 1
result = recommend_with_heatmap(
draws,
past_recs,
heatmap_window=heatmap_window,
heatmap_weight=heatmap_weight,
recent_window=recent_window,
recent_weight=recent_weight,
avoid_recent_k=avoid_recent_k,
)
nums = result["numbers"]
if _accept(nums):
chosen = nums
explain = result["explain"]
break
if chosen is None:
raise HTTPException(
status_code=400,
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
)
saved = save_recommendation_dedup(
latest["drw_no"] if latest else None,
chosen,
params,
)
metrics = calc_metrics(chosen)
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
return {
"id": saved["id"],
"saved": saved["saved"],
"deduped": saved["deduped"],
"based_on_latest_draw": latest["drw_no"] if latest else None,
"numbers": chosen,
"explain": explain,
"params": params,
"metrics": metrics,
"recent_overlap": overlap,
"tries": tries,
}
# ── 추천 이력 ────────────────────────────────────────────────────────────────
@app.get("/api/history")
def api_history(
limit: int = 30,
offset: int = 0,
favorite: Optional[bool] = None,
tag: Optional[str] = None,
q: Optional[str] = None,
sort: str = "id_desc",
):
items = list_recommendations_ex(
limit=limit,
offset=offset,
favorite=favorite,
tag=tag,
q=q,
sort=sort,
)
draws = get_all_draw_numbers()
out = []
for it in items:
nums = it["numbers"]
out.append({
**it,
"metrics": calc_metrics(nums),
"recent_overlap": calc_recent_overlap(
nums, draws, last_k=int(it["params"].get("avoid_recent_k", 0) or 0)
),
})
return {
"items": out,
"limit": limit,
"offset": offset,
"filters": {"favorite": favorite, "tag": tag, "q": q, "sort": sort},
}
@app.delete("/api/history/{rec_id:int}")
def api_history_delete(rec_id: int):
ok = delete_recommendation(rec_id)
if not ok:
raise HTTPException(status_code=404, detail="Not found")
return {"deleted": True, "id": rec_id}
class HistoryUpdate(BaseModel):
favorite: Optional[bool] = None
note: Optional[str] = None
tags: Optional[List[str]] = None
@app.patch("/api/history/{rec_id:int}")
def api_history_patch(rec_id: int, body: HistoryUpdate):
ok = update_recommendation(rec_id, favorite=body.favorite, note=body.note, tags=body.tags)
if not ok:
raise HTTPException(status_code=404, detail="Not found or no changes")
return {"updated": True, "id": rec_id}
# ── 배치 추천 (하위 호환 유지) ───────────────────────────────────────────────
def _batch_unique(draws, count: int, recent_window: int, recent_weight: float, avoid_recent_k: int, max_try: int = 200):
items = []
seen = set()
tries = 0
while len(items) < count and tries < max_try:
tries += 1
r = recommend_numbers(draws, recent_window=recent_window, recent_weight=recent_weight, avoid_recent_k=avoid_recent_k)
key = tuple(sorted(r["numbers"]))
if key in seen:
continue
seen.add(key)
items.append(r)
return items
@app.get("/api/lotto/recommend/batch")
def api_recommend_batch(
count: int = 5,
recent_window: int = 200,
recent_weight: float = 2.0,
avoid_recent_k: int = 5,
):
count = max(1, min(count, 20))
draws = get_all_draw_numbers()
if not draws:
raise HTTPException(status_code=404, detail="No data yet")
latest = get_latest_draw()
params = {
"recent_window": recent_window,
"recent_weight": float(recent_weight),
"avoid_recent_k": avoid_recent_k,
"count": count,
}
items = _batch_unique(draws, count, recent_window, float(recent_weight), avoid_recent_k)
return {
"based_on_latest_draw": latest["drw_no"] if latest else None,
"count": count,
"items": [{
"numbers": it["numbers"],
"explain": it["explain"],
"metrics": calc_metrics(it["numbers"]),
} for it in items],
"params": params,
}
class BatchSave(BaseModel):
items: List[List[int]]
params: dict
@app.post("/api/lotto/recommend/batch")
def api_recommend_batch_save(body: BatchSave):
latest = get_latest_draw()
based = latest["drw_no"] if latest else None
created, deduped = [], []
for nums in body.items:
saved = save_recommendation_dedup(based, nums, body.params)
(created if saved["saved"] else deduped).append(saved["id"])
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
@app.get("/api/version")
def version():
return {"version": os.getenv("APP_VERSION", "dev")}
# ── Todos API ─────────────────────────────────────────────────────────────────
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
status: str = "todo"
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
@app.get("/api/todos")
def api_todos_list():
return get_all_todos()
@app.post("/api/todos", status_code=201)
def api_todos_create(body: TodoCreate):
if body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
return create_todo(body.title, body.description, body.status)
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
@app.delete("/api/todos/done")
def api_todos_delete_done():
deleted = delete_done_todos()
return {"deleted": deleted}
@app.put("/api/todos/{todo_id}")
def api_todos_update(todo_id: str, body: TodoUpdate):
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Todo not found")
return updated
@app.delete("/api/todos/{todo_id}")
def api_todos_delete(todo_id: str):
ok = delete_todo(todo_id)
if not ok:
raise HTTPException(status_code=404, detail="Todo not found")
return {"ok": True}
# ── Blog API ──────────────────────────────────────────────────────────────────
class BlogPostCreate(BaseModel):
title: str
body: str = ""
excerpt: str = ""
tags: List[str] = []
date: str = "" # 빈 문자열이면 오늘 날짜 사용
class BlogPostUpdate(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
excerpt: Optional[str] = None
tags: Optional[List[str]] = None
date: Optional[str] = None
@app.get("/api/blog/posts")
def api_blog_list():
return {"posts": get_all_posts()}
@app.post("/api/blog/posts", status_code=201)
def api_blog_create(body: BlogPostCreate):
from datetime import date as _date
post_date = body.date if body.date else _date.today().isoformat()
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
return post
@app.put("/api/blog/posts/{post_id}")
def api_blog_update(post_id: int, body: BlogPostUpdate):
updated = update_post(post_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Post not found")
return updated
@app.delete("/api/blog/posts/{post_id}")
def api_blog_delete(post_id: int):
ok = delete_post(post_id)
if not ok:
raise HTTPException(status_code=404, detail="Post not found")
return {"ok": True}
# ── RealEstate API ─────────────────────────────────────────────────────────────
VALID_STATUSES = {"청약예정", "청약중", "결과발표", "완료"}
VALID_PRIORITIES = {"high", "normal", "low"}
class ComplexCreate(BaseModel):
name: str
address: str = ""
lat: Optional[float] = None
lng: Optional[float] = None
units: Optional[int] = None
types: List[str] = []
avgPricePerPyeong: Optional[int] = None
subscriptionStart: Optional[str] = None
subscriptionEnd: Optional[str] = None
resultDate: Optional[str] = None
status: str = "청약예정"
priority: str = "normal"
tags: List[str] = []
naverUrl: str = ""
floorPlanUrl: str = ""
memo: str = ""
class ComplexUpdate(BaseModel):
name: Optional[str] = None
address: Optional[str] = None
lat: Optional[float] = None
lng: Optional[float] = None
units: Optional[int] = None
types: Optional[List[str]] = None
avgPricePerPyeong: Optional[int] = None
subscriptionStart: Optional[str] = None
subscriptionEnd: Optional[str] = None
resultDate: Optional[str] = None
status: Optional[str] = None
priority: Optional[str] = None
tags: Optional[List[str]] = None
naverUrl: Optional[str] = None
floorPlanUrl: Optional[str] = None
memo: Optional[str] = None
@app.get("/api/realestate/complexes")
def api_realestate_list():
return get_all_complexes()
@app.post("/api/realestate/complexes", status_code=201)
def api_realestate_create(body: ComplexCreate):
if body.status not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
if body.priority not in VALID_PRIORITIES:
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
return create_complex(body.model_dump())
@app.put("/api/realestate/complexes/{complex_id}")
def api_realestate_update(complex_id: int, body: ComplexUpdate):
data = body.model_dump(exclude_none=True)
if "status" in data and data["status"] not in VALID_STATUSES:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}")
if "priority" in data and data["priority"] not in VALID_PRIORITIES:
raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}")
updated = update_complex(complex_id, data)
if updated is None:
raise HTTPException(status_code=404, detail="Complex not found")
return updated
@app.delete("/api/realestate/complexes/{complex_id}")
def api_realestate_delete(complex_id: int):
ok = delete_complex(complex_id)
if not ok:
raise HTTPException(status_code=404, detail="Complex not found")
return {"ok": True}
# ── Subscription API ───────────────────────────────────────────────────────────
class SubscriptionItemCreate(BaseModel):
complexName: str
address: str = ""
pyeong: Optional[str] = None
totalPrice: Optional[int] = None
type: Optional[str] = None
specialType: Optional[str] = None
supplyType: Optional[str] = None
status: str = "검토중"
minScore: Optional[int] = None
maxIncome: Optional[int] = None
homelessRequired: Optional[int] = None
subscriptionStart: Optional[str] = None
subscriptionEnd: Optional[str] = None
contractDate: Optional[str] = None
interimDate: Optional[str] = None
balanceDate: Optional[str] = None
resultDate: Optional[str] = None
depositRate: int = 10
interimRate: int = 60
balanceRate: int = 30
loanType: Optional[str] = None
loanRate: Optional[float] = None
memo: str = ""
naverUrl: str = ""
class SubscriptionItemUpdate(BaseModel):
complexName: Optional[str] = None
address: Optional[str] = None
pyeong: Optional[str] = None
totalPrice: Optional[int] = None
type: Optional[str] = None
specialType: Optional[str] = None
supplyType: Optional[str] = None
status: Optional[str] = None
minScore: Optional[int] = None
maxIncome: Optional[int] = None
homelessRequired: Optional[int] = None
subscriptionStart: Optional[str] = None
subscriptionEnd: Optional[str] = None
contractDate: Optional[str] = None
interimDate: Optional[str] = None
balanceDate: Optional[str] = None
resultDate: Optional[str] = None
depositRate: Optional[int] = None
interimRate: Optional[int] = None
balanceRate: Optional[int] = None
loanType: Optional[str] = None
loanRate: Optional[float] = None
memo: Optional[str] = None
naverUrl: Optional[str] = None
class SubscriptionProfile(BaseModel):
isHouseholdHead: Optional[bool] = None
isHomeless: Optional[bool] = None
homelessPeriod: Optional[int] = None
savingsMonths: Optional[int] = None
savingsCount: Optional[int] = None
dependents: Optional[int] = None
residencyArea: Optional[str] = None
isMarried: Optional[bool] = None
marriageMonths: Optional[int] = None
monthlyIncome: Optional[int] = None
specialQuals: Optional[List[str]] = None
@app.get("/api/subscription/items")
def api_subscription_list():
return get_all_subscription_items()
@app.post("/api/subscription/items", status_code=201)
def api_subscription_create(body: SubscriptionItemCreate):
return create_subscription_item(body.model_dump())
@app.put("/api/subscription/items/{item_id}")
def api_subscription_update(item_id: int, body: SubscriptionItemUpdate):
updated = update_subscription_item(item_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Item not found")
return updated
@app.delete("/api/subscription/items/{item_id}")
def api_subscription_delete(item_id: int):
ok = delete_subscription_item(item_id)
if not ok:
raise HTTPException(status_code=404, detail="Item not found")
return {"ok": True}
@app.get("/api/subscription/profile")
def api_subscription_profile_get():
profile = get_subscription_profile()
return profile if profile is not None else {}
@app.put("/api/subscription/profile")
def api_subscription_profile_put(body: SubscriptionProfile):
return upsert_subscription_profile(body.model_dump(exclude_none=True))