fix(realestate-lab): 최종 리뷰 이슈 수정 — FK CASCADE, 단일 연결, 동시성 가드

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 08:49:05 +09:00
parent bdfcdee5fd
commit afc159c84d
3 changed files with 41 additions and 35 deletions

View File

@@ -116,7 +116,7 @@ def init_db():
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS match_results ( CREATE TABLE IF NOT EXISTS match_results (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
announcement_id INTEGER NOT NULL, announcement_id INTEGER NOT NULL REFERENCES announcements(id) ON DELETE CASCADE,
model_id INTEGER, model_id INTEGER,
match_score INTEGER NOT NULL DEFAULT 0, match_score INTEGER NOT NULL DEFAULT 0,
match_reasons TEXT NOT NULL DEFAULT '[]', match_reasons TEXT NOT NULL DEFAULT '[]',
@@ -341,8 +341,7 @@ def update_announcement(ann_id: int, data: Dict[str, Any]) -> Optional[Dict[str,
def delete_announcement(ann_id: int) -> bool: def delete_announcement(ann_id: int) -> bool:
with _conn() as conn: with _conn() as conn:
# 관련 매칭 결과도 삭제 # match_results는 FK CASCADE로 자동 삭제
conn.execute("DELETE FROM match_results WHERE announcement_id = ?", (ann_id,))
cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,)) cur = conn.execute("DELETE FROM announcements WHERE id = ?", (ann_id,))
return cur.rowcount > 0 return cur.rowcount > 0
@@ -351,14 +350,12 @@ 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 " "SELECT id, status, 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)" "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"])
if new_status != "완료": if new_status != r["status"]: # only update if status actually changed
conn.execute("UPDATE announcements SET status = ? WHERE id = ?", (new_status, r["id"]))
else:
conn.execute( conn.execute(
"UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", "UPDATE announcements SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(new_status, r["id"]), (new_status, r["id"]),
@@ -509,11 +506,6 @@ def mark_match_read(match_id: int) -> bool:
return cur.rowcount > 0 return cur.rowcount > 0
def clear_match_results():
with _conn() as conn:
conn.execute("DELETE FROM match_results")
# ── collect_log CRUD ───────────────────────────────────────────────────────── # ── collect_log CRUD ─────────────────────────────────────────────────────────
def save_collect_log(new_count: int, total_count: int, error: str = None): def save_collect_log(new_count: int, total_count: int, error: str = None):

View File

@@ -1,5 +1,6 @@
import os import os
import logging import logging
import threading
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -110,9 +111,18 @@ def api_announcement_delete(ann_id: int):
# ── 수집 API ───────────────────────────────────────────────────────────────── # ── 수집 API ─────────────────────────────────────────────────────────────────
_collect_lock = threading.Lock()
def _run_collect_and_match(): def _run_collect_and_match():
collect_all() if not _collect_lock.acquire(blocking=False):
run_matching() logger.info("수집 이미 진행 중 — 건너뜀")
return
try:
collect_all()
run_matching()
finally:
_collect_lock.release()
@app.post("/api/realestate/collect") @app.post("/api/realestate/collect")

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, _conn from .db import get_profile, _conn
logger = logging.getLogger("realestate-lab") logger = logging.getLogger("realestate-lab")
@@ -30,7 +30,8 @@ def _check_eligible_types(profile: Dict[str, Any], ann: Dict[str, Any]) -> List[
elif is_homeless: elif is_homeless:
eligible.append("일반2순위") eligible.append("일반2순위")
# 특별공급 # 특별공급 — 신혼부부
# NOTE: 소득기준 검증은 향후 구현 예정 (income_level 필드 활용)
if profile.get("is_newlywed") and is_homeless: if profile.get("is_newlywed") and is_homeless:
eligible.append("특별-신혼부부") eligible.append("특별-신혼부부")
@@ -117,7 +118,7 @@ def run_matching():
"""프로필 기반 매칭을 실행하여 결과를 저장한다.""" """프로필 기반 매칭을 실행하여 결과를 저장한다."""
profile = get_profile() profile = get_profile()
if not profile: if not profile:
logger.info("매칭 스킵: 프로필이 설정되지 않음") logger.info("프로필 미설정 — 매칭 건너뜀")
return return
with _conn() as conn: with _conn() as conn:
@@ -125,32 +126,35 @@ def run_matching():
"SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')" "SELECT * FROM announcements WHERE status IN ('청약예정', '청약중')"
).fetchall() ).fetchall()
saved = 0 for ann_row in anns:
for row in anns: ann = {c: ann_row[c] for c in ann_row.keys()}
ann = {c: row[c] for c in row.keys()} models = conn.execute(
models_rows = conn.execute(
"SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?", "SELECT * FROM announcement_models WHERE house_manage_no = ? AND pblanc_no = ?",
(ann["house_manage_no"], ann["pblanc_no"]), (ann["house_manage_no"], ann["pblanc_no"]),
).fetchall() ).fetchall()
models = [{c: m[c] for c in m.keys()} for m in models_rows] model_list = [dict(m) for m in models]
result = _compute_score(profile, ann, models)
result = _compute_score(profile, ann, model_list)
if result["match_score"] > 0: if result["match_score"] > 0:
save_match_result({ conn.execute("""
"announcement_id": ann["id"], INSERT INTO match_results (announcement_id, model_id, match_score, match_reasons, eligible_types, is_new)
"model_id": None, VALUES (?, ?, ?, ?, ?, 1)
"match_score": result["match_score"], ON CONFLICT(announcement_id, model_id) DO UPDATE SET
"match_reasons": result["match_reasons"], match_score=excluded.match_score,
"eligible_types": result["eligible_types"], match_reasons=excluded.match_reasons,
}) eligible_types=excluded.eligible_types
saved += 1 """, (
ann["id"],
None,
result["match_score"],
json.dumps(result["match_reasons"]),
json.dumps(result["eligible_types"]),
))
# 완료/결과발표 공고의 매칭 결과 정리 # Clean up stale match results for completed announcements
conn.execute( conn.execute(
"DELETE FROM match_results WHERE announcement_id NOT IN " "DELETE FROM match_results WHERE announcement_id NOT IN "
"(SELECT id FROM announcements WHERE status IN ('청약예정', '청약중'))" "(SELECT id FROM announcements WHERE status IN ('청약예정', '청약중'))"
) )
logger.info("매칭 완료: %d건 공고 중 %d건 매칭됨", len(anns), saved) logger.info("매칭 완료")