feat(portfolio): 백엔드 서비스 + 인프라 설정
- FastAPI 앱: DB(5테이블), Pydantic 모델, 토큰 인증, 전체 API 라우트 - Docker Compose: portfolio 서비스 (포트 18850) - Nginx: /api/profile/ → portfolio:8000 - 배포 스크립트: portfolio 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -149,6 +149,25 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
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:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
container_name: travel-proxy
|
container_name: travel-proxy
|
||||||
|
|||||||
@@ -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
|
# agent-office API + WebSocket
|
||||||
location /api/agent-office/ {
|
location /api/agent-office/ {
|
||||||
resolver 127.0.0.11 valid=10s;
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|||||||
10
portfolio/Dockerfile
Normal file
10
portfolio/Dockerfile
Normal file
@@ -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"]
|
||||||
0
portfolio/app/__init__.py
Normal file
0
portfolio/app/__init__.py
Normal file
39
portfolio/app/auth.py
Normal file
39
portfolio/app/auth.py
Normal file
@@ -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]
|
||||||
350
portfolio/app/db.py
Normal file
350
portfolio/app/db.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
190
portfolio/app/main.py
Normal file
190
portfolio/app/main.py
Normal file
@@ -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
|
||||||
90
portfolio/app/models.py
Normal file
90
portfolio/app/models.py
Normal file
@@ -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
|
||||||
3
portfolio/requirements.txt
Normal file
3
portfolio/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
pydantic>=2.0
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
set -euo pipefail
|
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 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
|||||||
|
|
||||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||||
# docker compose 서비스명 (deployer 제외 — 자기 자신을 재빌드하면 스크립트 중단)
|
# 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 디렉토리
|
||||||
DATA_DIRS="music stock blog realestate agent-office"
|
DATA_DIRS="music stock blog realestate agent-office portfolio"
|
||||||
|
|
||||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user