refactor: portfolio → personal 리네이밍 + Blog/Todo 통합

- portfolio/ 디렉토리를 personal/로 리네이밍
- lotto-backend의 Blog/Todo 라우트·CRUD를 personal 서비스로 이전
- lotto-backend에서 Blog/Todo 코드 제거 (DB 테이블 스키마는 유지)
- nginx: /api/todos, /api/blog/ 라우팅을 personal로 추가
- docker-compose: portfolio → personal 서비스 변��
- deploy 스크립트: portfolio → personal 반영

데이터 마이그레이션은 배포 후 NAS에서 별도 수행 필요:
1. cp data/portfolio/portfolio.db data/personal/personal.db
2. sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
3. sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 16:32:55 +09:00
parent 6004bcf66d
commit e3d5eaf6f3
15 changed files with 516 additions and 263 deletions

View File

@@ -7,7 +7,7 @@
## 1. 프로젝트 개요 ## 1. 프로젝트 개요
Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포. Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
- **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, portfolio, deployer (9개) - **서비스**: lotto-lab, stock-lab, travel-proxy, music-lab, blog-lab, realestate-lab, agent-office, personal, deployer (9개)
- **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포 - **프론트엔드**: 별도 레포 (React + Vite SPA), 빌드 산출물만 NAS에 배포
- **인프라**: Docker Compose (9컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포 - **인프라**: Docker Compose (9컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
@@ -59,7 +59,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API | | `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API | | `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) | | `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) | | `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 | | `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 | | `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 | | `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
@@ -78,7 +78,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API | | `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
| `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API | | `/api/blog-marketing/` | `blog-lab:8000` | 블로그 마케팅 수익화 API |
| `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API | | `/api/realestate/` | `realestate-lab:8000` | 부동산 청약 API |
| `/api/profile/` | `portfolio:8000` | 포트폴리오 API | | `/api/todos` | `personal:8000` | 투두 API |
| `/api/blog/` | `personal:8000` | 블로그 API |
| `/api/profile/` | `personal:8000` | 포트폴리오 API |
| `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket | | `/api/agent-office/` | `agent-office:8000` | AI 에이전트 오피스 API + WebSocket |
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook | | `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 | | `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
@@ -156,8 +158,8 @@ docker compose up -d
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) | | `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
| `weekly_reports` | 주간 공략 리포트 캐시 | | `weekly_reports` | 주간 공략 리포트 캐시 |
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) | | `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
| `todos` | 투두리스트 (UUID PK) | | `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `blog_posts` | 블로그 글 (tags: JSON 배열) | | `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
**스케줄러 job** **스케줄러 job**
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`) - 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`)
@@ -191,15 +193,6 @@ docker compose up -d
| GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) | | GET | `/api/history` | 추천 이력 (limit, offset, favorite, tag, sort) |
| PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 | | PATCH | `/api/history/{id}` | 즐겨찾기·메모·태그 수정 |
| DELETE | `/api/history/{id}` | 삭제 | | DELETE | `/api/history/{id}` | 삭제 |
| GET | `/api/todos` | 투두 전체 목록 |
| POST | `/api/todos` | 투두 생성 (status: todo\|in_progress\|done) |
| PUT | `/api/todos/{id}` | 투두 수정 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
| GET | `/api/blog/posts` | 블로그 글 목록 (`{"posts": [...]}`, date DESC) |
| POST | `/api/blog/posts` | 블로그 글 생성 (date 미입력 시 오늘) |
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
| GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 | | GET | `/api/lotto/curator/candidates` | 큐레이터용 후보 N세트 + 피처 |
| GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) | | GET | `/api/lotto/curator/context` | 주간 맥락(핫/콜드·직전 회차) |
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 | | GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
@@ -494,16 +487,16 @@ docker compose up -d
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 | | GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) | | GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
### portfolio (portfolio/) ### personal (personal/)
- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리) - 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블) - DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL) - 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py` - 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
**환경변수** **환경변수**
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가) - `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
**portfolio API 목록** **personal API 목록**
| 메서드 | 경로 | 설명 | | 메서드 | 경로 | 설명 |
|--------|------|------| |--------|------|------|
@@ -528,6 +521,15 @@ docker compose up -d
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) | | PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) | | DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
| PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) | | PATCH | `/api/profile/introductions/{id}/main` | 메인 자기소개 지정 (인증) |
| GET | `/api/todos` | 투두 전체 목록 |
| POST | `/api/todos` | 투두 생성 |
| PUT | `/api/todos/{id}` | 투두 수정 |
| DELETE | `/api/todos/done` | 완료 항목 일괄 삭제 |
| DELETE | `/api/todos/{id}` | 투두 개별 삭제 |
| GET | `/api/blog/posts` | 블로그 글 목록 |
| POST | `/api/blog/posts` | 블로그 글 생성 |
| PUT | `/api/blog/posts/{id}` | 블로그 글 수정 |
| DELETE | `/api/blog/posts/{id}` | 블로그 글 삭제 |
### deployer (deployer/) ### deployer (deployer/)
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용) - Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
@@ -540,7 +542,7 @@ docker compose up -d
## 10. 주의사항 ## 10. 주의사항
- **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리 - **Nginx trailing slash**: `/api/portfolio`는 trailing slash 없이도 매칭되도록 두 location 블록으로 처리
- **라우트 순서**: `DELETE /api/todos/done``DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (FastAPI prefix 매칭 순서) - **라우트 순서**: `DELETE /api/todos/done``DELETE /api/todos/{id}` 보다 **반드시 먼저** 등록 (personal 서비스, FastAPI prefix 매칭 순서)
- **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입 - **PUID/PGID**: travel-proxy는 NAS 파일 권한을 위해 PUID/PGID를 환경변수로 주입
- **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable) - **캐시 전략**: `index.html``no-store`, `assets/`는 1년 장기 캐시(immutable)
- **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드 - **Frontend 배포**: git push로 자동 배포되지 않음. 로컬 빌드 후 NAS에 수동 업로드

