Files
web-page-backend/realestate-lab/app/main.py

309 lines
11 KiB
Python

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 _shared.access_log import install as install_access_log
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,
get_listing_criteria, update_listing_criteria, get_listings, get_listing_matches,
get_last_listing_collect_log, get_market_deals_for,
)
from .collector import collect_all
from .matcher import run_matching
from .notifier import notify_new_matches, notify_new_listings
from .listing_collector import collect_listings
from .listing_matcher import run_listing_matching, compute_safety, compute_valuation
from . import finance_rules
from .finance_rules_config import DISCLAIMER
from .models import (
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
)
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("상태 갱신 + 재매칭 완료")
_listing_collect_lock = threading.Lock()
def _run_listing_pipeline():
if not _listing_collect_lock.acquire(blocking=False):
logger.info("매물 수집 이미 진행 중 — 건너뜀"); return
try:
collect_listings(); run_listing_matching(); notify_new_listings()
finally:
_listing_collect_lock.release()
def scheduled_listing_collect():
logger.info("매물 스케줄 수집 시작"); _run_listing_pipeline()
@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.add_job(scheduled_listing_collect, "cron", hour="8,11,14,17,20", minute=40, id="listing_collect")
scheduler.start()
logger.info("realestate-lab 시작")
yield
scheduler.shutdown()
app = FastAPI(lifespan=lifespan)
install_access_log(app)
_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()
# ── 매물 API ─────────────────────────────────────────────────────────────────
@app.get("/api/realestate/listings")
def api_listings(dong: str = None, deal_type: str = None, tier: str = None,
matched_only: bool = False, page: int = 1, size: int = 50):
return {"listings": get_listings(dong, deal_type, tier, matched_only, size, (page - 1) * size)}
@app.post("/api/realestate/listings/collect")
def api_listings_collect(background_tasks: BackgroundTasks):
background_tasks.add_task(_run_listing_pipeline)
return {"ok": True, "message": "매물 수집 시작됨"}
@app.get("/api/realestate/listings/collect/status")
def api_listings_collect_status():
return get_last_listing_collect_log() or {"status": "never_run"}
@app.get("/api/realestate/listings/criteria")
def api_get_listing_criteria():
return get_listing_criteria()
@app.put("/api/realestate/listings/criteria")
def api_put_listing_criteria(req: ListingCriteriaUpdate):
update_listing_criteria(req.model_dump(exclude_none=True))
return get_listing_criteria()
@app.get("/api/realestate/listings/matches")
def api_listing_matches():
return {"matches": get_listing_matches()}
@app.post("/api/realestate/safety-check")
def api_safety_check(req: SafetyCheckRequest):
deals = get_market_deals_for(req.dong_code, req.complex_name, req.area,
"전세" if req.deal_type != "매매" else "매매")
reg = finance_rules.region_regulation(req.dong) # dong명(없으면 토허 판정 생략)
if req.deal_type == "매매":
res = compute_valuation(req.amount, deals)
ratio_key = "price_ratio"
else:
res = compute_safety(req.amount, req.deal_type, deals)
ratio_key = "jeonse_ratio"
return {"median": res["median"], "ratio": res.get(ratio_key), "tier": res["tier"],
"sample": res["sample"], "is_toheo": reg["is_toheo"],
"disclaimer": "등기부 선순위·토허 허가·실거주 의무는 계약 전 수동 확인 필수. " + DISCLAIMER}
@app.post("/api/realestate/budget")
def api_budget(req: BudgetRequest):
reg = finance_rules.region_regulation(req.target_dong)
return {
"jeonse": finance_rules.estimate_jeonse_budget(req.equity, req.annual_income),
"purchase": finance_rules.estimate_purchase_budget(req.equity, req.annual_income, reg,
req.is_first_home, req.is_homeless),
"region": reg,
"disclaimer": DISCLAIMER,
}