# Portfolio Service Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 개인 포트폴리오 서비스 — 프로필/경력/프로젝트/기술스택/자기소개 CRUD + 비밀번호 인증 + PDF 내보내기 + 홈 연동 **Architecture:** 새 백엔드 서비스 `portfolio/` (FastAPI + SQLite, 포트 18850) + 프론트 `/portfolio` 페이지 (3탭: 프로필&이력, 프로젝트, 자기소개). 기존 `/api/portfolio`가 stock-lab 주식 포트폴리오로 사용 중이므로 새 서비스 API 경로는 `/api/profile/`로 설정. **Tech Stack:** Python 3.12, FastAPI, SQLite, React, CSS --- ## File Structure ### Backend (`web-backend/portfolio/`) | 파일 | 역할 | |------|------| | `portfolio/Dockerfile` | Python 3.12-alpine 기반 컨테이너 | | `portfolio/requirements.txt` | fastapi, uvicorn, pydantic | | `portfolio/app/__init__.py` | 빈 패키지 파일 | | `portfolio/app/main.py` | FastAPI 앱, 라우트, CORS, 인증 미들웨어 | | `portfolio/app/db.py` | SQLite 연결, 테이블 초기화, CRUD 함수 | | `portfolio/app/models.py` | Pydantic 요청/응답 모델 | | `portfolio/app/auth.py` | 비밀번호 검증, 토큰 관리 | ### Infra (기존 파일 수정) | 파일 | 변경 | |------|------| | `docker-compose.yml` | portfolio 서비스 블록 추가 | | `nginx/default.conf` | `/api/profile/` 프록시 추가 | | `scripts/deploy-nas.sh` | SERVICES에 portfolio 추가 | | `scripts/deploy.sh` | BUILD_TARGETS, CONTAINER_NAMES, HEALTH_ENDPOINTS, DATA_DIRS에 추가 | ### Frontend (`web-ui/src/`) | 파일 | 역할 | |------|------| | `pages/portfolio/Portfolio.jsx` | 메인 페이지 (3탭 컨테이너 + 편집 모드) | | `pages/portfolio/Portfolio.css` | 전체 스타일 | | `pages/portfolio/ProfileTab.jsx` | 탭 1: 프로필 + 경력 타임라인 + 기술스택 | | `pages/portfolio/ProjectTab.jsx` | 탭 2: 프로젝트 카드 그리드 + 카테고리 필터 | | `pages/portfolio/IntroTab.jsx` | 탭 3: 자기소개 다중 버전 관리 | | `pages/portfolio/PasswordModal.jsx` | 비밀번호 입력 모달 | | `pages/portfolio/ResumeView.jsx` | PDF 출력 전용 이력서 레이아웃 | | `pages/portfolio/usePortfolioApi.js` | API 호출 + 인증 상태 관리 훅 | | `routes.jsx` | navLink + appRoute 추가 | | `components/Icons.jsx` | IconPortfolio 추가 | | `pages/home/Home.jsx` | Profile 섹션을 API 연동 요약 카드로 교체 | --- ### Task 1: Backend — DB 스키마 + 초기화 **Files:** - Create: `portfolio/app/__init__.py` - Create: `portfolio/app/db.py` - [ ] **Step 1: 빈 패키지 파일 생성** ```python # portfolio/app/__init__.py # (빈 파일) ``` - [ ] **Step 2: db.py 작성 — 연결 헬퍼 + 5개 테이블 초기화** ```python # portfolio/app/db.py 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") ``` - [ ] **Step 3: db.py — CRUD 함수 추가 (profile)** 아래 함수들을 `db.py` 끝에 추가: ```python # ── 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() ``` - [ ] **Step 4: db.py — CRUD 함수 추가 (careers, projects, skills)** ```python # ── 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) ``` - [ ] **Step 5: db.py — CRUD 함수 추가 (introductions + public)** ```python # ── 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, } ``` - [ ] **Step 6: Commit** ```bash git add portfolio/app/__init__.py portfolio/app/db.py git commit -m "feat(portfolio): DB 스키마 + CRUD 함수 (5 테이블)" ``` --- ### Task 2: Backend — Pydantic 모델 + 인증 **Files:** - Create: `portfolio/app/models.py` - Create: `portfolio/app/auth.py` - [ ] **Step 1: models.py 작성** ```python # portfolio/app/models.py 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 ``` - [ ] **Step 2: auth.py 작성 — 토큰 관리** ```python # portfolio/app/auth.py 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] ``` - [ ] **Step 3: Commit** ```bash git add portfolio/app/models.py portfolio/app/auth.py git commit -m "feat(portfolio): Pydantic 모델 + 토큰 인증" ``` --- ### Task 3: Backend — FastAPI 앱 + 전체 라우트 **Files:** - Create: `portfolio/app/main.py` - [ ] **Step 1: main.py 작성** ```python # portfolio/app/main.py 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 ``` - [ ] **Step 2: Commit** ```bash git add portfolio/app/main.py git commit -m "feat(portfolio): FastAPI 앱 + 전체 API 라우트" ``` --- ### Task 4: Backend — Dockerfile + requirements.txt **Files:** - Create: `portfolio/Dockerfile` - Create: `portfolio/requirements.txt` - [ ] **Step 1: Dockerfile 작성** ```dockerfile 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"] ``` - [ ] **Step 2: requirements.txt 작성** ``` fastapi==0.115.6 uvicorn[standard]==0.30.6 pydantic>=2.0 ``` - [ ] **Step 3: Commit** ```bash git add portfolio/Dockerfile portfolio/requirements.txt git commit -m "feat(portfolio): Dockerfile + requirements" ``` --- ### Task 5: Infra — Docker Compose + Nginx + 배포 스크립트 **Files:** - Modify: `docker-compose.yml` (agent-office 블록 뒤에 삽입) - Modify: `nginx/default.conf` (portfolio 프록시를 `/api/profile/`로 추가) - Modify: `scripts/deploy-nas.sh:5` - Modify: `scripts/deploy.sh:10,12,14,16` - [ ] **Step 1: docker-compose.yml에 portfolio 서비스 추가** agent-office 서비스 블록(`healthcheck` 3줄 포함) 뒤, `travel-proxy:` 블록 앞에 삽입: ```yaml 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 ``` - [ ] **Step 2: nginx/default.conf에 `/api/profile/` 프록시 추가** 기존 `/api/portfolio` (stock-lab) 블록 뒤, `# agent-office` 블록 앞에 삽입: ```nginx # 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/; } ``` - [ ] **Step 3: deploy-nas.sh SERVICES에 portfolio 추가** Line 5를 수정: ```bash SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office portfolio nginx scripts" ``` - [ ] **Step 4: deploy.sh에 portfolio 추가 (4곳)** Line 10: ```bash BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office portfolio frontend" ``` Line 12: ```bash CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office travel-proxy portfolio lotto-frontend" ``` Line 14: ```bash HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio" ``` Line 16: ```bash DATA_DIRS="music stock blog realestate agent-office portfolio" ``` - [ ] **Step 5: Commit** ```bash git add docker-compose.yml nginx/default.conf scripts/deploy-nas.sh scripts/deploy.sh git commit -m "infra(portfolio): Docker Compose + Nginx + 배포 스크립트" ``` --- ### Task 6: Frontend — 라우팅 + 아이콘 + API 훅 **Files:** - Modify: `web-ui/src/routes.jsx` - Modify: `web-ui/src/components/Icons.jsx` - Create: `web-ui/src/pages/portfolio/usePortfolioApi.js` - [ ] **Step 1: Icons.jsx에 IconPortfolio 추가** 파일 끝 `export const IconBuilding` 뒤에 추가: ```jsx export const IconPortfolio = () => ; ``` - [ ] **Step 2: routes.jsx에 Portfolio navLink + route 추가** import 섹션에 추가: ```jsx import { IconPortfolio } from './components/Icons'; ``` lazy import 추가 (기존 lazy import 블록 끝에): ```jsx const Portfolio = lazy(() => import('./pages/portfolio/Portfolio')); ``` navLinks 배열에서 `agent-office` 항목 앞에 추가: ```jsx { id: 'portfolio', label: 'Portfolio', path: '/portfolio', subtitle: 'RESUME', description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스', icon: , accent: '#06b6d4', }, ``` appRoutes 배열에서 `agent-office` 항목 앞에 추가: ```jsx { path: 'portfolio', element: , }, ``` - [ ] **Step 3: usePortfolioApi.js 작성** ```jsx // web-ui/src/pages/portfolio/usePortfolioApi.js import { useState, useCallback } from 'react'; const BASE = '/api/profile'; async function apiFetch(path, options = {}) { const res = await fetch(`${BASE}${path}`, { headers: { 'Content-Type': 'application/json', ...options.headers }, ...options, }); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || res.statusText); } return res.json(); } export default function usePortfolioApi() { const [token, setToken] = useState(null); const [authError, setAuthError] = useState(''); const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; const login = useCallback(async (password) => { setAuthError(''); try { const data = await apiFetch('/auth', { method: 'POST', body: JSON.stringify({ password }), }); setToken(data.token); return true; } catch (err) { setAuthError(err.message); return false; } }, []); const logout = useCallback(() => setToken(null), []); // ── Public ── const fetchPublic = useCallback(() => apiFetch('/public'), []); // ── Profile ── const fetchProfile = useCallback(() => apiFetch('/profile', { headers: authHeaders }), [token]); const saveProfile = useCallback((data) => apiFetch('/profile', { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]); // ── Careers ── const fetchCareers = useCallback(() => apiFetch('/careers', { headers: authHeaders }), [token]); const addCareer = useCallback((data) => apiFetch('/careers', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]); const editCareer = useCallback((id, data) => apiFetch(`/careers/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]); const removeCareer = useCallback((id) => apiFetch(`/careers/${id}`, { method: 'DELETE', headers: authHeaders }), [token]); // ── Projects ── const fetchProjects = useCallback(() => apiFetch('/projects', { headers: authHeaders }), [token]); const addProject = useCallback((data) => apiFetch('/projects', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]); const editProject = useCallback((id, data) => apiFetch(`/projects/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]); const removeProject = useCallback((id) => apiFetch(`/projects/${id}`, { method: 'DELETE', headers: authHeaders }), [token]); // ── Skills ── const fetchSkills = useCallback(() => apiFetch('/skills', { headers: authHeaders }), [token]); const addSkill = useCallback((data) => apiFetch('/skills', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]); const editSkill = useCallback((id, data) => apiFetch(`/skills/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]); const removeSkill = useCallback((id) => apiFetch(`/skills/${id}`, { method: 'DELETE', headers: authHeaders }), [token]); // ── Introductions ── const fetchIntros = useCallback(() => apiFetch('/introductions', { headers: authHeaders }), [token]); const addIntro = useCallback((data) => apiFetch('/introductions', { method: 'POST', headers: authHeaders, body: JSON.stringify(data) }), [token]); const editIntro = useCallback((id, data) => apiFetch(`/introductions/${id}`, { method: 'PUT', headers: authHeaders, body: JSON.stringify(data) }), [token]); const removeIntro = useCallback((id) => apiFetch(`/introductions/${id}`, { method: 'DELETE', headers: authHeaders }), [token]); const setMainIntro = useCallback((id) => apiFetch(`/introductions/${id}/main`, { method: 'PATCH', headers: authHeaders }), [token]); return { token, authError, login, logout, fetchPublic, fetchProfile, saveProfile, fetchCareers, addCareer, editCareer, removeCareer, fetchProjects, addProject, editProject, removeProject, fetchSkills, addSkill, editSkill, removeSkill, fetchIntros, addIntro, editIntro, removeIntro, setMainIntro, }; } ``` - [ ] **Step 4: Commit** ```bash cd web-ui git add src/routes.jsx src/components/Icons.jsx src/pages/portfolio/usePortfolioApi.js git commit -m "feat(portfolio): 라우팅 + 아이콘 + API 훅" ``` --- ### Task 7: Frontend — PasswordModal 컴포넌트 **Files:** - Create: `web-ui/src/pages/portfolio/PasswordModal.jsx` - [ ] **Step 1: PasswordModal.jsx 작성** ```jsx // web-ui/src/pages/portfolio/PasswordModal.jsx import { useState } from 'react'; export default function PasswordModal({ open, onAuth, onClose, error }) { const [pw, setPw] = useState(''); const [loading, setLoading] = useState(false); if (!open) return null; const handleSubmit = async (e) => { e.preventDefault(); if (!pw.trim()) return; setLoading(true); await onAuth(pw); setLoading(false); setPw(''); }; return (
e.stopPropagation()}>

