fix(realestate-lab): 코드 리뷰 이슈 수정 — 신규 추적, 보안, 비동기, 매칭 상태 보존
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user