From 6344f957fadf3e927f924c27e2395f1d477714cd Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 6 Apr 2026 08:34:12 +0900 Subject: [PATCH] =?UTF-8?q?refactor(lotto-backend):=20=EC=B2=AD=EC=95=BD?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20=EC=99=84=EC=A0=84?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20realestate-lab=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- backend/app/db.py | 443 -------------------------------------------- backend/app/main.py | 192 ------------------- 2 files changed, 635 deletions(-) diff --git a/backend/app/db.py b/backend/app/db.py index 455c751..31b5c9c 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -181,76 +181,6 @@ def init_db() -> None: "CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);" ) - # ── realestate_complexes 테이블 ──────────────────────────────────────── - conn.execute( - """ - CREATE TABLE IF NOT EXISTS realestate_complexes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - address TEXT NOT NULL DEFAULT '', - lat REAL, - lng REAL, - units INTEGER, - types TEXT NOT NULL DEFAULT '[]', - avg_price_per_pyeong INTEGER, - subscription_start TEXT, - subscription_end TEXT, - result_date TEXT, - status TEXT NOT NULL DEFAULT '청약예정' - CHECK(status IN ('청약예정','청약중','결과발표','완료')), - priority TEXT NOT NULL DEFAULT 'normal' - CHECK(priority IN ('high','normal','low')), - tags TEXT NOT NULL DEFAULT '[]', - naver_url TEXT NOT NULL DEFAULT '', - floor_plan_url TEXT NOT NULL DEFAULT '', - memo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); - """ - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_realestate_status ON realestate_complexes(status);" - ) - - # ── subscription_items 테이블 ────────────────────────────────────────── - conn.execute( - """ - CREATE TABLE IF NOT EXISTS subscription_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - complex_name TEXT NOT NULL, - address TEXT NOT NULL DEFAULT '', - pyeong TEXT, - total_price INTEGER, - type TEXT, - special_type TEXT, - supply_type TEXT, - status TEXT NOT NULL DEFAULT '검토중', - min_score INTEGER, - max_income INTEGER, - homeless_required INTEGER, - subscription_start TEXT, - subscription_end TEXT, - contract_date TEXT, - interim_date TEXT, - balance_date TEXT, - result_date TEXT, - deposit_rate INTEGER DEFAULT 10, - interim_rate INTEGER DEFAULT 60, - balance_rate INTEGER DEFAULT 30, - loan_type TEXT, - loan_rate REAL, - memo TEXT NOT NULL DEFAULT '', - naver_url TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) - ); - """ - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_sub_items_created ON subscription_items(created_at DESC);" - ) - # ── purchase_history 테이블 ──────────────────────────────────────────── conn.execute( """ @@ -279,26 +209,6 @@ def init_db() -> None: """ ) - # ── subscription_profile 테이블 (싱글톤 id=1) ────────────────────────── - conn.execute( - """ - CREATE TABLE IF NOT EXISTS subscription_profile ( - id INTEGER PRIMARY KEY DEFAULT 1, - is_household_head INTEGER DEFAULT 1, - is_homeless INTEGER DEFAULT 1, - homeless_period INTEGER, - savings_months INTEGER, - savings_count INTEGER, - dependents INTEGER DEFAULT 0, - residency_area TEXT, - is_married INTEGER, - marriage_months INTEGER, - monthly_income INTEGER, - special_quals TEXT NOT NULL DEFAULT '[]' - ); - """ - ) - # ── todos CRUD ─────────────────────────────────────────────────────────────── @@ -834,309 +744,6 @@ def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, A ] -# ── realestate_complexes CRUD ───────────────────────────────────────────────── - -def _complex_row_to_dict(r) -> Dict[str, Any]: - return { - "id": r["id"], - "name": r["name"], - "address": r["address"], - "lat": r["lat"], - "lng": r["lng"], - "units": r["units"], - "types": json.loads(r["types"]) if r["types"] else [], - "avgPricePerPyeong": r["avg_price_per_pyeong"], - "subscriptionStart": r["subscription_start"], - "subscriptionEnd": r["subscription_end"], - "resultDate": r["result_date"], - "status": r["status"], - "priority": r["priority"], - "tags": json.loads(r["tags"]) if r["tags"] else [], - "naverUrl": r["naver_url"], - "floorPlanUrl": r["floor_plan_url"], - "memo": r["memo"], - "created_at": r["created_at"], - "updated_at": r["updated_at"], - } - - -def get_all_complexes() -> List[Dict[str, Any]]: - with _conn() as conn: - rows = conn.execute( - "SELECT * FROM realestate_complexes ORDER BY id DESC" - ).fetchall() - return [_complex_row_to_dict(r) for r in rows] - - -def get_complex(complex_id: int) -> Optional[Dict[str, Any]]: - with _conn() as conn: - r = conn.execute( - "SELECT * FROM realestate_complexes WHERE id = ?", (complex_id,) - ).fetchone() - return _complex_row_to_dict(r) if r else None - - -def create_complex(data: Dict[str, Any]) -> Dict[str, Any]: - with _conn() as conn: - conn.execute( - """ - INSERT INTO realestate_complexes - (name, address, lat, lng, units, types, avg_price_per_pyeong, - subscription_start, subscription_end, result_date, - status, priority, tags, naver_url, floor_plan_url, memo) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - data["name"], - data.get("address", ""), - data.get("lat"), - data.get("lng"), - data.get("units"), - json.dumps(data.get("types", [])), - data.get("avgPricePerPyeong"), - data.get("subscriptionStart"), - data.get("subscriptionEnd"), - data.get("resultDate"), - data.get("status", "청약예정"), - data.get("priority", "normal"), - json.dumps(data.get("tags", [])), - data.get("naverUrl", ""), - data.get("floorPlanUrl", ""), - data.get("memo", ""), - ), - ) - row = conn.execute( - "SELECT * FROM realestate_complexes WHERE rowid = last_insert_rowid()" - ).fetchone() - return _complex_row_to_dict(row) - - -def update_complex(complex_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - field_map = { - "name": "name", - "address": "address", - "lat": "lat", - "lng": "lng", - "units": "units", - "avgPricePerPyeong": "avg_price_per_pyeong", - "subscriptionStart": "subscription_start", - "subscriptionEnd": "subscription_end", - "resultDate": "result_date", - "status": "status", - "priority": "priority", - "naverUrl": "naver_url", - "floorPlanUrl": "floor_plan_url", - "memo": "memo", - } - json_fields = {"types", "tags"} - - updates: Dict[str, Any] = {} - for camel, snake in field_map.items(): - if camel in data: - updates[snake] = data[camel] - for f in json_fields: - if f in data: - updates[f] = json.dumps(data[f]) - - if not updates: - return get_complex(complex_id) - - set_clauses = ", ".join(f"{k} = ?" for k in updates) - set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" - args = list(updates.values()) + [complex_id] - - with _conn() as conn: - conn.execute( - f"UPDATE realestate_complexes SET {set_clauses} WHERE id = ?", args - ) - row = conn.execute( - "SELECT * FROM realestate_complexes WHERE id = ?", (complex_id,) - ).fetchone() - return _complex_row_to_dict(row) if row else None - - -def delete_complex(complex_id: int) -> bool: - with _conn() as conn: - cur = conn.execute( - "DELETE FROM realestate_complexes WHERE id = ?", (complex_id,) - ) - return cur.rowcount > 0 - - -# ── subscription_items CRUD ─────────────────────────────────────────────────── - -_SUB_ITEM_FIELD_MAP = { - "complexName": "complex_name", - "address": "address", - "pyeong": "pyeong", - "totalPrice": "total_price", - "type": "type", - "specialType": "special_type", - "supplyType": "supply_type", - "status": "status", - "minScore": "min_score", - "maxIncome": "max_income", - "homelessRequired": "homeless_required", - "subscriptionStart": "subscription_start", - "subscriptionEnd": "subscription_end", - "contractDate": "contract_date", - "interimDate": "interim_date", - "balanceDate": "balance_date", - "resultDate": "result_date", - "depositRate": "deposit_rate", - "interimRate": "interim_rate", - "balanceRate": "balance_rate", - "loanType": "loan_type", - "loanRate": "loan_rate", - "memo": "memo", - "naverUrl": "naver_url", -} - - -def _sub_item_row_to_dict(r) -> Dict[str, Any]: - return { - "id": r["id"], - "complexName": r["complex_name"], - "address": r["address"], - "pyeong": r["pyeong"], - "totalPrice": r["total_price"], - "type": r["type"], - "specialType": r["special_type"], - "supplyType": r["supply_type"], - "status": r["status"], - "minScore": r["min_score"], - "maxIncome": r["max_income"], - "homelessRequired": r["homeless_required"], - "subscriptionStart": r["subscription_start"], - "subscriptionEnd": r["subscription_end"], - "contractDate": r["contract_date"], - "interimDate": r["interim_date"], - "balanceDate": r["balance_date"], - "resultDate": r["result_date"], - "depositRate": r["deposit_rate"], - "interimRate": r["interim_rate"], - "balanceRate": r["balance_rate"], - "loanType": r["loan_type"], - "loanRate": r["loan_rate"], - "memo": r["memo"], - "naverUrl": r["naver_url"], - "created_at": r["created_at"], - "updated_at": r["updated_at"], - } - - -def get_all_subscription_items() -> List[Dict[str, Any]]: - with _conn() as conn: - rows = conn.execute( - "SELECT * FROM subscription_items ORDER BY created_at DESC" - ).fetchall() - return [_sub_item_row_to_dict(r) for r in rows] - - -def create_subscription_item(data: Dict[str, Any]) -> Dict[str, Any]: - with _conn() as conn: - conn.execute( - """ - INSERT INTO subscription_items - (complex_name, address, pyeong, total_price, type, special_type, supply_type, - status, min_score, max_income, homeless_required, - subscription_start, subscription_end, contract_date, interim_date, - balance_date, result_date, deposit_rate, interim_rate, balance_rate, - loan_type, loan_rate, memo, naver_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - data["complexName"], - data.get("address", ""), - data.get("pyeong"), - data.get("totalPrice"), - data.get("type"), - data.get("specialType"), - data.get("supplyType"), - data.get("status", "검토중"), - data.get("minScore"), - data.get("maxIncome"), - data.get("homelessRequired"), - data.get("subscriptionStart"), - data.get("subscriptionEnd"), - data.get("contractDate"), - data.get("interimDate"), - data.get("balanceDate"), - data.get("resultDate"), - data.get("depositRate", 10), - data.get("interimRate", 60), - data.get("balanceRate", 30), - data.get("loanType"), - data.get("loanRate"), - data.get("memo", ""), - data.get("naverUrl", ""), - ), - ) - row = conn.execute( - "SELECT * FROM subscription_items WHERE rowid = last_insert_rowid()" - ).fetchone() - return _sub_item_row_to_dict(row) - - -def update_subscription_item(item_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: - updates: Dict[str, Any] = {} - for camel, snake in _SUB_ITEM_FIELD_MAP.items(): - if camel in data: - updates[snake] = data[camel] - - if not updates: - with _conn() as conn: - row = conn.execute( - "SELECT * FROM subscription_items WHERE id = ?", (item_id,) - ).fetchone() - return _sub_item_row_to_dict(row) if row else None - - set_clauses = ", ".join(f"{k} = ?" for k in updates) - set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" - args = list(updates.values()) + [item_id] - - with _conn() as conn: - conn.execute( - f"UPDATE subscription_items SET {set_clauses} WHERE id = ?", args - ) - row = conn.execute( - "SELECT * FROM subscription_items WHERE id = ?", (item_id,) - ).fetchone() - return _sub_item_row_to_dict(row) if row else None - - -def delete_subscription_item(item_id: int) -> bool: - with _conn() as conn: - cur = conn.execute("DELETE FROM subscription_items WHERE id = ?", (item_id,)) - return cur.rowcount > 0 - - -# ── subscription_profile CRUD (싱글톤) ──────────────────────────────────────── - -def _profile_row_to_dict(r) -> Dict[str, Any]: - return { - "isHouseholdHead": bool(r["is_household_head"]) if r["is_household_head"] is not None else None, - "isHomeless": bool(r["is_homeless"]) if r["is_homeless"] is not None else None, - "homelessPeriod": r["homeless_period"], - "savingsMonths": r["savings_months"], - "savingsCount": r["savings_count"], - "dependents": r["dependents"], - "residencyArea": r["residency_area"], - "isMarried": bool(r["is_married"]) if r["is_married"] is not None else None, - "marriageMonths": r["marriage_months"], - "monthlyIncome": r["monthly_income"], - "specialQuals": json.loads(r["special_quals"]) if r["special_quals"] else [], - } - - -def get_subscription_profile() -> Optional[Dict[str, Any]]: - with _conn() as conn: - r = conn.execute( - "SELECT * FROM subscription_profile WHERE id = 1" - ).fetchone() - return _profile_row_to_dict(r) if r else None - - # ── purchase_history CRUD ───────────────────────────────────────────────────── def _purchase_row_to_dict(r) -> Dict[str, Any]: @@ -1275,54 +882,4 @@ def get_all_recommendation_numbers() -> List[List[int]]: return [json.loads(r["numbers"]) for r in rows] -def upsert_subscription_profile(data: Dict[str, Any]) -> Dict[str, Any]: - field_map = { - "isHouseholdHead": "is_household_head", - "isHomeless": "is_homeless", - "homelessPeriod": "homeless_period", - "savingsMonths": "savings_months", - "savingsCount": "savings_count", - "dependents": "dependents", - "residencyArea": "residency_area", - "isMarried": "is_married", - "marriageMonths": "marriage_months", - "monthlyIncome": "monthly_income", - } - - updates: Dict[str, Any] = {} - for camel, snake in field_map.items(): - if camel in data: - val = data[camel] - # bool → int (SQLite) - if isinstance(val, bool): - val = 1 if val else 0 - updates[snake] = val - if "specialQuals" in data: - updates["special_quals"] = json.dumps(data["specialQuals"]) - - with _conn() as conn: - existing = conn.execute( - "SELECT id FROM subscription_profile WHERE id = 1" - ).fetchone() - - if existing: - if updates: - set_clauses = ", ".join(f"{k} = ?" for k in updates) - conn.execute( - f"UPDATE subscription_profile SET {set_clauses} WHERE id = 1", - list(updates.values()), - ) - else: - cols = ["id"] + list(updates.keys()) - vals = [1] + list(updates.values()) - placeholders = ", ".join("?" for _ in vals) - conn.execute( - f"INSERT INTO subscription_profile ({', '.join(cols)}) VALUES ({placeholders})", - vals, - ) - - row = conn.execute( - "SELECT * FROM subscription_profile WHERE id = 1" - ).fetchone() - return _profile_row_to_dict(row) diff --git a/backend/app/main.py b/backend/app/main.py index 2ed10a0..7894554 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,12 +19,6 @@ from .db import ( get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos, # blog get_all_posts, create_post, update_post, delete_post, - # realestate - get_all_complexes, get_complex, create_complex, update_complex, delete_complex, - # subscription - get_all_subscription_items, create_subscription_item, - update_subscription_item, delete_subscription_item, - get_subscription_profile, upsert_subscription_profile, # 성과 통계 get_recommendation_performance, # Phase 2: 구매 이력 @@ -876,189 +870,3 @@ def api_blog_delete(post_id: int): if not ok: raise HTTPException(status_code=404, detail="Post not found") return {"ok": True} - - -# ── RealEstate API ───────────────────────────────────────────────────────────── - -VALID_STATUSES = {"청약예정", "청약중", "결과발표", "완료"} -VALID_PRIORITIES = {"high", "normal", "low"} - - -class ComplexCreate(BaseModel): - name: str - address: str = "" - lat: Optional[float] = None - lng: Optional[float] = None - units: Optional[int] = None - types: List[str] = [] - avgPricePerPyeong: Optional[int] = None - subscriptionStart: Optional[str] = None - subscriptionEnd: Optional[str] = None - resultDate: Optional[str] = None - status: str = "청약예정" - priority: str = "normal" - tags: List[str] = [] - naverUrl: str = "" - floorPlanUrl: str = "" - memo: str = "" - - -class ComplexUpdate(BaseModel): - name: Optional[str] = None - address: Optional[str] = None - lat: Optional[float] = None - lng: Optional[float] = None - units: Optional[int] = None - types: Optional[List[str]] = None - avgPricePerPyeong: Optional[int] = None - subscriptionStart: Optional[str] = None - subscriptionEnd: Optional[str] = None - resultDate: Optional[str] = None - status: Optional[str] = None - priority: Optional[str] = None - tags: Optional[List[str]] = None - naverUrl: Optional[str] = None - floorPlanUrl: Optional[str] = None - memo: Optional[str] = None - - -@app.get("/api/realestate/complexes") -def api_realestate_list(): - return get_all_complexes() - - -@app.post("/api/realestate/complexes", status_code=201) -def api_realestate_create(body: ComplexCreate): - if body.status not in VALID_STATUSES: - raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}") - if body.priority not in VALID_PRIORITIES: - raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}") - return create_complex(body.model_dump()) - - -@app.put("/api/realestate/complexes/{complex_id}") -def api_realestate_update(complex_id: int, body: ComplexUpdate): - data = body.model_dump(exclude_none=True) - if "status" in data and data["status"] not in VALID_STATUSES: - raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATUSES}") - if "priority" in data and data["priority"] not in VALID_PRIORITIES: - raise HTTPException(status_code=400, detail=f"priority must be one of {VALID_PRIORITIES}") - updated = update_complex(complex_id, data) - if updated is None: - raise HTTPException(status_code=404, detail="Complex not found") - return updated - - -@app.delete("/api/realestate/complexes/{complex_id}") -def api_realestate_delete(complex_id: int): - ok = delete_complex(complex_id) - if not ok: - raise HTTPException(status_code=404, detail="Complex not found") - return {"ok": True} - - -# ── Subscription API ─────────────────────────────────────────────────────────── - -class SubscriptionItemCreate(BaseModel): - complexName: str - address: str = "" - pyeong: Optional[str] = None - totalPrice: Optional[int] = None - type: Optional[str] = None - specialType: Optional[str] = None - supplyType: Optional[str] = None - status: str = "검토중" - minScore: Optional[int] = None - maxIncome: Optional[int] = None - homelessRequired: Optional[int] = None - subscriptionStart: Optional[str] = None - subscriptionEnd: Optional[str] = None - contractDate: Optional[str] = None - interimDate: Optional[str] = None - balanceDate: Optional[str] = None - resultDate: Optional[str] = None - depositRate: int = 10 - interimRate: int = 60 - balanceRate: int = 30 - loanType: Optional[str] = None - loanRate: Optional[float] = None - memo: str = "" - naverUrl: str = "" - - -class SubscriptionItemUpdate(BaseModel): - complexName: Optional[str] = None - address: Optional[str] = None - pyeong: Optional[str] = None - totalPrice: Optional[int] = None - type: Optional[str] = None - specialType: Optional[str] = None - supplyType: Optional[str] = None - status: Optional[str] = None - minScore: Optional[int] = None - maxIncome: Optional[int] = None - homelessRequired: Optional[int] = None - subscriptionStart: Optional[str] = None - subscriptionEnd: Optional[str] = None - contractDate: Optional[str] = None - interimDate: Optional[str] = None - balanceDate: Optional[str] = None - resultDate: Optional[str] = None - depositRate: Optional[int] = None - interimRate: Optional[int] = None - balanceRate: Optional[int] = None - loanType: Optional[str] = None - loanRate: Optional[float] = None - memo: Optional[str] = None - naverUrl: Optional[str] = None - - -class SubscriptionProfile(BaseModel): - isHouseholdHead: Optional[bool] = None - isHomeless: Optional[bool] = None - homelessPeriod: Optional[int] = None - savingsMonths: Optional[int] = None - savingsCount: Optional[int] = None - dependents: Optional[int] = None - residencyArea: Optional[str] = None - isMarried: Optional[bool] = None - marriageMonths: Optional[int] = None - monthlyIncome: Optional[int] = None - specialQuals: Optional[List[str]] = None - - -@app.get("/api/subscription/items") -def api_subscription_list(): - return get_all_subscription_items() - - -@app.post("/api/subscription/items", status_code=201) -def api_subscription_create(body: SubscriptionItemCreate): - return create_subscription_item(body.model_dump()) - - -@app.put("/api/subscription/items/{item_id}") -def api_subscription_update(item_id: int, body: SubscriptionItemUpdate): - updated = update_subscription_item(item_id, body.model_dump(exclude_none=True)) - if updated is None: - raise HTTPException(status_code=404, detail="Item not found") - return updated - - -@app.delete("/api/subscription/items/{item_id}") -def api_subscription_delete(item_id: int): - ok = delete_subscription_item(item_id) - if not ok: - raise HTTPException(status_code=404, detail="Item not found") - return {"ok": True} - - -@app.get("/api/subscription/profile") -def api_subscription_profile_get(): - profile = get_subscription_profile() - return profile if profile is not None else {} - - -@app.put("/api/subscription/profile") -def api_subscription_profile_put(body: SubscriptionProfile): - return upsert_subscription_profile(body.model_dump(exclude_none=True))