편집 모드

편집하려면 비밀번호를 입력하세요.

setPw(e.target.value)} autoFocus /> {error &&

{error}

}
); } ``` - [ ] **Step 2: Commit** ```bash cd web-ui git add src/pages/portfolio/PasswordModal.jsx git commit -m "feat(portfolio): 비밀번호 모달 컴포넌트" ``` --- ### Task 8: Frontend — ProfileTab (탭 1: 프로필 + 경력 + 기술) **Files:** - Create: `web-ui/src/pages/portfolio/ProfileTab.jsx` - [ ] **Step 1: ProfileTab.jsx 작성** ```jsx // web-ui/src/pages/portfolio/ProfileTab.jsx import { useState } from 'react'; const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' }; const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' }; const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 }; const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 }; export default function ProfileTab({ data, editing, api, onRefresh }) { const { profile, careers, skills } = data; const [editingProfile, setEditingProfile] = useState(null); const [careerForm, setCareerForm] = useState(null); const [skillForm, setSkillForm] = useState(null); // ── Profile 편집 ── const startEditProfile = () => setEditingProfile({ ...profile }); const saveProfileEdit = async () => { await api.saveProfile(editingProfile); setEditingProfile(null); onRefresh(); }; // ── Career CRUD ── const saveCareer = async () => { if (careerForm.id) { await api.editCareer(careerForm.id, careerForm); } else { await api.addCareer(careerForm); } setCareerForm(null); onRefresh(); }; const deleteCareer = async (id) => { await api.removeCareer(id); onRefresh(); }; // ── Skill CRUD ── const saveSkill = async () => { if (skillForm.id) { await api.editSkill(skillForm.id, skillForm); } else { await api.addSkill(skillForm); } setSkillForm(null); onRefresh(); }; const deleteSkill = async (id) => { await api.removeSkill(id); onRefresh(); }; const grouped = (items, catMap) => { const groups = {}; for (const key of Object.keys(catMap)) groups[key] = []; for (const item of items) { const cat = item.category || Object.keys(catMap)[0]; if (!groups[cat]) groups[cat] = []; groups[cat].push(item); } return groups; }; return (
{/* ── 프로필 카드 ── */}
{editingProfile ? (