PostgreSQL 데이터 계층 추가
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=
|
DATABASE_URL=postgres://sori_studio:change-this-password@sori-studio-db:5432/sori_studio
|
||||||
DATABASE_NAME=
|
DATABASE_NAME=sori_studio
|
||||||
|
POSTGRES_DB=sori_studio
|
||||||
|
POSTGRES_USER=sori_studio
|
||||||
|
POSTGRES_PASSWORD=change-this-password
|
||||||
|
DB_PORT=43119
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
ADMIN_EMAIL=
|
ADMIN_EMAIL=
|
||||||
|
|||||||
47
db/migrations/001_initial_schema.sql
Normal file
47
db/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
excerpt TEXT NOT NULL DEFAULT '',
|
||||||
|
featured_image TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT posts_status_check CHECK (status IN ('published', 'draft', 'private'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS posts_status_published_at_idx
|
||||||
|
ON posts (status, published_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
featured_image TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS post_tags (
|
||||||
|
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx
|
||||||
|
ON post_tags (tag_id);
|
||||||
65
db/migrations/002_seed_development.sql
Normal file
65
db/migrations/002_seed_development.sql
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
INSERT INTO tags (id, name, slug, description)
|
||||||
|
VALUES
|
||||||
|
('44444444-4444-4444-8444-444444444444', 'NOTE', 'note', '생각과 기록을 모아두는 태그입니다.'),
|
||||||
|
('55555555-5555-4555-8555-555555555555', 'DEV', 'dev', '개발과 제작 과정을 기록하는 태그입니다.')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO posts (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
status,
|
||||||
|
published_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
'11111111-1111-4111-8111-111111111111',
|
||||||
|
'sori.studio를 직접 만들기 시작하며',
|
||||||
|
'hello-sori-studio',
|
||||||
|
'개인 블로그와 포털 역할을 한 공간에 담기 위한 첫 글입니다.',
|
||||||
|
'블로그와 포털의 경계에 있는 개인 공간을 직접 구축하기 위한 첫 기록입니다.',
|
||||||
|
'published',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'22222222-2222-4222-8222-222222222222',
|
||||||
|
'글쓰기 도구는 왜 직접 만들게 되는가',
|
||||||
|
'custom-writing-tool',
|
||||||
|
'기존 도구를 거치며 남은 취향의 빈칸을 직접 채우는 과정입니다.',
|
||||||
|
'네이버 블로그, 티스토리, 워드프레스, Ghost를 거쳐 남은 취향의 빈칸을 정리합니다.',
|
||||||
|
'published',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO post_tags (post_id, tag_id)
|
||||||
|
VALUES
|
||||||
|
('11111111-1111-4111-8111-111111111111', '44444444-4444-4444-8444-444444444444'),
|
||||||
|
('22222222-2222-4222-8222-222222222222', '55555555-5555-4555-8555-555555555555')
|
||||||
|
ON CONFLICT (post_id, tag_id) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO pages (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
content,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'33333333-3333-4333-8333-333333333333',
|
||||||
|
'About',
|
||||||
|
'about',
|
||||||
|
'sori.studio 소개 페이지입니다.',
|
||||||
|
'2026-04-29T00:00:00.000Z',
|
||||||
|
'2026-04-29T00:00:00.000Z'
|
||||||
|
)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
@@ -10,4 +10,25 @@ services:
|
|||||||
- "${APP_PORT:-43118}:3000"
|
- "${APP_PORT:-43118}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
|
depends_on:
|
||||||
|
- sori-studio-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
sori-studio-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: sori-studio-db
|
||||||
|
env_file:
|
||||||
|
- .env.production
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-43119}:5432"
|
||||||
|
volumes:
|
||||||
|
- sori-studio-postgres:/var/lib/postgresql/data
|
||||||
|
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
sori-studio-postgres:
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v0.0.5
|
||||||
|
|
||||||
|
- PostgreSQL 초기 스키마와 개발용 시드 데이터를 추가.
|
||||||
|
- Nuxt 서버 API에 DB 저장소 계층을 추가.
|
||||||
|
- DB 연결이 없을 때는 샘플 데이터로 동작하도록 fallback 구조를 추가.
|
||||||
|
- Docker Compose에 PostgreSQL 서비스를 추가.
|
||||||
|
|
||||||
## v0.0.4
|
## v0.0.4
|
||||||
|
|
||||||
- 헤더 좌측 아이콘을 사이드바 메뉴 토글 버튼으로 수정.
|
- 헤더 좌측 아이콘을 사이드바 메뉴 토글 버튼으로 수정.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ npm install
|
|||||||
# 개발 환경 변수 설정
|
# 개발 환경 변수 설정
|
||||||
cp .env.example .env.development
|
cp .env.example .env.development
|
||||||
# .env.development 파일에 개발 DB 연결 정보 입력
|
# .env.development 파일에 개발 DB 연결 정보 입력
|
||||||
|
# 로컬 DB 컨테이너를 호스트에서 접근할 때는 127.0.0.1:43119 사용
|
||||||
|
|
||||||
# 개발 서버 실행 (127.0.0.1:43117)
|
# 개발 서버 실행 (127.0.0.1:43117)
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -71,9 +72,10 @@ cd sori.studio
|
|||||||
# 운영 환경 변수 설정
|
# 운영 환경 변수 설정
|
||||||
cp .env.example .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보와 APP_PORT=43118 입력
|
# .env.production 파일에 운영 DB 연결 정보와 APP_PORT=43118 입력
|
||||||
|
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
||||||
|
|
||||||
# Docker 빌드 및 실행
|
# Docker 빌드 및 실행
|
||||||
docker-compose up -d
|
docker compose --env-file .env.production up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 프로덕션 빌드 (NAS에서)
|
### 프로덕션 빌드 (NAS에서)
|
||||||
@@ -92,6 +94,7 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
- 로컬 개발: 43117
|
- 로컬 개발: 43117
|
||||||
- NAS Docker 외부: 43118
|
- NAS Docker 외부: 43118
|
||||||
- 컨테이너 내부: 3000
|
- 컨테이너 내부: 3000
|
||||||
|
- PostgreSQL 외부: 43119
|
||||||
- HTTPS: 3001 (SSL 설정 시)
|
- HTTPS: 3001 (SSL 설정 시)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -100,9 +103,12 @@ docker run -d -p 3000:3000 sori.studio:latest
|
|||||||
|
|
||||||
- 로컬 개발: `.env.development`의 `DATABASE_URL`
|
- 로컬 개발: `.env.development`의 `DATABASE_URL`
|
||||||
- NAS 운영: `.env.production`의 `DATABASE_URL`
|
- NAS 운영: `.env.production`의 `DATABASE_URL`
|
||||||
|
- 로컬 개발 예시: `postgres://sori_studio:비밀번호@127.0.0.1:43119/sori_studio`
|
||||||
|
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||||
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
|
||||||
|
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
|
||||||
|
|
||||||
## 사용자 액션 필요 항목
|
## 사용자 액션 필요 항목
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-29 v0.0.5
|
||||||
|
|
||||||
|
### PostgreSQL 기반 데이터 계층 결정
|
||||||
|
|
||||||
|
DB 관리 도구로 CloudBeaver를 고려하고 NAS Docker 배포를 전제로 하기 때문에 초기 데이터베이스는 PostgreSQL로 잡는다. SQLite보다 운영/개발 분리, 외부 관리 도구 연결, 향후 확장에 유리하다.
|
||||||
|
|
||||||
|
Nuxt 서버 API는 바로 DB에 강결합하지 않고 `server/repositories`를 통해 콘텐츠를 조회한다. `DATABASE_URL`이 설정된 환경에서는 PostgreSQL을 사용하고, 설정되지 않은 환경에서는 샘플 데이터를 사용해 화면과 API 개발을 계속할 수 있게 했다.
|
||||||
|
|
||||||
|
Docker Compose에는 앱과 PostgreSQL 서비스를 함께 두되, 실제 운영 비밀번호와 연결 문자열은 `.env.production`에서 관리한다. DB 외부 포트는 기존 사용 포트와 겹치지 않도록 `43119`를 사용한다.
|
||||||
|
|
||||||
## 2026-04-29 v0.0.4
|
## 2026-04-29 v0.0.4
|
||||||
|
|
||||||
### 메뉴 토글 구현 방식 결정
|
### 메뉴 토글 구현 방식 결정
|
||||||
|
|||||||
@@ -73,6 +73,15 @@
|
|||||||
| server/api/tags.get.js | 태그 목록 샘플 API |
|
| server/api/tags.get.js | 태그 목록 샘플 API |
|
||||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
|
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||||
|
|
||||||
|
## 데이터베이스
|
||||||
|
|
||||||
|
| 파일 | 기능 |
|
||||||
|
|------|------|
|
||||||
|
| db/migrations/001_initial_schema.sql | PostgreSQL 초기 테이블 스키마 |
|
||||||
|
| db/migrations/002_seed_development.sql | 개발용 샘플 데이터 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
21
docs/spec.md
21
docs/spec.md
@@ -99,6 +99,7 @@ components/content/
|
|||||||
|
|
||||||
### 환경 분리 원칙
|
### 환경 분리 원칙
|
||||||
|
|
||||||
|
- 데이터베이스는 PostgreSQL을 기준으로 한다.
|
||||||
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
- 로컬 개발 환경과 NAS 운영 환경은 서로 다른 DB를 사용
|
||||||
- 로컬 개발 서버는 개발 DB만 연결
|
- 로컬 개발 서버는 개발 DB만 연결
|
||||||
- NAS 배포 환경은 운영 DB만 연결
|
- NAS 배포 환경은 운영 DB만 연결
|
||||||
@@ -114,7 +115,7 @@ components/content/
|
|||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| content | Text | 마크다운 콘텐츠 |
|
| content | Text | 마크다운 콘텐츠 |
|
||||||
| excerpt | String | 요약 |
|
| excerpt | String | 요약 |
|
||||||
| featured_image | String | 대표 이미지 |
|
| featured_image | String nullable | 대표 이미지 |
|
||||||
| status | Enum | published/draft/private |
|
| status | Enum | published/draft/private |
|
||||||
| published_at | DateTime | 발행일 |
|
| published_at | DateTime | 발행일 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
@@ -128,7 +129,7 @@ components/content/
|
|||||||
| title | String | 제목 |
|
| title | String | 제목 |
|
||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| content | Text | 마크다운 콘텐츠 |
|
| content | Text | 마크다운 콘텐츠 |
|
||||||
| featured_image | String | 대표 이미지 |
|
| featured_image | String nullable | 대표 이미지 |
|
||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_at | DateTime | 수정일 |
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
@@ -140,6 +141,8 @@ components/content/
|
|||||||
| name | String | 태그명 |
|
| name | String | 태그명 |
|
||||||
| slug | String | URL 슬러그 |
|
| slug | String | URL 슬러그 |
|
||||||
| description | String | 설명 |
|
| description | String | 설명 |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
| updated_at | DateTime | 수정일 |
|
||||||
|
|
||||||
### PostTags (다대다)
|
### PostTags (다대다)
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@ components/content/
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| post_id | UUID | FK → Posts |
|
| post_id | UUID | FK → Posts |
|
||||||
| tag_id | UUID | FK → Tags |
|
| tag_id | UUID | FK → Tags |
|
||||||
|
| created_at | DateTime | 생성일 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -159,6 +163,8 @@ components/content/
|
|||||||
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
- 별도 `backend/` 앱을 두지 않고 Nuxt/Nitro 서버 기능을 사용
|
||||||
- 공개 API는 `server/api`에 작성
|
- 공개 API는 `server/api`에 작성
|
||||||
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
- 서버 공통 스키마와 샘플 데이터는 `server/utils`에 작성
|
||||||
|
- PostgreSQL 연결과 조회 로직은 `server/repositories`에 작성
|
||||||
|
- `DATABASE_URL`이 없으면 샘플 데이터 저장소를 사용
|
||||||
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
- 초기 단계에서는 같은 앱 배포로 관리 비용을 낮춤
|
||||||
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
- 독립 API 서버가 필요해지는 시점에만 백엔드 분리를 재검토
|
||||||
|
|
||||||
@@ -203,8 +209,12 @@ components/content/
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=
|
DATABASE_URL=postgres://sori_studio:change-this-password@sori-studio-db:5432/sori_studio
|
||||||
DATABASE_NAME=
|
DATABASE_NAME=sori_studio
|
||||||
|
POSTGRES_DB=sori_studio
|
||||||
|
POSTGRES_USER=sori_studio
|
||||||
|
POSTGRES_PASSWORD=change-this-password
|
||||||
|
DB_PORT=43119
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
ADMIN_EMAIL=
|
ADMIN_EMAIL=
|
||||||
@@ -237,11 +247,12 @@ APP_PORT=43118
|
|||||||
| 로컬 개발 서버 | 43117 |
|
| 로컬 개발 서버 | 43117 |
|
||||||
| NAS Docker 외부 포트 | 43118 |
|
| NAS Docker 외부 포트 | 43118 |
|
||||||
| 컨테이너 내부 포트 | 3000 |
|
| 컨테이너 내부 포트 | 3000 |
|
||||||
|
| PostgreSQL 외부 포트 | 43119 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 버전 관리
|
## 버전 관리
|
||||||
|
|
||||||
- 현재 버전: v0.0.4
|
- 현재 버전: v0.0.5
|
||||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||||
|
|||||||
12
docs/todo.md
12
docs/todo.md
@@ -51,17 +51,15 @@
|
|||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
- [ ] Posts 테이블 설계
|
- [ ] PostgreSQL 마이그레이션 실행 스크립트 작성
|
||||||
- [ ] Pages 테이블 설계
|
- [ ] 로컬 개발 DB 컨테이너 실행 가이드 작성
|
||||||
- [ ] Tags 테이블 설계
|
- [ ] NAS 운영 DB 연결 설정 실제 값 작성
|
||||||
- [ ] PostTags 테이블 설계
|
|
||||||
- [ ] 로컬 개발 DB 연결 설정 작성
|
|
||||||
- [ ] NAS 운영 DB 연결 설정 작성
|
|
||||||
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
- [ ] 개발 DB와 운영 DB 분리 검증 절차 작성
|
||||||
- [ ] CloudBeaver 등 DB 관리 도구 연결 방식 결정
|
- [ ] CloudBeaver PostgreSQL 연결 방식 확정
|
||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
- [ ] UGREEN NAS Docker 배포 가이드 작성
|
||||||
- [ ] Docker 빌드 검증
|
- [ ] Docker 빌드 검증
|
||||||
|
- [ ] `.env.production` 작성 후 `docker compose --env-file .env.production config` 검증
|
||||||
- [ ] NAS 운영 환경 변수 작성
|
- [ ] NAS 운영 환경 변수 작성
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.5
|
||||||
|
|
||||||
|
- PostgreSQL 초기 스키마 마이그레이션 추가.
|
||||||
|
- 개발용 시드 데이터 SQL 추가.
|
||||||
|
- Nuxt 서버 API 저장소 계층 추가.
|
||||||
|
- `DATABASE_URL`이 있으면 PostgreSQL을 사용하고, 없으면 샘플 데이터를 사용하도록 수정.
|
||||||
|
- Docker Compose에 PostgreSQL 서비스와 전용 DB 포트 43119 추가.
|
||||||
|
- 공개 API가 저장소 계층을 통해 게시물, 페이지, 태그를 조회하도록 수정.
|
||||||
|
|
||||||
## v0.0.4
|
## v0.0.4
|
||||||
|
|
||||||
- 헤더 좌측 아이콘을 브랜드 마크에서 메뉴 토글 버튼으로 수정.
|
- 헤더 좌측 아이콘을 브랜드 마크에서 메뉴 토글 버튼으로 수정.
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"nuxt": "^3.16.2",
|
"nuxt": "^3.16.2",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
@@ -8844,6 +8844,19 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres": {
|
||||||
|
"version": "3.4.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||||
|
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/porsager"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.4",
|
"version": "0.0.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"nuxt": "^3.16.2",
|
"nuxt": "^3.16.2",
|
||||||
|
"postgres": "^3.4.9",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSamplePages } from '../utils/sample-content'
|
import { listPages } from '../repositories/content-repository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 고정 페이지 목록 API
|
* 공개 고정 페이지 목록 API
|
||||||
* @returns {Array} 고정 페이지 목록
|
* @returns {Array} 고정 페이지 목록
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => getSamplePages())
|
export default defineEventHandler(() => listPages())
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { getSamplePageBySlug } from '../../utils/sample-content'
|
import { getPageBySlug } from '../../repositories/content-repository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 고정 페이지 상세 API
|
* 공개 고정 페이지 상세 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Object} 고정 페이지 상세
|
* @returns {Object} 고정 페이지 상세
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const slug = getRouterParam(event, 'slug')
|
const slug = getRouterParam(event, 'slug')
|
||||||
const page = getSamplePageBySlug(slug)
|
const page = await getPageBySlug(slug)
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSamplePosts } from '../utils/sample-content'
|
import { listPosts } from '../repositories/content-repository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 게시물 목록 API
|
* 공개 게시물 목록 API
|
||||||
* @returns {Array} 게시물 목록
|
* @returns {Array} 게시물 목록
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => getSamplePosts())
|
export default defineEventHandler(() => listPosts())
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { getSamplePostBySlug } from '../../utils/sample-content'
|
import { getPostBySlug } from '../../repositories/content-repository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 게시물 상세 API
|
* 공개 게시물 상세 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
* @returns {Object} 게시물 상세
|
* @returns {Object} 게시물 상세
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const slug = getRouterParam(event, 'slug')
|
const slug = getRouterParam(event, 'slug')
|
||||||
const post = getSamplePostBySlug(slug)
|
const post = await getPostBySlug(slug)
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getSampleTags } from '../utils/sample-content'
|
import { listTags } from '../repositories/content-repository'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 태그 목록 API
|
* 공개 태그 목록 API
|
||||||
* @returns {Array} 태그 목록
|
* @returns {Array} 태그 목록
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler(() => getSampleTags())
|
export default defineEventHandler(() => listTags())
|
||||||
|
|||||||
170
server/repositories/content-repository.js
Normal file
170
server/repositories/content-repository.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
getSamplePageBySlug,
|
||||||
|
getSamplePages,
|
||||||
|
getSamplePostBySlug,
|
||||||
|
getSamplePosts,
|
||||||
|
getSampleTags
|
||||||
|
} from '../utils/sample-content'
|
||||||
|
import { getPostgresClient } from './postgres-client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 행을 API 응답 구조로 변환
|
||||||
|
* @param {Object} row - 게시물 행
|
||||||
|
* @returns {Object} 게시물 응답
|
||||||
|
*/
|
||||||
|
const mapPostRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
slug: row.slug,
|
||||||
|
content: row.content,
|
||||||
|
excerpt: row.excerpt,
|
||||||
|
featuredImage: row.featured_image,
|
||||||
|
status: row.status,
|
||||||
|
publishedAt: row.published_at ? row.published_at.toISOString() : null,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString(),
|
||||||
|
tags: row.tags || []
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 고정 페이지 행을 API 응답 구조로 변환
|
||||||
|
* @param {Object} row - 고정 페이지 행
|
||||||
|
* @returns {Object} 고정 페이지 응답
|
||||||
|
*/
|
||||||
|
const mapPageRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
slug: row.slug,
|
||||||
|
content: row.content,
|
||||||
|
featuredImage: row.featured_image,
|
||||||
|
createdAt: row.created_at.toISOString(),
|
||||||
|
updatedAt: row.updated_at.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그 행을 API 응답 구조로 변환
|
||||||
|
* @param {Object} row - 태그 행
|
||||||
|
* @returns {Object} 태그 응답
|
||||||
|
*/
|
||||||
|
const mapTagRow = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
description: row.description
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시물 목록 조회
|
||||||
|
* @returns {Promise<Array>} 게시물 목록
|
||||||
|
*/
|
||||||
|
export const listPosts = async () => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return getSamplePosts()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
posts.*,
|
||||||
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||||
|
FROM posts
|
||||||
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||||
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||||
|
WHERE posts.status = 'published'
|
||||||
|
GROUP BY posts.id
|
||||||
|
ORDER BY posts.published_at DESC NULLS LAST, posts.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map(mapPostRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 게시물 상세 조회
|
||||||
|
* @param {string} slug - 게시물 슬러그
|
||||||
|
* @returns {Promise<Object | null>} 게시물 상세
|
||||||
|
*/
|
||||||
|
export const getPostBySlug = async (slug) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return getSamplePostBySlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT
|
||||||
|
posts.*,
|
||||||
|
COALESCE(array_agg(tags.slug) FILTER (WHERE tags.slug IS NOT NULL), '{}') AS tags
|
||||||
|
FROM posts
|
||||||
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||||
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||||
|
WHERE posts.slug = ${slug}
|
||||||
|
AND posts.status = 'published'
|
||||||
|
GROUP BY posts.id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows[0] ? mapPostRow(rows[0]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 고정 페이지 목록 조회
|
||||||
|
* @returns {Promise<Array>} 고정 페이지 목록
|
||||||
|
*/
|
||||||
|
export const listPages = async () => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return getSamplePages()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT *
|
||||||
|
FROM pages
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map(mapPageRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 고정 페이지 상세 조회
|
||||||
|
* @param {string} slug - 페이지 슬러그
|
||||||
|
* @returns {Promise<Object | null>} 고정 페이지 상세
|
||||||
|
*/
|
||||||
|
export const getPageBySlug = async (slug) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return getSamplePageBySlug(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT *
|
||||||
|
FROM pages
|
||||||
|
WHERE slug = ${slug}
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows[0] ? mapPageRow(rows[0]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 태그 목록 조회
|
||||||
|
* @returns {Promise<Array>} 태그 목록
|
||||||
|
*/
|
||||||
|
export const listTags = async () => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return getSampleTags()
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT *
|
||||||
|
FROM tags
|
||||||
|
ORDER BY name ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
return rows.map(mapTagRow)
|
||||||
|
}
|
||||||
25
server/repositories/postgres-client.js
Normal file
25
server/repositories/postgres-client.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import postgres from 'postgres'
|
||||||
|
|
||||||
|
let client = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 클라이언트 조회
|
||||||
|
* @returns {ReturnType<typeof postgres> | null} PostgreSQL 클라이언트
|
||||||
|
*/
|
||||||
|
export const getPostgresClient = () => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
if (!config.databaseUrl) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
client = postgres(config.databaseUrl, {
|
||||||
|
max: 5,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user