From c6366ad238ce9ad39140ff31eea28dd261b7d42f Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 14:33:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20+=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI 앱: DB(5테이블), Pydantic 모델, 토큰 인증, 전체 API 라우트 - Docker Compose: portfolio 서비스 (포트 18850) - Nginx: /api/profile/ → portfolio:8000 - 배포 스크립트: portfolio 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 19 ++ nginx/default.conf | 10 ++ portfolio/Dockerfile | 10 ++ portfolio/app/__init__.py | 0 portfolio/app/auth.py | 39 +++++ portfolio/app/db.py | 350 +++++++++++++++++++++++++++++++++++++ portfolio/app/main.py | 190 ++++++++++++++++++++ portfolio/app/models.py | 90 ++++++++++ portfolio/requirements.txt | 3 + scripts/deploy-nas.sh | 2 +- scripts/deploy.sh | 8 +- 11 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 portfolio/Dockerfile create mode 100644 portfolio/app/__init__.py create mode 100644 portfolio/app/auth.py create mode 100644 portfolio/app/db.py create mode 100644 portfolio/app/main.py create mode 100644 portfolio/app/models.py create mode 100644 portfolio/requirements.txt diff --git a/docker-compose.yml b/docker-compose.yml index 7f5f411..afe1b62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -149,6 +149,25 @@ services: timeout: 5s retries: 3 + portfolio: + build: + context: ./portfolio + container_name: portfolio + restart: unless-stopped + ports: + - "18850:8000" + environment: + - TZ=${TZ:-Asia/Seoul} + - PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-} + - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} + volumes: + - ${RUNTIME_PATH:-.}/data/portfolio:/app/data + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + travel-proxy: build: ./travel-proxy container_name: travel-proxy diff --git a/nginx/default.conf b/nginx/default.conf index 43d9a0d..2275de4 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -140,6 +140,16 @@ server { } + # profile API (Portfolio Service) + location /api/profile/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_pass http://portfolio:8000/api/profile/; + } + # agent-office API + WebSocket location /api/agent-office/ { resolver 127.0.0.11 valid=10s; diff --git a/portfolio/Dockerfile b/portfolio/Dockerfile new file mode 100644 index 0000000..c05ee7c --- /dev/null +++ b/portfolio/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-alpine +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/portfolio/app/__init__.py b/portfolio/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portfolio/app/auth.py b/portfolio/app/auth.py new file mode 100644 index 0000000..00c625c --- /dev/null +++ b/portfolio/app/auth.py @@ -0,0 +1,39 @@ +import os +import uuid +import time +import logging +from fastapi import Header, HTTPException + +logger = logging.getLogger("portfolio") + +EDIT_PASSWORD = os.getenv("PORTFOLIO_EDIT_PASSWORD", "") +TOKEN_TTL = 86400 # 24시간 + +_tokens: dict[str, float] = {} # token -> expiry timestamp + + +def authenticate(password: str) -> dict: + if not EDIT_PASSWORD: + raise HTTPException(status_code=503, detail="Edit password not configured") + if password != EDIT_PASSWORD: + raise HTTPException(status_code=401, detail="Invalid password") + token = uuid.uuid4().hex + _tokens[token] = time.time() + TOKEN_TTL + _cleanup() + return {"token": token, "expires_in": TOKEN_TTL} + + +def require_auth(authorization: str = Header("")): + token = authorization.replace("Bearer ", "").strip() + if not token or token not in _tokens: + raise HTTPException(status_code=401, detail="Authentication required") + if time.time() > _tokens[token]: + del _tokens[token] + raise HTTPException(status_code=401, detail="Token expired") + + +def _cleanup(): + now = time.time() + expired = [t for t, exp in _tokens.items() if now > exp] + for t in expired: + del _tokens[t] diff --git a/portfolio/app/db.py b/portfolio/app/db.py new file mode 100644 index 0000000..9775b35 --- /dev/null +++ b/portfolio/app/db.py @@ -0,0 +1,350 @@ +import sqlite3 +import json +import logging +from typing import Dict, Any, List, Optional + +logger = logging.getLogger("portfolio") + +DB_PATH = "/app/data/portfolio.db" + + +def _conn(): + c = sqlite3.connect(DB_PATH, timeout=10) + c.row_factory = sqlite3.Row + c.execute("PRAGMA journal_mode=WAL;") + c.execute("PRAGMA foreign_keys=ON;") + return c + + +def _row_to_dict(r) -> Dict[str, Any]: + if r is None: + return None + d = {c: r[c] for c in r.keys()} + if "tech_stack" in d and isinstance(d["tech_stack"], str): + try: + d["tech_stack"] = json.loads(d["tech_stack"]) + except (json.JSONDecodeError, TypeError): + d["tech_stack"] = [] + return d + + +def init_db(): + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + name TEXT NOT NULL DEFAULT '', + name_en TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + role_en TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + github_url TEXT NOT NULL DEFAULT '', + blog_url TEXT NOT NULL DEFAULT '', + photo_url TEXT NOT NULL DEFAULT '', + bio TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + conn.execute(""" + INSERT OR IGNORE INTO profile (id) VALUES (1) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS careers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL DEFAULT 'company', + organization TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + 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 TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL DEFAULT 'personal', + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + tech_stack TEXT NOT NULL DEFAULT '[]', + role TEXT NOT NULL DEFAULT '', + start_date TEXT NOT NULL DEFAULT '', + end_date TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + image_url TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + 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 TABLE IF NOT EXISTS skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category TEXT NOT NULL DEFAULT 'language', + name TEXT NOT NULL DEFAULT '', + level INTEGER NOT NULL DEFAULT 3, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + """) + + conn.execute(""" + CREATE TABLE IF NOT EXISTS introductions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + is_main INTEGER NOT NULL DEFAULT 0, + 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')) + ) + """) + logger.info("portfolio DB initialized") + + +# ── Profile ── + +def get_profile() -> Dict[str, Any]: + with _conn() as conn: + row = conn.execute("SELECT * FROM profile WHERE id = 1").fetchone() + return _row_to_dict(row) + + +def update_profile(data: Dict[str, Any]) -> Dict[str, Any]: + fields = {k: v for k, v in data.items() if k != "id" and v is not None} + if not fields: + return get_profile() + set_clauses = ", ".join(f"{k} = ?" for k in fields) + set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" + with _conn() as conn: + conn.execute( + f"UPDATE profile SET {set_clauses} WHERE id = 1", + list(fields.values()), + ) + return get_profile() + + +# ── Careers ── + +def get_careers() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall() + return [_row_to_dict(r) for r in rows] + + +def create_career(data: Dict[str, Any]) -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + """INSERT INTO careers (category, organization, role, description, start_date, end_date, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (data.get("category", "company"), data.get("organization", ""), + data.get("role", ""), data.get("description", ""), + data.get("start_date", ""), data.get("end_date", ""), + data.get("sort_order", 0)), + ) + row = conn.execute("SELECT * FROM careers ORDER BY id DESC LIMIT 1").fetchone() + return _row_to_dict(row) + + +def update_career(career_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} + if not fields: + return get_career(career_id) + set_clauses = ", ".join(f"{k} = ?" for k in fields) + set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" + with _conn() as conn: + existing = conn.execute("SELECT id FROM careers WHERE id = ?", (career_id,)).fetchone() + if not existing: + return None + conn.execute(f"UPDATE careers SET {set_clauses} WHERE id = ?", list(fields.values()) + [career_id]) + row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone() + return _row_to_dict(row) + + +def delete_career(career_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM careers WHERE id = ?", (career_id,)) + return cur.rowcount > 0 + + +def get_career(career_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT * FROM careers WHERE id = ?", (career_id,)).fetchone() + return _row_to_dict(row) + + +# ── Projects ── + +def get_projects() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall() + return [_row_to_dict(r) for r in rows] + + +def create_project(data: Dict[str, Any]) -> Dict[str, Any]: + tech = json.dumps(data.get("tech_stack", []), ensure_ascii=False) + with _conn() as conn: + conn.execute( + """INSERT INTO projects (category, title, description, tech_stack, role, start_date, end_date, url, image_url, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (data.get("category", "personal"), data.get("title", ""), + data.get("description", ""), tech, + data.get("role", ""), data.get("start_date", ""), + data.get("end_date", ""), data.get("url", ""), + data.get("image_url", ""), data.get("sort_order", 0)), + ) + row = conn.execute("SELECT * FROM projects ORDER BY id DESC LIMIT 1").fetchone() + return _row_to_dict(row) + + +def update_project(project_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} + if "tech_stack" in fields and isinstance(fields["tech_stack"], list): + fields["tech_stack"] = json.dumps(fields["tech_stack"], ensure_ascii=False) + if not fields: + return get_project(project_id) + set_clauses = ", ".join(f"{k} = ?" for k in fields) + set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" + with _conn() as conn: + existing = conn.execute("SELECT id FROM projects WHERE id = ?", (project_id,)).fetchone() + if not existing: + return None + conn.execute(f"UPDATE projects SET {set_clauses} WHERE id = ?", list(fields.values()) + [project_id]) + row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + return _row_to_dict(row) + + +def delete_project(project_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + return cur.rowcount > 0 + + +def get_project(project_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + return _row_to_dict(row) + + +# ── Skills ── + +def get_skills() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall() + return [_row_to_dict(r) for r in rows] + + +def create_skill(data: Dict[str, Any]) -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + "INSERT INTO skills (category, name, level, sort_order) VALUES (?, ?, ?, ?)", + (data.get("category", "language"), data.get("name", ""), + data.get("level", 3), data.get("sort_order", 0)), + ) + row = conn.execute("SELECT * FROM skills ORDER BY id DESC LIMIT 1").fetchone() + return _row_to_dict(row) + + +def update_skill(skill_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + fields = {k: v for k, v in data.items() if k != "id" and v is not None} + if not fields: + return get_skill(skill_id) + set_clauses = ", ".join(f"{k} = ?" for k in fields) + with _conn() as conn: + existing = conn.execute("SELECT id FROM skills WHERE id = ?", (skill_id,)).fetchone() + if not existing: + return None + conn.execute(f"UPDATE skills SET {set_clauses} WHERE id = ?", list(fields.values()) + [skill_id]) + row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() + return _row_to_dict(row) + + +def delete_skill(skill_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,)) + return cur.rowcount > 0 + + +def get_skill(skill_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() + return _row_to_dict(row) + + +# ── Introductions ── + +def get_introductions() -> List[Dict[str, Any]]: + with _conn() as conn: + rows = conn.execute("SELECT * FROM introductions ORDER BY is_main DESC, updated_at DESC").fetchall() + return [_row_to_dict(r) for r in rows] + + +def create_introduction(data: Dict[str, Any]) -> Dict[str, Any]: + with _conn() as conn: + conn.execute( + "INSERT INTO introductions (title, content, is_main) VALUES (?, ?, ?)", + (data.get("title", ""), data.get("content", ""), data.get("is_main", 0)), + ) + row = conn.execute("SELECT * FROM introductions ORDER BY id DESC LIMIT 1").fetchone() + return _row_to_dict(row) + + +def update_introduction(intro_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + fields = {k: v for k, v in data.items() if k not in ("id", "created_at") and v is not None} + if not fields: + return get_introduction(intro_id) + set_clauses = ", ".join(f"{k} = ?" for k in fields) + set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')" + with _conn() as conn: + existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone() + if not existing: + return None + conn.execute(f"UPDATE introductions SET {set_clauses} WHERE id = ?", list(fields.values()) + [intro_id]) + row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() + return _row_to_dict(row) + + +def delete_introduction(intro_id: int) -> bool: + with _conn() as conn: + cur = conn.execute("DELETE FROM introductions WHERE id = ?", (intro_id,)) + return cur.rowcount > 0 + + +def get_introduction(intro_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() + return _row_to_dict(row) + + +def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]: + with _conn() as conn: + existing = conn.execute("SELECT id FROM introductions WHERE id = ?", (intro_id,)).fetchone() + if not existing: + return None + conn.execute("UPDATE introductions SET is_main = 0 WHERE is_main = 1") + conn.execute("UPDATE introductions SET is_main = 1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", (intro_id,)) + row = conn.execute("SELECT * FROM introductions WHERE id = ?", (intro_id,)).fetchone() + return _row_to_dict(row) + + +# ── Public (일괄 조회) ── + +def get_public_data() -> Dict[str, Any]: + with _conn() as conn: + profile = _row_to_dict(conn.execute("SELECT * FROM profile WHERE id = 1").fetchone()) + careers = [_row_to_dict(r) for r in conn.execute("SELECT * FROM careers ORDER BY sort_order, start_date DESC").fetchall()] + projects = [_row_to_dict(r) for r in conn.execute("SELECT * FROM projects ORDER BY sort_order, start_date DESC").fetchall()] + skills = [_row_to_dict(r) for r in conn.execute("SELECT * FROM skills ORDER BY sort_order, category, name").fetchall()] + main_intro_row = conn.execute("SELECT * FROM introductions WHERE is_main = 1 LIMIT 1").fetchone() + main_introduction = _row_to_dict(main_intro_row) if main_intro_row else None + return { + "profile": profile, + "careers": careers, + "projects": projects, + "skills": skills, + "main_introduction": main_introduction, + } diff --git a/portfolio/app/main.py b/portfolio/app/main.py new file mode 100644 index 0000000..606529f --- /dev/null +++ b/portfolio/app/main.py @@ -0,0 +1,190 @@ +import os +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Depends, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from .db import ( + init_db, get_public_data, + get_profile, update_profile, + get_careers, create_career, update_career, delete_career, + get_projects, create_project, update_project, delete_project, + get_skills, create_skill, update_skill, delete_skill, + get_introductions, create_introduction, update_introduction, + delete_introduction, set_main_introduction, +) +from .models import ( + ProfileUpdate, CareerCreate, CareerUpdate, + ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate, + IntroCreate, IntroUpdate, AuthRequest, +) +from .auth import authenticate, require_auth + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") +logger = logging.getLogger("portfolio") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + logger.info("portfolio service 시작") + yield + + +app = FastAPI(lifespan=lifespan) + +_cors_origins = os.getenv("CORS_ALLOW_ORIGINS", "http://localhost:3007,http://localhost:8080").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in _cors_origins], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +# ── Public ── + +@app.get("/api/profile/public") +def api_public(): + return get_public_data() + + +# ── Auth ── + +@app.post("/api/profile/auth") +def api_auth(body: AuthRequest): + return authenticate(body.password) + + +# ── Profile (편집) ── + +@app.get("/api/profile/profile", dependencies=[Depends(require_auth)]) +def api_profile_get(): + return get_profile() + + +@app.put("/api/profile/profile", dependencies=[Depends(require_auth)]) +def api_profile_update(body: ProfileUpdate): + return update_profile(body.model_dump(exclude_none=True)) + + +# ── Careers (편집) ── + +@app.get("/api/profile/careers", dependencies=[Depends(require_auth)]) +def api_careers_list(): + return get_careers() + + +@app.post("/api/profile/careers", status_code=201, dependencies=[Depends(require_auth)]) +def api_career_create(body: CareerCreate): + return create_career(body.model_dump()) + + +@app.put("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)]) +def api_career_update(career_id: int, body: CareerUpdate): + result = update_career(career_id, body.model_dump(exclude_none=True)) + if not result: + raise HTTPException(status_code=404, detail="Career not found") + return result + + +@app.delete("/api/profile/careers/{career_id}", dependencies=[Depends(require_auth)]) +def api_career_delete(career_id: int): + if not delete_career(career_id): + raise HTTPException(status_code=404, detail="Career not found") + return {"ok": True} + + +# ── Projects (편집) ── + +@app.get("/api/profile/projects", dependencies=[Depends(require_auth)]) +def api_projects_list(): + return get_projects() + + +@app.post("/api/profile/projects", status_code=201, dependencies=[Depends(require_auth)]) +def api_project_create(body: ProjectCreate): + return create_project(body.model_dump()) + + +@app.put("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)]) +def api_project_update(project_id: int, body: ProjectUpdate): + result = update_project(project_id, body.model_dump(exclude_none=True)) + if not result: + raise HTTPException(status_code=404, detail="Project not found") + return result + + +@app.delete("/api/profile/projects/{project_id}", dependencies=[Depends(require_auth)]) +def api_project_delete(project_id: int): + if not delete_project(project_id): + raise HTTPException(status_code=404, detail="Project not found") + return {"ok": True} + + +# ── Skills (편집) ── + +@app.get("/api/profile/skills", dependencies=[Depends(require_auth)]) +def api_skills_list(): + return get_skills() + + +@app.post("/api/profile/skills", status_code=201, dependencies=[Depends(require_auth)]) +def api_skill_create(body: SkillCreate): + return create_skill(body.model_dump()) + + +@app.put("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)]) +def api_skill_update(skill_id: int, body: SkillUpdate): + result = update_skill(skill_id, body.model_dump(exclude_none=True)) + if not result: + raise HTTPException(status_code=404, detail="Skill not found") + return result + + +@app.delete("/api/profile/skills/{skill_id}", dependencies=[Depends(require_auth)]) +def api_skill_delete(skill_id: int): + if not delete_skill(skill_id): + raise HTTPException(status_code=404, detail="Skill not found") + return {"ok": True} + + +# ── Introductions (편집) ── + +@app.get("/api/profile/introductions", dependencies=[Depends(require_auth)]) +def api_intros_list(): + return get_introductions() + + +@app.post("/api/profile/introductions", status_code=201, dependencies=[Depends(require_auth)]) +def api_intro_create(body: IntroCreate): + return create_introduction(body.model_dump()) + + +@app.put("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)]) +def api_intro_update(intro_id: int, body: IntroUpdate): + result = update_introduction(intro_id, body.model_dump(exclude_none=True)) + if not result: + raise HTTPException(status_code=404, detail="Introduction not found") + return result + + +@app.delete("/api/profile/introductions/{intro_id}", dependencies=[Depends(require_auth)]) +def api_intro_delete(intro_id: int): + if not delete_introduction(intro_id): + raise HTTPException(status_code=404, detail="Introduction not found") + return {"ok": True} + + +@app.patch("/api/profile/introductions/{intro_id}/main", dependencies=[Depends(require_auth)]) +def api_intro_set_main(intro_id: int): + result = set_main_introduction(intro_id) + if not result: + raise HTTPException(status_code=404, detail="Introduction not found") + return result diff --git a/portfolio/app/models.py b/portfolio/app/models.py new file mode 100644 index 0000000..41cf02d --- /dev/null +++ b/portfolio/app/models.py @@ -0,0 +1,90 @@ +from typing import Optional, List +from pydantic import BaseModel + + +class ProfileUpdate(BaseModel): + name: Optional[str] = None + name_en: Optional[str] = None + role: Optional[str] = None + role_en: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + github_url: Optional[str] = None + blog_url: Optional[str] = None + photo_url: Optional[str] = None + bio: Optional[str] = None + + +class CareerCreate(BaseModel): + category: str = "company" + organization: str = "" + role: str = "" + description: str = "" + start_date: str = "" + end_date: str = "" + sort_order: int = 0 + + +class CareerUpdate(BaseModel): + category: Optional[str] = None + organization: Optional[str] = None + role: Optional[str] = None + description: Optional[str] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + sort_order: Optional[int] = None + + +class ProjectCreate(BaseModel): + category: str = "personal" + title: str = "" + description: str = "" + tech_stack: List[str] = [] + role: str = "" + start_date: str = "" + end_date: str = "" + url: str = "" + image_url: str = "" + sort_order: int = 0 + + +class ProjectUpdate(BaseModel): + category: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + tech_stack: Optional[List[str]] = None + role: Optional[str] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + url: Optional[str] = None + image_url: Optional[str] = None + sort_order: Optional[int] = None + + +class SkillCreate(BaseModel): + category: str = "language" + name: str = "" + level: int = 3 + sort_order: int = 0 + + +class SkillUpdate(BaseModel): + category: Optional[str] = None + name: Optional[str] = None + level: Optional[int] = None + sort_order: Optional[int] = None + + +class IntroCreate(BaseModel): + title: str = "" + content: str = "" + is_main: int = 0 + + +class IntroUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + + +class AuthRequest(BaseModel): + password: str diff --git a/portfolio/requirements.txt b/portfolio/requirements.txt new file mode 100644 index 0000000..e21ba3c --- /dev/null +++ b/portfolio/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.30.6 +pydantic>=2.0 diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index a4308b0..3948416 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -2,7 +2,7 @@ set -euo pipefail # ── 서비스 목록 (한 곳에서만 관리) ── -SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office nginx scripts" +SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts" # 1. 자동 감지: Docker 컨테이너 내부인가? if [ -d "/repo" ] && [ -d "/runtime" ]; then diff --git a/scripts/deploy.sh b/scripts/deploy.sh index beb0e99..3c69fbb 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,13 +7,13 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; } # ── 서비스 목록 (한 곳에서만 관리) ── # docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단) -BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office frontend" +BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend" # 컨테이너 이름 (고아 정리용) -CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy lotto-frontend" +CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office portfolio travel-proxy lotto-frontend" # 헬스체크 대상 -HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office" +HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio" # data 디렉토리 -DATA_DIRS="music stock blog realestate agent-office" +DATA_DIRS="music stock blog realestate agent-office portfolio" # 1. 자동 감지: Docker 컨테이너 내부인가? if [ -d "/repo" ] && [ -d "/runtime" ]; then