Files
web-page-backend/realestate-lab/app/main.py
gahusb 7a470aad44 perf(infra): NAS CPU 폭주 5건 일괄 fix (CHECK_POINT 🔴 즉시)
J4025 Celeron 2C/2.0GHz에서 oversaturation을 일으키던 5개 패턴 해소.

1) 09:00 cron 스태거링 — agent-office insta_trends 09:00 / lotto 09:05 /
   youtube 09:10, realestate-lab collect 09:15. 동시 실행 4개가 직렬
   분산되어 1분 단위로 분산됨.
2) lotto Monte Carlo 08:05 → 08:30 — stock 08:00 cron과 25분 분리.
3) insta-lab card_renderer.render_slate를 asyncio.Semaphore(1)로 감쌈.
   동시 슬레이트 렌더 요청이 와도 Chromium 인스턴스 1개만 직렬 launch.
4) docker-compose healthcheck interval 30s → 60s (9 백엔드 + frontend
   총 10개). 30초마다 동시 healthcheck로 인한 CPU 잡음 절반으로.
5) 9개 백엔드 Dockerfile CMD에 --workers 1 명시. 기본값 의존 제거.

CHECK_POINT.md 갱신 — 즉시 5건 체크 + 변경 이력 한 줄.
적용 효과 검증: NAS 재기동 후 `docker stats` 비교.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 10:31:02 +09:00

208 lines
7.1 KiB
Python

import os
import logging
import threading
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:00 — 수집 + 정리 + 매칭 + 알림 push"""
logger.info("스케줄 수집 시작")
collect_all()
deleted = delete_old_completed_announcements(grace_days=90)
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()