# 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:
편집하려면 비밀번호를 입력하세요.
{profile.name_en}
}{profile.role || profile.role_en}
{profile.bio}
} {editing && } > )}{c.description}
} {editing && (프로젝트가 없습니다.
} {filtered.map(p => ({p.role}
} {p.description &&{p.description}
} {p.tech_stack?.length > 0 && (자기소개 글이 없습니다.
} {introductions.map(intro => ({intro.content}
{profile.role}
{main_introduction?.content || profile.bio}
{c.description}
}{p.description}
} {p.tech_stack?.length > 0 && ({p.tech_stack.join(' · ')}
)}{skills.map(s => s.name).join(' · ')}
불러오는 중...
{error}
페이지 주인 소개 영역입니다.
{portfolio?.profile?.role || 'Server Developer'}
{portfolio?.profile?.name || '박 재 오'}
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}