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:
40
CLAUDE.md
40
CLAUDE.md
@@ -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에 수동 업로드
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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초)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user