fix(realestate-lab): 코드 리뷰 이슈 수정 — 신규 추적, 보안, 비동기, 매칭 상태 보존

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 08:43:27 +09:00
parent 3b118725ca
commit bdfcdee5fd
4 changed files with 58 additions and 23 deletions

View File

@@ -145,8 +145,10 @@ def collect_all() -> Dict[str, Any]:
for raw in detail_rows: for raw in detail_rows:
try: try:
parsed = _parse_apt_detail(raw) parsed = _parse_apt_detail(raw)
upsert_announcement(parsed) _, is_new = upsert_announcement(parsed)
total_count += 1 total_count += 1
if is_new:
new_count += 1
except Exception as e: except Exception as e:
logger.error("공고 upsert 실패 [%s]: %s", detail_ep, e) logger.error("공고 upsert 실패 [%s]: %s", detail_ep, e)
@@ -158,8 +160,6 @@ def collect_all() -> Dict[str, Any]:
upsert_model(parsed) upsert_model(parsed)
except Exception as e: except Exception as e:
logger.error("모델 upsert 실패 [%s]: %s", model_ep, e) logger.error("모델 upsert 실패 [%s]: %s", model_ep, e)
new_count = total_count # 첫 수집 시 전부 신규로 기록
save_collect_log(new_count, total_count) save_collect_log(new_count, total_count)
logger.info("수집 완료: new=%d, total=%d", new_count, total_count) logger.info("수집 완료: new=%d, total=%d", new_count, total_count)
return {"new_count": new_count, "total_count": total_count} return {"new_count": new_count, "total_count": total_count}

View File

