From 5d6fe2f04bf6d7953769dc6574cb48416830178d Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 16 Mar 2026 02:18:06 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B2=AD=EC=95=BD=20=EA=B4=80=EB=A6=AC=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(/api/subscription)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subscription_items 테이블: 청약 목록 CRUD (GET/POST/PUT/DELETE) - subscription_profile 테이블: 내 청약 조건 프로필 싱글톤 (GET/PUT, upsert) - specialQuals JSON 배열, bool → int SQLite 변환 처리 Co-Authored-By: Claude Sonnet 4.6 --- backend/app/db.py | 284 ++++++++++++++++++++++++++++++++++++++++++++ backend/app/main.py | 111 +++++++++++++++++ 2 files changed, 395 insertions(+) diff --git a/backend/app/db.py b/backend/app/db.py index 13f035c..41a3d7f 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -213,6 +213,64 @@ def init_db() -> None: "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);" + ) + + # ── 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 ─────────────────────────────────────────────────────────────── @@ -828,3 +886,229 @@ def delete_complex(complex_id: int) -> bool: ) 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 + + +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 ccd1299..301ef5b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,10 @@ from .db import ( 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, ) from .recommender import recommend_numbers, recommend_with_heatmap from .collector import sync_latest, sync_ensure_all @@ -751,3 +755,110 @@ def api_realestate_delete(complex_id: int): 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))