diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py new file mode 100644 index 0000000..c8ac0ad --- /dev/null +++ b/realestate-lab/app/main.py @@ -0,0 +1,163 @@ +import os +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Query, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from apscheduler.schedulers.background import BackgroundScheduler + +from .db import ( + init_db, get_announcements, get_announcement, create_announcement, + update_announcement, delete_announcement, update_all_statuses, + get_profile, upsert_profile, get_matches, mark_match_read, + get_last_collect_log, get_dashboard, +) +from .collector import collect_all +from .matcher import run_matching +from .models import AnnouncementCreate, AnnouncementUpdate, ProfileUpdate + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") +logger = logging.getLogger("realestate-lab") + +scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul")) + + +def scheduled_collect(): + """매일 09:00 — 수집 + 매칭""" + logger.info("스케줄 수집 시작") + collect_all() + run_matching() + logger.info("스케줄 수집 + 매칭 완료") + + +def scheduled_status_update(): + """매일 00:00 — 상태 갱신 + 재매칭""" + logger.info("상태 갱신 시작") + update_all_statuses() + run_matching() + logger.info("상태 갱신 + 재매칭 완료") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + scheduler.add_job(scheduled_collect, "cron", hour=9, minute=0, id="collect") + scheduler.add_job(scheduled_status_update, "cron", hour=0, minute=0, id="status_update") + scheduler.start() + logger.info("realestate-lab 시작") + yield + scheduler.shutdown() + + +app = FastAPI(lifespan=lifespan) + +_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in _cors_origins], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +# ── 공고 API ───────────────────────────────────────────────────────────────── + +@app.get("/api/realestate/announcements") +def api_announcements( + region: str = None, + status: str = None, + house_type: str = None, + matched_only: bool = False, + sort: str = "date", + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), +): + return get_announcements(region, status, house_type, matched_only, sort, page, size) + + +@app.get("/api/realestate/announcements/{ann_id}") +def api_announcement_detail(ann_id: int): + ann = get_announcement(ann_id) + if not ann: + raise HTTPException(status_code=404, detail="Announcement not found") + return ann + + +@app.post("/api/realestate/announcements", status_code=201) +def api_announcement_create(body: AnnouncementCreate): + return create_announcement(body.model_dump()) + + +@app.put("/api/realestate/announcements/{ann_id}") +def api_announcement_update(ann_id: int, body: AnnouncementUpdate): + updated = update_announcement(ann_id, body.model_dump(exclude_none=True)) + if not updated: + raise HTTPException(status_code=404, detail="Announcement not found") + return updated + + +@app.delete("/api/realestate/announcements/{ann_id}") +def api_announcement_delete(ann_id: int): + if not delete_announcement(ann_id): + raise HTTPException(status_code=404, detail="Announcement not found") + return {"ok": True} + + +# ── 수집 API ───────────────────────────────────────────────────────────────── + +@app.post("/api/realestate/collect") +def api_collect(): + result = collect_all() + run_matching() + return result + + +@app.get("/api/realestate/collect/status") +def api_collect_status(): + log = get_last_collect_log() + return log if log else {"collected_at": None, "new_count": 0, "total_count": 0, "error": None} + + +# ── 프로필 API ─────────────────────────────────────────────────────────────── + +@app.get("/api/realestate/profile") +def api_profile_get(): + profile = get_profile() + return profile if profile else {} + + +@app.put("/api/realestate/profile") +def api_profile_update(body: ProfileUpdate): + return upsert_profile(body.model_dump(exclude_none=True)) + + +# ── 매칭 API ───────────────────────────────────────────────────────────────── + +@app.get("/api/realestate/matches") +def api_matches(page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)): + return get_matches(page, size) + + +@app.post("/api/realestate/matches/refresh") +def api_matches_refresh(): + run_matching() + return {"ok": True} + + +@app.patch("/api/realestate/matches/{match_id}/read") +def api_match_read(match_id: int): + if not mark_match_read(match_id): + raise HTTPException(status_code=404, detail="Match not found") + return {"ok": True} + + +# ── 대시보드 API ───────────────────────────────────────────────────────────── + +@app.get("/api/realestate/dashboard") +def api_dashboard(): + return get_dashboard()