@@ -160,14 +160,19 @@ def _ann_row_to_dict(r) -> Dict[str, Any]:
return {c: r[c] for c in r.keys()} return {c: r[c] for c in r.keys()}
def upsert_announcement(data: Dict[str, Any]) -> Dict[str, Any]: def upsert_announcement(data: Dict[str, Any]) -> tuple:
"""공고 upsert — house_manage_no + pblanc_no 기준.""" """공고 upsert — house_manage_no + pblanc_no 기준. Returns (dict, is_new: bool)."""
status = compute_status( status = compute_status(
data.get("receipt_start", ""), data.get("receipt_start", ""),
data.get("receipt_end", ""), data.get("receipt_end", ""),
data.get("winner_date", ""), data.get("winner_date", ""),
) )
with _conn() as conn: with _conn() as conn:
exists = conn.execute(
"SELECT 1 FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
(data["house_manage_no"], data["pblanc_no"]),
).fetchone()
is_new = exists is None
conn.execute(""" conn.execute("""
INSERT INTO announcements ( INSERT INTO announcements (
house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd, house_manage_no, pblanc_no, house_nm, house_secd, house_dtl_secd,
@@ -220,7 +225,7 @@ def upsert_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
"SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?", "SELECT * FROM announcements WHERE house_manage_no = ? AND pblanc_no = ?",
(data["house_manage_no"], data["pblanc_no"]), (data["house_manage_no"], data["pblanc_no"]),
).fetchone() ).fetchone()
return _ann_row_to_dict(row) return _ann_row_to_dict(row), is_new
def get_announcements( def get_announcements(
@@ -243,25 +248,24 @@ def get_announcements(
conditions.append("a.house_secd = ?") conditions.append("a.house_secd = ?")
params.append(house_type) params.append(house_type)
join_clause = ""
if matched_only: if matched_only:
join_clause = "INNER JOIN match_results m ON m.announcement_id = a.id" conditions.append("a.id IN (SELECT announcement_id FROM match_results)")
where = f"WHERE {' AND '.join(conditions)}" if conditions else "" where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"} order_map = {"date": "a.rcrit_date DESC", "score": "a.id DESC", "price": "a.id ASC"}
order = order_map.get(sort, "a.rcrit_date DESC") order = order_map.get(sort, "a.rcrit_date DESC")
if matched_only and sort == "score": if matched_only and sort == "score":
order = "m.match_score DESC" order = "(SELECT MAX(match_score) FROM match_results WHERE announcement_id = a.id) DESC"
offset = (page - 1) * size offset = (page - 1) * size
with _conn() as conn: with _conn() as conn:
total = conn.execute( total = conn.execute(
f"SELECT COUNT(*) FROM announcements a {join_clause} {where}", params f"SELECT COUNT(*) FROM announcements a {where}", params
).fetchone()[0] ).fetchone()[0]
rows = conn.execute( rows = conn.execute(
f"SELECT a.* FROM announcements a {join_clause} {where} ORDER BY {order} LIMIT ? OFFSET ?", f"SELECT a.* FROM announcements a {where} ORDER BY {order} LIMIT ? OFFSET ?",
params + [size, offset], params + [size, offset],
).fetchall() ).fetchall()
return { return {
@@ -292,11 +296,23 @@ def create_announcement(data: Dict[str, Any]) -> Dict[str, Any]:
data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}") data["house_manage_no"] = data.get("house_manage_no", f"MANUAL-{uuid.uuid4().hex[:8]}")
data["pblanc_no"] = data.get("pblanc_no", "00") data["pblanc_no"] = data.get("pblanc_no", "00")
data["source"] = "manual" data["source"] = "manual"
return upsert_announcement(data) result, _ = upsert_announcement(data)
return result
ANNOUNCEMENT_COLUMNS = {
"house_nm", "house_secd", "house_dtl_secd", "rent_secd",
"region_code", "region_name", "address", "total_units",
"rcrit_date", "receipt_start", "receipt_end", "spsply_start", "spsply_end",
"gnrl_rank1_start", "gnrl_rank1_end", "winner_date",
"contract_start", "contract_end", "homepage_url", "pblanc_url",
"constructor", "developer", "move_in_month",
"is_speculative_area", "is_price_cap", "contact",
}
def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
fields = {k: v for k, v in data.items() if v is not None} fields = {k: v for k, v in data.items() if v is not None and k in ANNOUNCEMENT_COLUMNS}
if not fields: if not fields:
return get_announcement(ann_id) return get_announcement(ann_id)
@@ -335,7 +351,8 @@ def update_all_statuses():
"""모든 진행중 공고의 status를 날짜 기반으로 재계산.""" """모든 진행중 공고의 status를 날짜 기반으로 재계산."""
with _conn() as conn: with _conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, receipt_start, receipt_end, winner_date FROM announcements WHERE status != '완료'" "SELECT id, receipt_start, receipt_end, winner_date FROM announcements "
"WHERE status != '완료' AND (receipt_start IS NOT NULL OR receipt_end IS NOT NULL OR winner_date IS NOT NULL)"
).fetchall() ).fetchall()
for r in rows: for r in rows:
new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"]) new_status = compute_status(r["receipt_start"], r["receipt_end"], r["winner_date"])
@@ -402,10 +419,20 @@ def get_profile() -> Optional[Dict[str, Any]]:
return _profile_row_to_dict(r) if r else None return _profile_row_to_dict(r) if r else None
PROFILE_COLUMNS = {
"name", "age", "is_homeless", "is_householder",
"subscription_months", "subscription_amount", "family_members",
"has_dependents", "children_count", "is_newlywed", "marriage_months",
"has_newborn", "is_first_home", "income_level",
"preferred_regions", "preferred_types",
"min_area", "max_area", "max_price",
}
def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]: def upsert_profile(data: Dict[str, Any]) -> Dict[str, Any]:
updates = {} updates = {}
for k, v in data.items(): for k, v in data.items():
if v is None: if v is None or k not in PROFILE_COLUMNS:
continue continue
if isinstance(v, bool): if isinstance(v, bool):
updates[k] = 1 if v else 0 updates[k] = 1 if v else 0

View File

@@ -1,7 +1,7 @@
import os import os
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Query, HTTPException from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
@@ -110,11 +110,15 @@ def api_announcement_delete(ann_id: int):
# ── 수집 API ───────────────────────────────────────────────────────────────── # ── 수집 API ─────────────────────────────────────────────────────────────────
@app.post("/api/realestate/collect") def _run_collect_and_match():
def api_collect(): collect_all()
result = collect_all()
run_matching() run_matching()
return result
@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") @app.get("/api/realestate/collect/status")

View File

@@ -2,7 +2,7 @@ import json
import logging import logging
from typing import Dict, Any, List from typing import Dict, Any, List
from .db import get_profile, save_match_result, clear_match_results, _conn from .db import get_profile, save_match_result, _conn
logger = logging.getLogger("realestate-lab") logger = logging.getLogger("realestate-lab")
@@ -120,8 +120,6 @@ def run_matching():
logger.info("매칭 스킵: 프로필이 설정되지 않음") logger.info("매칭 스킵: 프로필이 설정되지 않음")
return return
clear_match_results()
with _conn() as conn: with _conn() as conn:
anns = conn.execute( anns = conn.execute(
"SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')" "SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')"
@@ -149,4 +147,10 @@ def run_matching():
}) })
saved += 1 saved += 1
# 완료/결과발표 공고의 매칭 결과 정리
conn.execute(
"DELETE FROM match_results WHERE announcement_id NOT IN "
"(SELECT id FROM announcements WHERE status IN ('청약예정', '청약중'))"
)
logger.info("매칭 완료: %d건 공고 중 %d건 매칭됨", len(anns), saved) logger.info("매칭 완료: %d건 공고 중 %d건 매칭됨", len(anns), saved)