View File

@@ -298,133 +298,6 @@ def init_db() -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_briefings_draw ON lotto_briefings(draw_no DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_briefings_draw ON lotto_briefings(draw_no DESC)")
# ── todos CRUD ───────────────────────────────────────────────────────────────
def _todo_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"title": r["title"],
"description": r["description"],
"status": r["status"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def get_all_todos() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM todos ORDER BY created_at DESC"
).fetchall()
return [_todo_row_to_dict(r) for r in rows]
def create_todo(title: str, description: Optional[str], status: str) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO todos (title, description, status) VALUES (?, ?, ?)",
(title, description, status),
)
row = conn.execute(
"SELECT * FROM todos WHERE rowid = last_insert_rowid()"
).fetchone()
return _todo_row_to_dict(row)
def update_todo(todo_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""fields에 있는 항목만 업데이트 (PATCH 방식), updated_at 자동 갱신"""
allowed = {"title", "description", "status"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
with _conn() as conn:
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
return _todo_row_to_dict(row) if row else None
set_clauses = ", ".join(f"{k} = ?" for k in updates)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
args = list(updates.values()) + [todo_id]
with _conn() as conn:
conn.execute(
f"UPDATE todos SET {set_clauses} WHERE id = ?",
args,
)
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
return _todo_row_to_dict(row) if row else None
def delete_todo(todo_id: str) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
return cur.rowcount > 0
def delete_done_todos() -> int:
with _conn() as conn:
cur = conn.execute("DELETE FROM todos WHERE status = 'done'")
return cur.rowcount
# ── blog_posts CRUD ──────────────────────────────────────────────────────────
def _post_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"],
"title": r["title"],
"body": r["body"],
"excerpt": r["excerpt"],
"tags": json.loads(r["tags"]) if r["tags"] else [],
"date": r["date"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
}
def get_all_posts() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM blog_posts ORDER BY date DESC, id DESC"
).fetchall()
return [_post_row_to_dict(r) for r in rows]
def create_post(title: str, body: str, excerpt: str, tags: List[str], date: str) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO blog_posts (title, body, excerpt, tags, date) VALUES (?, ?, ?, ?, ?)",
(title, body, excerpt, json.dumps(tags), date),
)
row = conn.execute(
"SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()"
).fetchone()
return _post_row_to_dict(row)
def update_post(post_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
allowed = {"title", "body", "excerpt", "tags", "date"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
with _conn() as conn:
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
return _post_row_to_dict(row) if row else None
if "tags" in updates:
updates["tags"] = json.dumps(updates["tags"])
set_clauses = ", ".join(f"{k} = ?" for k in updates)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
args = list(updates.values()) + [post_id]
with _conn() as conn:
conn.execute(f"UPDATE blog_posts SET {set_clauses} WHERE id = ?", args)
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
return _post_row_to_dict(row) if row else None
def delete_post(post_id: int) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
return cur.rowcount > 0
def upsert_draw(row: Dict[str, Any]) -> None: def upsert_draw(row: Dict[str, Any]) -> None:

View File

@@ -15,10 +15,6 @@ from .db import (
update_recommendation, update_recommendation,
# 시뮬레이션 관련 # 시뮬레이션 관련
get_best_picks, get_simulation_runs, get_simulation_candidates, get_best_picks, get_simulation_runs, get_simulation_candidates,
# todos
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
# blog
get_all_posts, create_post, update_post, delete_post,
# 성과 통계 # 성과 통계
get_recommendation_performance, get_recommendation_performance,
# Phase 2: 구매 이력 # Phase 2: 구매 이력
@@ -839,99 +835,3 @@ def version():
return {"version": os.getenv("APP_VERSION", "dev")} return {"version": os.getenv("APP_VERSION", "dev")}
# ── Todos API ─────────────────────────────────────────────────────────────────
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
status: str = "todo"
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
@app.get("/api/todos")
def api_todos_list():
return get_all_todos()
@app.post("/api/todos", status_code=201)
def api_todos_create(body: TodoCreate):
if body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
return create_todo(body.title, body.description, body.status)
# ⚠️ /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
@app.delete("/api/todos/done")
def api_todos_delete_done():
deleted = delete_done_todos()
return {"deleted": deleted}
@app.put("/api/todos/{todo_id}")
def api_todos_update(todo_id: str, body: TodoUpdate):
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Todo not found")
return updated
@app.delete("/api/todos/{todo_id}")
def api_todos_delete(todo_id: str):
ok = delete_todo(todo_id)
if not ok:
raise HTTPException(status_code=404, detail="Todo not found")
return {"ok": True}
# ── Blog API ──────────────────────────────────────────────────────────────────
class BlogPostCreate(BaseModel):
title: str
body: str = ""
excerpt: str = ""
tags: List[str] = []
date: str = "" # 빈 문자열이면 오늘 날짜 사용
class BlogPostUpdate(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
excerpt: Optional[str] = None
tags: Optional[List[str]] = None
date: Optional[str] = None
@app.get("/api/blog/posts")
def api_blog_list():
return {"posts": get_all_posts()}
@app.post("/api/blog/posts", status_code=201)
def api_blog_create(body: BlogPostCreate):
from datetime import date as _date
post_date = body.date if body.date else _date.today().isoformat()
post = create_post(body.title, body.body, body.excerpt, body.tags, post_date)
return post
@app.put("/api/blog/posts/{post_id}")
def api_blog_update(post_id: int, body: BlogPostUpdate):
updated = update_post(post_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Post not found")
return updated
@app.delete("/api/blog/posts/{post_id}")
def api_blog_delete(post_id: int):
ok = delete_post(post_id)
if not ok:
raise HTTPException(status_code=404, detail="Post not found")
return {"ok": True}

View File

@@ -149,10 +149,10 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
portfolio: personal:
build: build:
context: ./portfolio context: ./personal
container_name: portfolio container_name: personal
restart: unless-stopped restart: unless-stopped
ports: ports:
- "18850:8000" - "18850:8000"
@@ -161,7 +161,7 @@ services:
- PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-} - PORTFOLIO_EDIT_PASSWORD=${PORTFOLIO_EDIT_PASSWORD:-}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${RUNTIME_PATH:-.}/data/portfolio:/app/data - ${RUNTIME_PATH:-.}/data/personal:/app/data
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s interval: 30s

View File

@@ -0,0 +1,220 @@
# Personal 서비스 마이그레이션 설계
## 개요
기존 `portfolio` 서비스를 `personal`로 리네이밍하고, lotto-backend에 있던 Blog/Todo 기능을 personal 서비스로 통합한다.
**목표**: 신규 컨테이너 없이, 개인 콘텐츠(포트폴리오 + 블로그 + 투두)를 하나의 서비스로 통합
**제약**: 기존 데이터 무손실 이전 필수
---
## 아키텍처
### 변경 전
```
lotto-backend (lotto.db)
├── 로또 API (/api/lotto/*)
├── 블로그 API (/api/blog/posts) ← 이전 대상
└── 투두 API (/api/todos) ← 이전 대상
portfolio (portfolio.db)
└── 포트폴리오 API (/api/profile/*)
```
### 변경 후
```
lotto-backend (lotto.db)
└── 로또 API (/api/lotto/*) ← Blog/Todo 라우트 제거
personal (personal.db)
├── 포트폴리오 API (/api/profile/*)
├── 블로그 API (/api/blog/posts) ← 통합
└── 투두 API (/api/todos) ← 통합
```
---
## 서비스 속성
| 항목 | 현재 (portfolio) | 변경 후 (personal) |
|------|-----------------|-------------------|
| 디렉토리 | `portfolio/` | `personal/` |
| 컨테이너명 | `portfolio` | `personal` |
| 포트 | 18850 | 18850 (유지) |
| DB 파일 | `data/portfolio/portfolio.db` | `data/personal/personal.db` |
| API prefix | `/api/profile/` | `/api/profile/` + `/api/todos` + `/api/blog/` |
---
## DB 스키마
personal.db에 기존 5테이블 + 신규 2테이블:
### 기존 테이블 (portfolio에서 이관)
- `profile` — 프로필 (id=1 싱글턴)
- `careers` — 경력
- `projects` — 프로젝트
- `skills` — 기술스택
- `introductions` — 자기소개
### 신규 추가 테이블 (lotto-backend에서 이관)
```sql
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo'
CHECK(status IN ('todo','in_progress','done')),
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'))
);
CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC);
CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
date TEXT NOT NULL DEFAULT (date('now','localtime')),
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'))
);
CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC);
```
---
## API 엔드포인트 (personal 서비스 전체)
### 포트폴리오 (기존 유지)
| 메서드 | 경로 | 인증 | 설명 |
|--------|------|------|------|
| GET | `/api/profile/public` | - | 공개 데이터 일괄 조회 |
| POST | `/api/profile/auth` | - | 비밀번호 인증 → 토큰 |
| GET/PUT | `/api/profile/profile` | Bearer | 프로필 조회/수정 |
| GET/POST | `/api/profile/careers` | Bearer | 경력 목록/추가 |
| PUT/DELETE | `/api/profile/careers/{id}` | Bearer | 경력 수정/삭제 |
| GET/POST | `/api/profile/projects` | Bearer | 프로젝트 목록/추가 |
| PUT/DELETE | `/api/profile/projects/{id}` | Bearer | 프로젝트 수정/삭제 |
| GET/POST | `/api/profile/skills` | Bearer | 기술 목록/추가 |
| PUT/DELETE | `/api/profile/skills/{id}` | Bearer | 기술 수정/삭제 |
| GET/POST | `/api/profile/introductions` | Bearer | 자기소개 목록/추가 |
| PUT/DELETE | `/api/profile/introductions/{id}` | Bearer | 자기소개 수정/삭제 |
| PATCH | `/api/profile/introductions/{id}/main` | Bearer | 메인 자기소개 지정 |
### 투두 (lotto-backend에서 이전, 인증 없음)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/todos` | 전체 목록 |
| POST | `/api/todos` | 생성 |
| DELETE | `/api/todos/done` | 완료 일괄 삭제 |
| PUT | `/api/todos/{id}` | 수정 |
| DELETE | `/api/todos/{id}` | 삭제 |
### 블로그 (lotto-backend에서 이전, 인증 없음)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/blog/posts` | 목록 (`{"posts": [...]}`) |
| POST | `/api/blog/posts` | 생성 |
| PUT | `/api/blog/posts/{id}` | 수정 |
| DELETE | `/api/blog/posts/{id}` | 삭제 |
---
## Nginx 라우팅 변경
```nginx
# 추가: /api/todos → personal
location /api/todos {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
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://$personal_backend$request_uri;
}
# 추가: /api/blog/ → personal
location /api/blog/ {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
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://$personal_backend$request_uri;
}
# 변경: portfolio → personal
location /api/profile/ {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
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://$personal_backend$request_uri;
}
```
기존 `/api/` catch-all은 lotto-backend로 유지 (todos/blog 요청은 위의 더 구체적인 location에서 먼저 매칭).
---
## 인프라 변경
### docker-compose.yml
- `portfolio` 서비스 → `personal`로 리네이밍
- 볼륨: `${RUNTIME_PATH}/data/personal:/app/data`
- 환경변수 동일 (PORTFOLIO_EDIT_PASSWORD 등)
### deploy.sh / deploy-nas.sh
- SERVICES, BUILD_TARGETS, CONTAINER_NAMES 등에서 `portfolio``personal` 변경
- DATA_DIRS에서 `portfolio``personal` 변경
### lotto-backend 정리
- `main.py`에서 Blog/Todo 라우트 + Pydantic 모델 제거 (약 100줄)
- `db.py`에서 Blog/Todo CRUD 함수 제거 (약 130줄)
- `db.py``init_db()`에서 todos/blog_posts 테이블 생성 코드는 유지 (기존 DB 호환)
---
## 배포 순서 (안전 우선)
1. **코드 개발** — personal 서비스 + lotto-backend 정리 + 인프라 변경
2. **git push** — 자동 배포 트리거
3. **NAS에서 데이터 디렉토리 준비**`mkdir -p data/personal`
4. **기존 portfolio.db 이동**`cp data/portfolio/portfolio.db data/personal/personal.db`
5. **lotto.db에서 Blog/Todo 데이터 복사**:
```bash
sqlite3 data/lotto.db ".dump todos" | sqlite3 data/personal/personal.db
sqlite3 data/lotto.db ".dump blog_posts" | sqlite3 data/personal/personal.db
```
6. **컨테이너 재시작** — `docker compose restart personal`
7. **검증** — API 호출로 데이터 건수 대조
8. **lotto.db 원본 테이블** — 삭제하지 않고 당분간 유지
---
## 프론트엔드
변경 없음. 모든 API 호출이 상대경로(`/api/todos`, `/api/blog/posts`, `/api/profile/`)이므로 nginx 라우팅 변경만으로 자동 적용.
---
## 리스크
- **낮음**: Blog/Todo는 lotto 테이블과 FK/공유 쿼리 없음
- **롤백**: lotto.db 원본 테이블 유지 + nginx 라우팅 원복으로 즉시 롤백 가능
- **다운타임**: nginx reload 순간 (~1초)

View File

@@ -84,6 +84,32 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# todos API → personal
location /api/todos {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
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://$personal_backend$request_uri;
}
# blog API → personal
location /api/blog/ {
resolver 127.0.0.11 valid=10s;
set $personal_backend personal:8000;
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://$personal_backend$request_uri;
}
# travel API # travel API
location /api/travel/ { location /api/travel/ {
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -140,17 +166,17 @@ server {
} }
# profile API (Portfolio Service) # profile API (Personal Service)
location /api/profile/ { location /api/profile/ {
resolver 127.0.0.11 valid=10s; resolver 127.0.0.11 valid=10s;
set $portfolio_backend portfolio:8000; set $personal_backend personal:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$portfolio_backend$request_uri; proxy_pass http://$personal_backend$request_uri;
} }
# agent-office API + WebSocket # agent-office API + WebSocket

View File

@@ -3,9 +3,9 @@ import json
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
logger = logging.getLogger("portfolio") logger = logging.getLogger("personal")
DB_PATH = "/app/data/portfolio.db" DB_PATH = "/app/data/personal.db"
def _conn(): def _conn():
@@ -103,7 +103,37 @@ def init_db():
updated_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") # ── todos 테이블 ──
conn.execute("""
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY
DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2)))),
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'todo'
CHECK(status IN ('todo','in_progress','done')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC)")
# ── blog_posts 테이블 ──
conn.execute("""
CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
excerpt TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
date TEXT NOT NULL DEFAULT (date('now','localtime')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_blog_date ON blog_posts(date DESC)")
logger.info("personal DB initialized")
# ── Profile ── # ── Profile ──
@@ -331,6 +361,108 @@ def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
return _row_to_dict(row) return _row_to_dict(row)
# ── Public (일괄 조회) ──
# ── Todos ──
def _todo_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"], "title": r["title"], "description": r["description"],
"status": r["status"], "created_at": r["created_at"], "updated_at": r["updated_at"],
}
def get_all_todos() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall()
return [_todo_row_to_dict(r) for r in rows]
def create_todo(title: str, description: Optional[str], status: str) -> Dict[str, Any]:
with _conn() as conn:
conn.execute("INSERT INTO todos (title, description, status) VALUES (?, ?, ?)", (title, description, status))
row = conn.execute("SELECT * FROM todos WHERE rowid = last_insert_rowid()").fetchone()
return _todo_row_to_dict(row)
def update_todo(todo_id: str, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
allowed = {"title", "description", "status"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
with _conn() as conn:
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
return _todo_row_to_dict(row) if row else None
set_clauses = ", ".join(f"{k} = ?" for k in updates)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
args = list(updates.values()) + [todo_id]
with _conn() as conn:
conn.execute(f"UPDATE todos SET {set_clauses} WHERE id = ?", args)
row = conn.execute("SELECT * FROM todos WHERE id = ?", (todo_id,)).fetchone()
return _todo_row_to_dict(row) if row else None
def delete_todo(todo_id: str) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,))
return cur.rowcount > 0
def delete_done_todos() -> int:
with _conn() as conn:
cur = conn.execute("DELETE FROM todos WHERE status = 'done'")
return cur.rowcount
# ── Blog Posts ──
def _post_row_to_dict(r) -> Dict[str, Any]:
return {
"id": r["id"], "title": r["title"], "body": r["body"],
"excerpt": r["excerpt"], "tags": json.loads(r["tags"]) if r["tags"] else [],
"date": r["date"], "created_at": r["created_at"], "updated_at": r["updated_at"],
}
def get_all_posts() -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute("SELECT * FROM blog_posts ORDER BY date DESC, id DESC").fetchall()
return [_post_row_to_dict(r) for r in rows]
def create_post(title: str, body: str, excerpt: str, tags: List[str], date: str) -> Dict[str, Any]:
with _conn() as conn:
conn.execute(
"INSERT INTO blog_posts (title, body, excerpt, tags, date) VALUES (?, ?, ?, ?, ?)",
(title, body, excerpt, json.dumps(tags), date),
)
row = conn.execute("SELECT * FROM blog_posts WHERE rowid = last_insert_rowid()").fetchone()
return _post_row_to_dict(row)
def update_post(post_id: int, fields: Dict[str, Any]) -> Optional[Dict[str, Any]]:
allowed = {"title", "body", "excerpt", "tags", "date"}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
with _conn() as conn:
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
return _post_row_to_dict(row) if row else None
if "tags" in updates:
updates["tags"] = json.dumps(updates["tags"])
set_clauses = ", ".join(f"{k} = ?" for k in updates)
set_clauses += ", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"
args = list(updates.values()) + [post_id]
with _conn() as conn:
conn.execute(f"UPDATE blog_posts SET {set_clauses} WHERE id = ?", args)
row = conn.execute("SELECT * FROM blog_posts WHERE id = ?", (post_id,)).fetchone()
return _post_row_to_dict(row) if row else None
def delete_post(post_id: int) -> bool:
with _conn() as conn:
cur = conn.execute("DELETE FROM blog_posts WHERE id = ?", (post_id,))
return cur.rowcount > 0
# ── Public (일괄 조회) ── # ── Public (일괄 조회) ──
def get_public_data() -> Dict[str, Any]: def get_public_data() -> Dict[str, Any]:

View File

@@ -12,22 +12,25 @@ from .db import (
get_skills, create_skill, update_skill, delete_skill, get_skills, create_skill, update_skill, delete_skill,
get_introductions, create_introduction, update_introduction, get_introductions, create_introduction, update_introduction,
delete_introduction, set_main_introduction, delete_introduction, set_main_introduction,
get_all_todos, create_todo, update_todo, delete_todo, delete_done_todos,
get_all_posts, create_post, update_post, delete_post,
) )
from .models import ( from .models import (
ProfileUpdate, CareerCreate, CareerUpdate, ProfileUpdate, CareerCreate, CareerUpdate,
ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate, ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
IntroCreate, IntroUpdate, AuthRequest, IntroCreate, IntroUpdate, AuthRequest,
TodoCreate, TodoUpdate, BlogPostCreate, BlogPostUpdate,
) )
from .auth import authenticate, require_auth from .auth import authenticate, require_auth
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("portfolio") logger = logging.getLogger("personal")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
logger.info("portfolio service 시작") logger.info("personal service 시작")
yield yield
@@ -188,3 +191,72 @@ def api_intro_set_main(intro_id: int):
if not result: if not result:
raise HTTPException(status_code=404, detail="Introduction not found") raise HTTPException(status_code=404, detail="Introduction not found")
return result return result
# ── Todos API ──
@app.get("/api/todos")
def api_todos_list():
return get_all_todos()
@app.post("/api/todos", status_code=201)
def api_todos_create(body: TodoCreate):
if body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
return create_todo(body.title, body.description, body.status)
# /done 라우트를 /{todo_id} 보다 먼저 등록해야 done이 id로 매칭되지 않음
@app.delete("/api/todos/done")
def api_todos_delete_done():
deleted = delete_done_todos()
return {"deleted": deleted}
@app.put("/api/todos/{todo_id}")
def api_todos_update(todo_id: str, body: TodoUpdate):
if body.status is not None and body.status not in ("todo", "in_progress", "done"):
raise HTTPException(status_code=422, detail="status must be todo | in_progress | done")
updated = update_todo(todo_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Todo not found")
return updated
@app.delete("/api/todos/{todo_id}")
def api_todos_delete(todo_id: str):
ok = delete_todo(todo_id)
if not ok:
raise HTTPException(status_code=404, detail="Todo not found")
return {"ok": True}
# ── Blog API ──
@app.get("/api/blog/posts")
def api_blog_list():
return {"posts": get_all_posts()}
@app.post("/api/blog/posts", status_code=201)
def api_blog_create(body: BlogPostCreate):
from datetime import date as _date
post_date = body.date if body.date else _date.today().isoformat()
return create_post(body.title, body.body, body.excerpt, body.tags, post_date)
@app.put("/api/blog/posts/{post_id}")
def api_blog_update(post_id: int, body: BlogPostUpdate):
updated = update_post(post_id, body.model_dump(exclude_none=True))
if updated is None:
raise HTTPException(status_code=404, detail="Post not found")
return updated
@app.delete("/api/blog/posts/{post_id}")
def api_blog_delete(post_id: int):
ok = delete_post(post_id)
if not ok:
raise HTTPException(status_code=404, detail="Post not found")
return {"ok": True}

View File

@@ -88,3 +88,31 @@ class IntroUpdate(BaseModel):
class AuthRequest(BaseModel): class AuthRequest(BaseModel):
password: str password: str
class TodoCreate(BaseModel):
title: str
description: Optional[str] = None
status: str = "todo"
class TodoUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
class BlogPostCreate(BaseModel):
title: str
body: str = ""
excerpt: str = ""
tags: List[str] = []
date: str = ""
class BlogPostUpdate(BaseModel):
title: Optional[str] = None
body: Optional[str] = None
excerpt: Optional[str] = None
tags: Optional[List[str]] = None
date: Optional[str] = None

View File

@@ -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 portfolio nginx scripts" SERVICES="backend travel-proxy deployer stock-lab music-lab blog-lab realestate-lab agent-office personal nginx scripts"
# 1. 자동 감지: Docker 컨테이너 내부인가? # 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then if [ -d "/repo" ] && [ -d "/runtime" ]; then

View File

@@ -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 portfolio frontend" BUILD_TARGETS="backend travel-proxy stock-lab music-lab blog-lab realestate-lab agent-office personal frontend"
# 컨테이너 이름 (고아 정리용) # 컨테이너 이름 (고아 정리용)
CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office portfolio travel-proxy lotto-frontend" CONTAINER_NAMES="lotto-backend stock-lab music-lab blog-lab realestate-lab agent-office personal travel-proxy lotto-frontend"
# 헬스체크 대상 # 헬스체크 대상
HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office portfolio" HEALTH_ENDPOINTS="backend stock-lab travel-proxy music-lab blog-lab realestate-lab agent-office personal"
# data 디렉토리 # data 디렉토리
DATA_DIRS="music stock blog realestate agent-office portfolio" DATA_DIRS="music stock blog realestate agent-office personal"
# 1. 자동 감지: Docker 컨테이너 내부인가? # 1. 자동 감지: Docker 컨테이너 내부인가?
if [ -d "/repo" ] && [ -d "/runtime" ]; then if [ -d "/repo" ] && [ -d "/runtime" ]; then