import os import logging import threading from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from fastapi import BackgroundTasks, 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, delete_closed_announcements, toggle_bookmark, update_all_statuses, get_profile, upsert_profile, get_matches, mark_match_read, get_last_collect_log, get_dashboard, delete_old_completed_announcements, ) from .collector import collect_all from .matcher import run_matching from .notifier import notify_new_matches 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:15 — 수집 + 정리 (병렬) → 매칭 → 알림 push. collect_all과 delete_old_completed_announcements는 서로 다른 데이터 영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차 실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+ 동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용. """ logger.info("스케줄 수집 시작") with ThreadPoolExecutor(max_workers=2) as ex: collect_future = ex.submit(collect_all) delete_future = ex.submit(delete_old_completed_announcements, 90) collect_future.result() deleted = delete_future.result() if deleted: logger.info("정리: %d건 삭제", deleted) run_matching() notify_new_matches() 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() # 09:00 cron 스태거링 — agent-office 09:00/05/10 이후 (CHECK_POINT FU-A) scheduler.add_job(scheduled_collect, "cron", hour=9, minute=15, 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, bookmarked: 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, bookmarked, 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.patch("/api/realestate/announcements/{ann_id}/bookmark") def api_announcement_bookmark(ann_id: int): result = toggle_bookmark(ann_id) if result is None: raise HTTPException(status_code=404, detail="Announcement not found") return result @app.delete("/api/realestate/announcements/closed") def api_announcement_delete_closed(): """status='완료' 공고 일괄 삭제.""" count = delete_closed_announcements() return {"ok": True, "deleted": count} @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 ───────────────────────────────────────────────────────────────── _collect_lock = threading.Lock() def _run_collect_and_match(): if not _collect_lock.acquire(blocking=False): logger.info("수집 이미 진행 중 — 건너뜀") return try: collect_all() delete_old_completed_announcements(grace_days=90) run_matching() notify_new_matches() finally: _collect_lock.release() @app.post("/api/realestate/collect") def api_collect(background_tasks: BackgroundTasks): background_tasks.add_task(_run_collect_and_match) return {"ok": True, "message": "수집 시작됨"} @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(): try: run_matching() except Exception as e: logger.exception("매칭 실행 실패") raise HTTPException(status_code=500, detail=str(e)) 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()