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. 프로젝트 개요
|
||||
|
||||
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에 배포
|
||||
- **인프라**: Docker Compose (9컨테이너) + Nginx(리버스 프록시) + Gitea Webhook 자동 배포
|
||||
|
||||
@@ -59,7 +59,7 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| `blog-lab` | 18700 | 블로그 마케팅 수익화 API |
|
||||
| `realestate-lab` | 18800 | 부동산 청약 자동 수집·매칭 API |
|
||||
| `agent-office` | 18900 | AI 에이전트 오피스 (실시간 WebSocket + 텔레그램 연동) |
|
||||
| `portfolio` | 18850 | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개 관리) |
|
||||
| `personal` | 18850 | 개인 서비스 (포트폴리오·블로그·투두 통합) |
|
||||
| `travel-proxy` | 19000 | 여행 사진 API + 썸네일 생성 |
|
||||
| `lotto-frontend` (nginx) | 8080 | 정적 SPA 서빙 + API 리버스 프록시 |
|
||||
| `webpage-deployer` | 19010 | Gitea Webhook 수신 → 자동 배포 |
|
||||
@@ -78,7 +78,9 @@ Synology NAS 기반의 개인 웹 플랫폼 백엔드 모노레포.
|
||||
| `/api/music/` | `music-lab:8000` | AI 음악 생성·라이브러리 API |
|
||||
| `/api/blog-marketing/` | `blog-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 |
|
||||
| `/webhook`, `/webhook/` | `deployer:9000` | Gitea Webhook |
|
||||
| `/media/music/` | `/data/music/` (파일 직접 서빙) | 생성된 오디오 파일 |
|
||||
@@ -156,8 +158,8 @@ docker compose up -d
|
||||
| `strategy_weights` | 메타 전략 가중치 (EMA + Softmax) |
|
||||
| `weekly_reports` | 주간 공략 리포트 캐시 |
|
||||
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
|
||||
| `todos` | 투두리스트 (UUID PK) |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) |
|
||||
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
|
||||
|
||||
**스케줄러 job**
|
||||
- 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) |
|
||||
| PATCH | `/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/context` | 주간 맥락(핫/콜드·직전 회차) |
|
||||
| GET | `/api/lotto/curator/usage` | 큐레이터 토큰·비용 집계 |
|
||||
@@ -494,16 +487,16 @@ docker compose up -d
|
||||
| GET | `/api/agent-office/states` | 전체 에이전트 상태 조회 |
|
||||
| GET | `/api/agent-office/conversation/stats` | 텔레그램 자연어 대화 토큰·캐시 통계 (`days` 필터) |
|
||||
|
||||
### portfolio (portfolio/)
|
||||
- 개인 포트폴리오 서비스 (프로필, 경력, 프로젝트, 기술스택, 자기소개 관리)
|
||||
- DB: `/app/data/portfolio.db` (profile, careers, projects, skills, introductions 테이블)
|
||||
### personal (personal/)
|
||||
- 개인 서비스 (포트폴리오 + 블로그 + 투두 통합)
|
||||
- DB: `/app/data/personal.db` (profile, careers, projects, skills, introductions, todos, blog_posts 테이블)
|
||||
- 편집 인증: `PORTFOLIO_EDIT_PASSWORD` 환경변수, Bearer 토큰 (24시간 TTL)
|
||||
- 파일 구조: `main.py`, `db.py`, `models.py`, `auth.py`
|
||||
|
||||
**환경변수**
|
||||
- `PORTFOLIO_EDIT_PASSWORD`: 편집 모드 비밀번호 (미설정 시 편집 불가)
|
||||
|
||||
**portfolio API 목록**
|
||||
**personal API 목록**
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
@@ -528,6 +521,15 @@ docker compose up -d
|
||||
| PUT | `/api/profile/introductions/{id}` | 자기소개 수정 (인증) |
|
||||
| DELETE | `/api/profile/introductions/{id}` | 자기소개 삭제 (인증) |
|
||||
| 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/)
|
||||
- Webhook 검증: `X-Gitea-Signature` (HMAC SHA256, `compare_digest` 사용)
|
||||
@@ -540,7 +542,7 @@ docker compose up -d
|
||||
## 10. 주의사항
|
||||
|
||||
- **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를 환경변수로 주입
|
||||
- **캐시 전략**: `index.html`은 `no-store`, `assets/`는 1년 장기 캐시(immutable)
|
||||
- **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)")
|
||||
|
||||
|
||||
# ── 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:
|
||||
|
||||
@@ -15,10 +15,6 @@ from .db import (
|
||||
update_recommendation,
|
||||
# 시뮬레이션 관련
|
||||
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,
|
||||
# Phase 2: 구매 이력
|
||||
@@ -839,99 +835,3 @@ def version():
|
||||
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
|
||||
retries: 3
|
||||
|
||||
portfolio:
|
||||
personal:
|
||||
build:
|
||||
context: ./portfolio
|
||||
container_name: portfolio
|
||||
context: ./personal
|
||||
container_name: personal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18850:8000"
|
||||
@@ -161,7 +161,7 @@ services:
|
||||
- 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
|
||||
- ${RUNTIME_PATH:-.}/data/personal:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
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;
|
||||
}
|
||||
|
||||
# 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
|
||||
location /api/travel/ {
|
||||
proxy_http_version 1.1;
|
||||
@@ -140,17 +166,17 @@ server {
|
||||
}
|
||||
|
||||
|
||||
# profile API (Portfolio Service)
|
||||
# profile API (Personal Service)
|
||||
location /api/profile/ {
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $portfolio_backend portfolio:8000;
|
||||
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://$portfolio_backend$request_uri;
|
||||
proxy_pass http://$personal_backend$request_uri;
|
||||
}
|
||||
|
||||
# agent-office API + WebSocket
|
||||
|
||||
@@ -3,9 +3,9 @@ import json
|
||||
import logging
|
||||
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():
|
||||
@@ -103,7 +103,37 @@ def init_db():
|
||||
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 ──
|
||||
@@ -331,6 +361,108 @@ def set_main_introduction(intro_id: int) -> Optional[Dict[str, Any]]:
|
||||
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 (일괄 조회) ──
|
||||
|
||||
def get_public_data() -> Dict[str, Any]:
|
||||
@@ -12,22 +12,25 @@ from .db import (
|
||||
get_skills, create_skill, update_skill, delete_skill,
|
||||
get_introductions, create_introduction, update_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 (
|
||||
ProfileUpdate, CareerCreate, CareerUpdate,
|
||||
ProjectCreate, ProjectUpdate, SkillCreate, SkillUpdate,
|
||||
IntroCreate, IntroUpdate, AuthRequest,
|
||||
TodoCreate, TodoUpdate, BlogPostCreate, BlogPostUpdate,
|
||||
)
|
||||
from .auth import authenticate, require_auth
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("portfolio")
|
||||
logger = logging.getLogger("personal")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
logger.info("portfolio service 시작")
|
||||
logger.info("personal service 시작")
|
||||
yield
|
||||
|
||||
|
||||
@@ -188,3 +191,72 @@ def api_intro_set_main(intro_id: int):
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Introduction not found")
|
||||
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):
|
||||
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
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
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 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
@@ -7,13 +7,13 @@ flock -n 200 || { echo "Deploy already running, skipping"; exit 0; }
|
||||
|
||||
# ── 서비스 목록 (한 곳에서만 관리) ──
|
||||
# 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_DIRS="music stock blog realestate agent-office portfolio"
|
||||
DATA_DIRS="music stock blog realestate agent-office personal"
|
||||
|
||||
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||
|
||||
Reference in New Issue
Block a user