Files
web-page-backend/realestate-lab/app/main.py
gahusb 1fff511752 fix(realestate): compose INTERNAL_API_KEY(배포 401 방지) + 매칭·알림 공유락(중복 텔레그램 방지)
- docker-compose.yml realestate-lab environment에 INTERNAL_API_KEY/NAVER_PAGE_LIMIT 누락 추가
  (verify_internal_key 항상 401 → 워커 계약 전체 마비 방지, sibling image/video/music/insta-lab과 동형)
- pipeline_lock.py 신설: cron _run_listing_pipeline과 워커 listings_ingest가
  run_listing_matching()+notify_new_listings() 임계구역을 공유 threading.Lock으로 직렬화
  (동시 실행 시 동일 매물 중복 텔레그램 발송 방지). 느린 collect는 락 밖 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
2026-07-10 00:50:55 +09:00

315 lines
12 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 .pipeline_lock import match_notify_lock
from . import finance_rules
from .finance_rules_config import DISCLAIMER
from .models import (
AnnouncementCreate, AnnouncementUpdate, ProfileUpdate,
ListingCriteriaUpdate, SafetyCheckRequest, BudgetRequest,
)
from .internal_router import router as internal_router
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()
with match_notify_lock:
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)
app.include_router(internal_router)
_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,
}