게시물 Export 기간 선택과 삭제 추가 v1.5.23
This commit is contained in:
8
db/migrations/042_post_export_date_range.sql
Normal file
8
db/migrations/042_post_export_date_range.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE post_export_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS date_from TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE post_export_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS date_to TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE post_export_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS range_label TEXT NOT NULL DEFAULT '전체';
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.23
|
||||||
|
|
||||||
|
- 게시물 Export를 전체뿐 아니라 특정년, 특정월, 직접 지정 날짜 범위로 요청할 수 있게 개선했다.
|
||||||
|
- 완료되었거나 실패한 Export 백업 파일을 관리자 화면에서 바로 삭제할 수 있게 정리했다.
|
||||||
|
|
||||||
## v1.5.22
|
## v1.5.22
|
||||||
|
|
||||||
- 게시물 Export가 실제 분할 ZIP 파일을 생성하고, 준비 완료된 파일을 관리자 화면에서 내려받을 수 있도록 개선했다.
|
- 게시물 Export가 실제 분할 ZIP 파일을 생성하고, 준비 완료된 파일을 관리자 화면에서 내려받을 수 있도록 개선했다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.5.22에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.5.23에서 `npm run lint`, `npm run build`, `npm run db:migrate:dev` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
@@ -381,6 +381,7 @@ docker compose --env-file .env.production restart sori-studio-db
|
|||||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||||
- 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다.
|
- 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다.
|
||||||
|
- 완료·실패한 게시물 Export 작업을 관리자 화면에서 삭제하면 연결된 ZIP 파일도 함께 삭제된다.
|
||||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||||
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
- NAS 운영에서는 `docker-compose.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다.
|
||||||
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
- 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-06-01 v1.5.23 — Export는 필요한 기간만 꺼낼 수 있어야 한다
|
||||||
|
|
||||||
|
전체 백업은 최초 백업이나 큰 이관에는 필요하지만, 운영 중 반복 백업에서는 같은 게시물과 자산을 계속 다시 묶어 시간과 저장 공간을 낭비한다. Export 요청에 전체·특정년·특정월·직접 지정 날짜 범위를 추가해 필요한 기간만 백업할 수 있게 했다. 게시물 기준일은 발행일이 있으면 `published_at`, 없으면 `created_at`으로 판단한다. 생성 완료된 백업은 사용자가 바로 삭제할 수 있어야 서버 업로드 볼륨을 관리하기 쉽기 때문에, 완료·실패 작업 삭제 API도 함께 둔다.
|
||||||
|
|
||||||
## 2026-06-01 v1.5.22 — Export는 실제 파일 생성 단계로 연결
|
## 2026-06-01 v1.5.22 — Export는 실제 파일 생성 단계로 연결
|
||||||
|
|
||||||
Export 작업이 대기열과 진행도 표시까지만 존재하면 관리자는 같은 버튼을 반복해서 누르기 쉽고, 실제 백업 파일도 받을 수 없다. 이번 단계에서는 요청 직후 서버 프로세스가 대기 작업을 백그라운드로 실행해 분할 ZIP을 만들고, 준비 완료된 파일만 다운로드할 수 있게 했다. 내부 `/uploads` 자산은 ZIP 내부의 `images/` 또는 `files/` 폴더로 복사하고 Markdown 참조는 상대 경로로 바꿔, 원본 서버 URL이 사라져도 Obsidian에서 백업을 열 수 있는 형태를 우선했다. 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼은 잠가 중복 작업 생성을 막는다.
|
Export 작업이 대기열과 진행도 표시까지만 존재하면 관리자는 같은 버튼을 반복해서 누르기 쉽고, 실제 백업 파일도 받을 수 없다. 이번 단계에서는 요청 직후 서버 프로세스가 대기 작업을 백그라운드로 실행해 분할 ZIP을 만들고, 준비 완료된 파일만 다운로드할 수 있게 했다. 내부 `/uploads` 자산은 ZIP 내부의 `images/` 또는 `files/` 폴더로 복사하고 Markdown 참조는 상대 경로로 바꿔, 원본 서버 URL이 사라져도 Obsidian에서 백업을 열 수 있는 형태를 우선했다. 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼은 잠가 중복 작업 생성을 막는다.
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 타임존, 게시물 Import/Export 작업 요청·최근 작업·진행도·준비 완료 분할 파일 다운로드, 진행 중 요청 버튼 잠금 |
|
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), 타임존, 게시물 Import/Export 전체·연도·월·직접 날짜 범위 작업 요청·최근 작업·진행도·준비 완료 분할 파일 다운로드·작업 삭제, 진행 중 요청 버튼 잠금 |
|
||||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
@@ -205,6 +205,7 @@
|
|||||||
| server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API |
|
| server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API |
|
||||||
| server/routes/admin/api/posts/export-jobs.get.js | 관리자 게시물 Export 작업 목록 API |
|
| server/routes/admin/api/posts/export-jobs.get.js | 관리자 게시물 Export 작업 목록 API |
|
||||||
| server/routes/admin/api/posts/export-jobs.post.js | 관리자 게시물 Export 작업 요청 API |
|
| server/routes/admin/api/posts/export-jobs.post.js | 관리자 게시물 Export 작업 요청 API |
|
||||||
|
| server/routes/admin/api/posts/export-jobs/[jobId].delete.js | 관리자 게시물 Export 작업·생성 ZIP 삭제 API |
|
||||||
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
|
| server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API |
|
||||||
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||||
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||||
@@ -304,6 +305,7 @@
|
|||||||
| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 |
|
| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 |
|
||||||
| db/migrations/040_post_export_jobs.sql | 게시물 Export 작업·분할 파일 계획 테이블 추가 |
|
| db/migrations/040_post_export_jobs.sql | 게시물 Export 작업·분할 파일 계획 테이블 추가 |
|
||||||
| db/migrations/041_post_export_progress.sql | 게시물 Export 작업 진행도 컬럼 추가 |
|
| db/migrations/041_post_export_progress.sql | 게시물 Export 작업 진행도 컬럼 추가 |
|
||||||
|
| db/migrations/042_post_export_date_range.sql | 게시물 Export 날짜 범위 컬럼 추가 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
10
docs/spec.md
10
docs/spec.md
@@ -287,6 +287,9 @@ components/content/
|
|||||||
| post_export_jobs.current_part_index | Integer nullable | 현재 처리 중인 분할 순번 |
|
| post_export_jobs.current_part_index | Integer nullable | 현재 처리 중인 분할 순번 |
|
||||||
| post_export_jobs.chunk_size | Integer | 분할당 게시물 수 |
|
| post_export_jobs.chunk_size | Integer | 분할당 게시물 수 |
|
||||||
| post_export_jobs.retention_days | Integer | 보존 일수, 최대 100 |
|
| post_export_jobs.retention_days | Integer | 보존 일수, 최대 100 |
|
||||||
|
| post_export_jobs.date_from | DateTime nullable | Export 대상 게시물 기준일 시작 시각, null이면 시작 제한 없음 |
|
||||||
|
| post_export_jobs.date_to | DateTime nullable | Export 대상 게시물 기준일 종료 시각(exclusive), null이면 종료 제한 없음 |
|
||||||
|
| post_export_jobs.range_label | String | 관리자 화면과 파일명에 표시할 범위 라벨 |
|
||||||
| post_export_jobs.expires_at | DateTime | 만료 예정 시각 |
|
| post_export_jobs.expires_at | DateTime | 만료 예정 시각 |
|
||||||
| post_export_jobs.message | Text | 작업 메시지 |
|
| post_export_jobs.message | Text | 작업 메시지 |
|
||||||
| post_export_jobs.progress_message | Text | 진행 상세 메시지 |
|
| post_export_jobs.progress_message | Text | 진행 상세 메시지 |
|
||||||
@@ -513,7 +516,8 @@ components/content/
|
|||||||
- `PUT /admin/api/posts/:id` - 글 수정
|
- `PUT /admin/api/posts/:id` - 글 수정
|
||||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||||
- `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록
|
- `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록
|
||||||
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. 작업 레코드와 100개 단위 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다.
|
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위를 받아 작업 레코드와 100개 단위 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다.
|
||||||
|
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
|
||||||
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
|
||||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||||
@@ -689,9 +693,9 @@ components/content/
|
|||||||
|
|
||||||
### 사이트 설정
|
### 사이트 설정
|
||||||
|
|
||||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export는 Export 작업 요청, 최근 작업 목록, 진행도, 준비 완료 분할 파일 다운로드를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/Export는 전체·특정년·특정월·직접 지정 범위 Export 요청, 최근 작업 목록, 진행도, 준비 완료 분할 파일 다운로드, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 Export 요청 버튼을 비활성화한다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||||
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 폴더/zip을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 미디어 업로드 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다.
|
- 게시물 Import/Export 1차 포맷은 Obsidian 호환 백업 번들을 기준으로 한다. Export는 게시물마다 별도 폴더를 만들고, 폴더 안에 `제목.md` 메인 파일과 `images/`, `files/` 같은 자산 폴더를 함께 둔다. 본문은 기존 Markdown을 최대한 유지하되 `/uploads/...`로 연결된 내부 이미지·파일은 번들 안의 로컬 파일로 복사하고, Markdown 참조는 `./images/...` 또는 `./files/...` 같은 상대 경로로 재작성한다. 제목·슬러그·상태·발행일·요약·대표 이미지·SEO·태그는 YAML frontmatter에 저장한다. Import는 같은 구조의 폴더/zip을 읽어 frontmatter를 게시물 메타데이터로 복원하고, 로컬 자산은 미디어 업로드 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다.
|
||||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 모든 분할 파일 생성 완료 이메일 알림, 브라우저 일괄 순차 다운로드, 실패 지점 재다운로드, 만료 파일 정리는 후속 구현 대상이다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다.
|
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, 서버 프로세스 안에서 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 모든 분할 파일 생성 완료 이메일 알림, 브라우저 일괄 순차 다운로드, 실패 지점 재다운로드, 만료 파일 자동 정리는 후속 구현 대상이다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다.
|
||||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||||
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
- 관리자는 사이트 이름, 설명, 사이트 URL, 로고 이미지, 저작권 문구를 수정할 수 있다.
|
||||||
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
- **메인 화면**(`home_cover_image_url`, `home_cover_dark_image_url`, `home_cover_title`, `home_cover_text`): 홈(`/`) 상단 720px 커버 배너. 라이트 이미지는 기본 커버이며, 다크 이미지가 있으면 시스템 다크모드 또는 `html[data-theme='dark']`에서 다크 이미지를 표시한다. 다크 이미지가 없으면 라이트 이미지를 그대로 사용한다. 이미지가 있을 때만 `HomeHero`를 표시하며, 제목·짧은 본문은 이미지 왼쪽 하단 그라데이션 오버레이로 겹친다. 오버레이 본문은 textarea에서 입력한 줄바꿈(`\n`)을 저장·표시하며, `HomeHero` 본문은 `whitespace-pre-line`으로 여러 줄을 렌더링한다. 관리자 UI에서는 커버 파일 업로드·제목·본문을 편집한 뒤 **저장** 한 번에 `PUT /admin/api/settings`로 반영한다. 읽기·편집 미리보기는 실제 `HomeHero` 컴포넌트를 사용해 긴 본문도 공개 화면과 같은 오버레이 폭(`max-w-[32rem]`)과 줄바꿈으로 확인한다. 파일 업로드 API는 디스크에만 올리고 URL을 돌려준다(가로 720px WebP, `/uploads/system/home-cover-YYYYMM-random.webp`).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 1차 관리자 개발
|
## 1차 관리자 개발
|
||||||
|
|
||||||
- [ ] 게시물 Import 1차 구현: Export ZIP의 frontmatter를 게시물 메타데이터로 복원하고, 로컬 `images/`·`files/` 자산을 미디어 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 재매핑
|
- [ ] 게시물 Import 1차 구현: Export ZIP의 frontmatter를 게시물 메타데이터로 복원하고, 로컬 `images/`·`files/` 자산을 미디어 저장소로 가져온 뒤 본문 경로를 새 `/uploads/...` URL로 재매핑
|
||||||
- [ ] 게시물 Export 대용량 작업 후속 구현: 완료 이메일 알림, 일괄 순차 다운로드 UI, 실패 지점 재다운로드, 100일 만료 파일 정리, 산출 ZIP 용량 기준 분할 고도화
|
- [ ] 게시물 Export 대용량 작업 후속 구현: 완료 이메일 알림, 일괄 순차 다운로드 UI, 실패 지점 재다운로드, 100일 만료 파일 자동 정리, 산출 ZIP 용량 기준 분할 고도화
|
||||||
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.23
|
||||||
|
|
||||||
|
- 게시물 Export: 전체·특정년·특정월·직접 지정 날짜 범위 백업 요청 추가.
|
||||||
|
- 게시물 Export: 작업 레코드에 `date_from`, `date_to`, `range_label` 저장 추가.
|
||||||
|
- 게시물 Export: 날짜 범위 기준 게시물만 분할 ZIP 대상에 포함하도록 수정.
|
||||||
|
- 관리자 사이트 설정: Export 요청 카드에 범위 선택 UI 추가.
|
||||||
|
- 관리자 사이트 설정: 완료·실패한 Export 작업과 생성 ZIP 파일을 삭제할 수 있도록 추가.
|
||||||
|
|
||||||
## v1.5.22
|
## v1.5.22
|
||||||
|
|
||||||
- 게시물 Export: 대기 작업을 백그라운드에서 실제 ZIP 파일로 생성하는 서버 실행부 추가.
|
- 게시물 Export: 대기 작업을 백그라운드에서 실제 ZIP 파일로 생성하는 서버 실행부 추가.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.22",
|
"version": "1.5.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const uploadingLogo = ref(false)
|
|||||||
const uploadingHomeCover = ref(false)
|
const uploadingHomeCover = ref(false)
|
||||||
const uploadingHomeCoverDark = ref(false)
|
const uploadingHomeCoverDark = ref(false)
|
||||||
const requestingPostExport = ref(false)
|
const requestingPostExport = ref(false)
|
||||||
|
const deletingPostExportJobIds = ref([])
|
||||||
|
const postExportDateRangeMode = ref('all')
|
||||||
|
const postExportYear = ref(new Date().getFullYear())
|
||||||
|
const postExportMonth = ref(new Date().getMonth() + 1)
|
||||||
|
const postExportDateFrom = ref('')
|
||||||
|
const postExportDateTo = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const toast = ref(null)
|
const toast = ref(null)
|
||||||
const logoInputRef = ref(null)
|
const logoInputRef = ref(null)
|
||||||
@@ -180,11 +186,40 @@ const hasActivePostExportJobs = computed(() => normalizedPostExportJobs.value.so
|
|||||||
job.status === 'queued' || job.status === 'processing'
|
job.status === 'queued' || job.status === 'processing'
|
||||||
)))
|
)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 연도 선택지
|
||||||
|
* @returns {Array<number>} 연도 목록
|
||||||
|
*/
|
||||||
|
const postExportYearOptions = computed(() => {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
return Array.from({ length: 10 }, (_, index) => currentYear - index)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 월 선택지
|
||||||
|
* @type {ReadonlyArray<number>}
|
||||||
|
*/
|
||||||
|
const postExportMonthOptions = Array.from({ length: 12 }, (_, index) => index + 1)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 export 요청 버튼 활성 가능 여부
|
* 게시물 export 요청 버튼 활성 가능 여부
|
||||||
* @returns {boolean} 활성 가능 여부
|
* @returns {boolean} 활성 가능 여부
|
||||||
*/
|
*/
|
||||||
const canRequestPostExport = computed(() => !requestingPostExport.value && !hasActivePostExportJobs.value)
|
const canRequestPostExport = computed(() => {
|
||||||
|
if (requestingPostExport.value || hasActivePostExportJobs.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postExportDateRangeMode.value === 'custom') {
|
||||||
|
return Boolean(
|
||||||
|
postExportDateFrom.value
|
||||||
|
&& postExportDateTo.value
|
||||||
|
&& postExportDateFrom.value <= postExportDateTo.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 export 요청 버튼 안내 문구
|
* 게시물 export 요청 버튼 안내 문구
|
||||||
@@ -195,9 +230,50 @@ const postExportRequestTitle = computed(() => {
|
|||||||
return '진행 중인 Export 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
|
return '진행 중인 Export 작업이 끝난 뒤 새 요청을 만들 수 있습니다.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (postExportDateRangeMode.value === 'custom' && !canRequestPostExport.value) {
|
||||||
|
return '올바른 시작일과 종료일을 선택해 주세요.'
|
||||||
|
}
|
||||||
|
|
||||||
return '게시물 Export 작업을 요청합니다.'
|
return '게시물 Export 작업을 요청합니다.'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 요청 범위 입력을 만든다.
|
||||||
|
* @returns {Object} Export 범위 입력
|
||||||
|
*/
|
||||||
|
const createPostExportRequestBody = () => {
|
||||||
|
const base = {
|
||||||
|
chunkSize: 100,
|
||||||
|
retentionDays: 100,
|
||||||
|
dateRangeMode: postExportDateRangeMode.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postExportDateRangeMode.value === 'year') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
year: Number(postExportYear.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postExportDateRangeMode.value === 'month') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
year: Number(postExportYear.value),
|
||||||
|
month: Number(postExportMonth.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postExportDateRangeMode.value === 'custom') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
dateFrom: postExportDateFrom.value,
|
||||||
|
dateTo: postExportDateTo.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* export 상태 라벨 조회
|
* export 상태 라벨 조회
|
||||||
* @param {string} status - export 상태
|
* @param {string} status - export 상태
|
||||||
@@ -490,10 +566,7 @@ const requestPostExport = async () => {
|
|||||||
try {
|
try {
|
||||||
await $fetch('/admin/api/posts/export-jobs', {
|
await $fetch('/admin/api/posts/export-jobs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: createPostExportRequestBody()
|
||||||
chunkSize: 100,
|
|
||||||
retentionDays: 100
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
await refreshPostExportJobs()
|
await refreshPostExportJobs()
|
||||||
showToast('success', '게시물 Export 작업이 등록되었습니다.')
|
showToast('success', '게시물 Export 작업이 등록되었습니다.')
|
||||||
@@ -512,6 +585,40 @@ const requestPostExport = async () => {
|
|||||||
*/
|
*/
|
||||||
const getPostExportDownloadUrl = (file) => `/admin/api/posts/export-jobs/${file.id}/download`
|
const getPostExportDownloadUrl = (file) => `/admin/api/posts/export-jobs/${file.id}/download`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 작업 삭제 중 여부
|
||||||
|
* @param {string} jobId - 작업 ID
|
||||||
|
* @returns {boolean} 삭제 중 여부
|
||||||
|
*/
|
||||||
|
const isDeletingPostExportJob = (jobId) => deletingPostExportJobIds.value.includes(jobId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 작업을 삭제한다.
|
||||||
|
* @param {Object} job - Export 작업
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const deletePostExportJob = async (job) => {
|
||||||
|
if (!job?.id || isDeletingPostExportJob(job.id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingPostExportJobIds.value = [...deletingPostExportJobIds.value, job.id]
|
||||||
|
showToast('info', 'Export 백업 파일을 삭제하는 중입니다.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/admin/api/posts/export-jobs/${job.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
await refreshPostExportJobs()
|
||||||
|
showToast('success', 'Export 백업 파일을 삭제했습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error?.data?.message || 'Export 백업 파일을 삭제하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
|
} finally {
|
||||||
|
deletingPostExportJobIds.value = deletingPostExportJobIds.value.filter((id) => id !== job.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 로고 파일 선택창을 연다.
|
* 로고 파일 선택창을 연다.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -1826,24 +1933,100 @@ onBeforeUnmount(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-settings-screen__export-actions mt-5 flex flex-col gap-3 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4 md:flex-row md:items-center md:justify-between">
|
<div class="admin-settings-screen__export-actions mt-5 grid gap-4 rounded-lg border border-[#e6e8eb] bg-[#fbfcfd] p-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-semibold text-[#15171a]">
|
<p class="text-sm font-semibold text-[#15171a]">
|
||||||
Obsidian 호환 백업 준비
|
Obsidian 호환 백업 준비
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||||
100개 단위 분할 zip 계획을 만들고, 산출물은 최대 100일 동안 보관합니다.
|
전체·연도·월·직접 범위로 게시물을 골라 100개 단위 ZIP 백업을 만듭니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="grid gap-3">
|
||||||
class="admin-settings-screen__export-request inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
|
<div class="admin-settings-screen__export-range grid gap-2 md:grid-cols-4">
|
||||||
type="button"
|
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
|
||||||
:disabled="!canRequestPostExport"
|
범위
|
||||||
:title="postExportRequestTitle"
|
<select
|
||||||
@click="requestPostExport"
|
v-model="postExportDateRangeMode"
|
||||||
>
|
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? 'Export 진행 중' : 'Export 요청' }}
|
>
|
||||||
</button>
|
<option value="all">전체</option>
|
||||||
|
<option value="year">특정년</option>
|
||||||
|
<option value="month">특정월</option>
|
||||||
|
<option value="custom">직접 지정</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-if="postExportDateRangeMode === 'year' || postExportDateRangeMode === 'month'"
|
||||||
|
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||||
|
>
|
||||||
|
연도
|
||||||
|
<select
|
||||||
|
v-model.number="postExportYear"
|
||||||
|
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="year in postExportYearOptions"
|
||||||
|
:key="year"
|
||||||
|
:value="year"
|
||||||
|
>
|
||||||
|
{{ year }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-if="postExportDateRangeMode === 'month'"
|
||||||
|
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||||
|
>
|
||||||
|
월
|
||||||
|
<select
|
||||||
|
v-model.number="postExportMonth"
|
||||||
|
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="month in postExportMonthOptions"
|
||||||
|
:key="month"
|
||||||
|
:value="month"
|
||||||
|
>
|
||||||
|
{{ month }}월
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-if="postExportDateRangeMode === 'custom'"
|
||||||
|
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||||
|
>
|
||||||
|
시작일
|
||||||
|
<input
|
||||||
|
v-model="postExportDateFrom"
|
||||||
|
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
type="date"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
v-if="postExportDateRangeMode === 'custom'"
|
||||||
|
class="grid gap-1 text-xs font-semibold text-[#5d6673]"
|
||||||
|
>
|
||||||
|
종료일
|
||||||
|
<input
|
||||||
|
v-model="postExportDateTo"
|
||||||
|
class="h-10 rounded-md border border-[#dce0e5] bg-white px-3 text-sm font-semibold text-[#15171a] outline-none focus:border-[#15171a] focus:ring-1 focus:ring-[#15171a]"
|
||||||
|
type="date"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
class="admin-settings-screen__export-request inline-flex h-10 shrink-0 cursor-pointer items-center justify-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#c7cdd4]"
|
||||||
|
type="button"
|
||||||
|
:disabled="!canRequestPostExport"
|
||||||
|
:title="postExportRequestTitle"
|
||||||
|
@click="requestPostExport"
|
||||||
|
>
|
||||||
|
{{ requestingPostExport ? '요청 중...' : hasActivePostExportJobs ? 'Export 진행 중' : 'Export 요청' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-settings-screen__export-list mt-5">
|
<div class="admin-settings-screen__export-list mt-5">
|
||||||
@@ -1886,18 +2069,31 @@ onBeforeUnmount(() => {
|
|||||||
<span class="text-sm text-[#9aa3ad]">
|
<span class="text-sm text-[#9aa3ad]">
|
||||||
{{ job.files.length }}개 파일
|
{{ job.files.length }}개 파일
|
||||||
</span>
|
</span>
|
||||||
|
<span class="text-sm text-[#657080]">
|
||||||
|
{{ job.rangeLabel || '전체' }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-[#657080]">
|
<p class="mt-2 text-xs text-[#657080]">
|
||||||
요청: {{ formatPostDateTime(job.createdAt) }} · 만료: {{ formatPostDateTime(job.expiresAt) }}
|
요청: {{ formatPostDateTime(job.createdAt) }} · 만료: {{ formatPostDateTime(job.expiresAt) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
class="inline-flex h-9 shrink-0 cursor-not-allowed items-center justify-center rounded-md border border-[#dce0e5] px-3 text-xs font-semibold text-[#9aa3ad]"
|
<button
|
||||||
type="button"
|
class="inline-flex h-9 shrink-0 cursor-not-allowed items-center justify-center rounded-md border border-[#dce0e5] px-3 text-xs font-semibold text-[#9aa3ad]"
|
||||||
disabled
|
type="button"
|
||||||
>
|
disabled
|
||||||
일괄 다운로드 준비 중
|
>
|
||||||
</button>
|
일괄 다운로드 준비 중
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex h-9 shrink-0 cursor-pointer items-center justify-center rounded-md border border-[#ffd5d5] px-3 text-xs font-semibold text-[#d64545] transition hover:bg-[#fff3f3] disabled:cursor-not-allowed disabled:border-[#e1e5ea] disabled:text-[#a6b0bb]"
|
||||||
|
type="button"
|
||||||
|
:disabled="job.status === 'queued' || job.status === 'processing' || isDeletingPostExportJob(job.id)"
|
||||||
|
@click="deletePostExportJob(job)"
|
||||||
|
>
|
||||||
|
{{ isDeletingPostExportJob(job.id) ? '삭제 중' : '삭제' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-settings-screen__export-progress mt-4 rounded-lg border border-[#edf0f3] bg-[#fbfcfd] p-3">
|
<div class="admin-settings-screen__export-progress mt-4 rounded-lg border border-[#edf0f3] bg-[#fbfcfd] p-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { basename, dirname, extname, join, relative } from 'node:path'
|
import { basename, dirname, extname, join, relative } from 'node:path'
|
||||||
import { createZipBuffer } from '../utils/zip-writer'
|
import { createZipBuffer } from '../utils/zip-writer'
|
||||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||||
@@ -283,6 +283,9 @@ const mapPostExportJobRow = (row) => ({
|
|||||||
currentPartIndex: row.current_part_index ? Number(row.current_part_index) : null,
|
currentPartIndex: row.current_part_index ? Number(row.current_part_index) : null,
|
||||||
chunkSize: Number(row.chunk_size || DEFAULT_CHUNK_SIZE),
|
chunkSize: Number(row.chunk_size || DEFAULT_CHUNK_SIZE),
|
||||||
retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS),
|
retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS),
|
||||||
|
dateFrom: row.date_from ? row.date_from.toISOString() : null,
|
||||||
|
dateTo: row.date_to ? row.date_to.toISOString() : null,
|
||||||
|
rangeLabel: row.range_label || '전체',
|
||||||
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
|
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
|
||||||
message: row.message || '',
|
message: row.message || '',
|
||||||
progressMessage: row.progress_message || '',
|
progressMessage: row.progress_message || '',
|
||||||
@@ -343,6 +346,107 @@ const normalizeRetentionDays = (value) => {
|
|||||||
return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS)
|
return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 입력을 안전하게 보정한다.
|
||||||
|
* @param {unknown} value - 날짜 입력
|
||||||
|
* @returns {Date|null} 보정된 날짜
|
||||||
|
*/
|
||||||
|
const normalizeDateInput = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(String(value))
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 라벨을 만든다.
|
||||||
|
* @param {Date|null} dateFrom - 시작일
|
||||||
|
* @param {Date|null} dateTo - 종료일
|
||||||
|
* @returns {string} 범위 라벨
|
||||||
|
*/
|
||||||
|
const createRangeLabel = (dateFrom, dateTo) => {
|
||||||
|
const formatDate = (date) => date.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
if (!dateFrom && !dateTo) {
|
||||||
|
return '전체'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom && dateTo) {
|
||||||
|
return `${formatDate(dateFrom)} - ${formatDate(new Date(dateTo.getTime() - 1))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFrom) {
|
||||||
|
return `${formatDate(dateFrom)} 이후`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDate(new Date(dateTo.getTime() - 1))} 이전`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 날짜 범위를 보정한다.
|
||||||
|
* @param {Object} input - 작업 요청 입력
|
||||||
|
* @returns {{ dateFrom: Date|null, dateTo: Date|null, rangeLabel: string }} 날짜 범위
|
||||||
|
*/
|
||||||
|
const normalizeExportDateRange = (input) => {
|
||||||
|
const dateFrom = normalizeDateInput(input.dateFrom)
|
||||||
|
const dateTo = normalizeDateInput(input.dateTo)
|
||||||
|
|
||||||
|
if (dateFrom && dateTo && dateFrom >= dateTo) {
|
||||||
|
throw new Error('INVALID_EXPORT_DATE_RANGE')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
rangeLabel: input.rangeLabel || createRangeLabel(dateFrom, dateTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 날짜 범위 조건을 만든다.
|
||||||
|
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||||
|
* @param {Object} range - 날짜 범위
|
||||||
|
* @returns {unknown} SQL 조건
|
||||||
|
*/
|
||||||
|
const createPostExportDateCondition = (sql, range) => {
|
||||||
|
if (range.dateFrom && range.dateTo) {
|
||||||
|
return sql`COALESCE(posts.published_at, posts.created_at) >= ${range.dateFrom} AND COALESCE(posts.published_at, posts.created_at) < ${range.dateTo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.dateFrom) {
|
||||||
|
return sql`COALESCE(posts.published_at, posts.created_at) >= ${range.dateFrom}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range.dateTo) {
|
||||||
|
return sql`COALESCE(posts.published_at, posts.created_at) < ${range.dateTo}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql`true`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export 파일 경로를 안전하게 해석한다.
|
||||||
|
* @param {string} filePath - 상대 경로
|
||||||
|
* @returns {string|null} 디스크 경로
|
||||||
|
*/
|
||||||
|
const resolveExportFilePath = (filePath) => {
|
||||||
|
const absolutePath = join(UPLOAD_ROOT, filePath || '')
|
||||||
|
const safeRelativePath = relative(UPLOAD_ROOT, absolutePath)
|
||||||
|
|
||||||
|
if (!safeRelativePath || safeRelativePath.startsWith('..')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 사이트 이름을 조회한다.
|
* 사이트 이름을 조회한다.
|
||||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||||
@@ -429,6 +533,9 @@ export const listQueuedPostExportJobIds = async () => {
|
|||||||
* @param {string} input.requestedBy - 요청 관리자 회원 ID
|
* @param {string} input.requestedBy - 요청 관리자 회원 ID
|
||||||
* @param {string} input.requestedEmail - 요청 관리자 이메일
|
* @param {string} input.requestedEmail - 요청 관리자 이메일
|
||||||
* @param {'all'|'author'} [input.scope] - export 범위
|
* @param {'all'|'author'} [input.scope] - export 범위
|
||||||
|
* @param {Date|string|null} [input.dateFrom] - 시작일
|
||||||
|
* @param {Date|string|null} [input.dateTo] - 종료일
|
||||||
|
* @param {string} [input.rangeLabel] - 범위 라벨
|
||||||
* @param {number} [input.chunkSize] - 분할당 게시물 수
|
* @param {number} [input.chunkSize] - 분할당 게시물 수
|
||||||
* @param {number} [input.retentionDays] - 산출물 보존 일수
|
* @param {number} [input.retentionDays] - 산출물 보존 일수
|
||||||
* @returns {Promise<Object>} 생성된 export 작업
|
* @returns {Promise<Object>} 생성된 export 작업
|
||||||
@@ -443,19 +550,24 @@ export const createPostExportJob = async (input) => {
|
|||||||
const scope = input.scope === 'author' ? 'author' : 'all'
|
const scope = input.scope === 'author' ? 'author' : 'all'
|
||||||
const chunkSize = normalizeChunkSize(input.chunkSize)
|
const chunkSize = normalizeChunkSize(input.chunkSize)
|
||||||
const retentionDays = normalizeRetentionDays(input.retentionDays)
|
const retentionDays = normalizeRetentionDays(input.retentionDays)
|
||||||
|
const exportDateRange = normalizeExportDateRange(input)
|
||||||
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
|
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
|
||||||
|
const fileRangeName = sanitizeFilenameSegment(exportDateRange.rangeLabel)
|
||||||
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000)
|
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
const [createdJob] = await sql.begin(async (transaction) => {
|
const [createdJob] = await sql.begin(async (transaction) => {
|
||||||
|
const dateCondition = createPostExportDateCondition(transaction, exportDateRange)
|
||||||
const [{ count }] = scope === 'author'
|
const [{ count }] = scope === 'author'
|
||||||
? await transaction`
|
? await transaction`
|
||||||
SELECT COUNT(*)::int AS count
|
SELECT COUNT(*)::int AS count
|
||||||
FROM posts
|
FROM posts
|
||||||
WHERE author_id = ${input.requestedBy}
|
WHERE author_id = ${input.requestedBy}
|
||||||
|
AND ${dateCondition}
|
||||||
`
|
`
|
||||||
: await transaction`
|
: await transaction`
|
||||||
SELECT COUNT(*)::int AS count
|
SELECT COUNT(*)::int AS count
|
||||||
FROM posts
|
FROM posts
|
||||||
|
WHERE ${dateCondition}
|
||||||
`
|
`
|
||||||
const postCount = Number(count || 0)
|
const postCount = Number(count || 0)
|
||||||
const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY
|
const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY
|
||||||
@@ -471,6 +583,9 @@ export const createPostExportJob = async (input) => {
|
|||||||
post_count,
|
post_count,
|
||||||
chunk_size,
|
chunk_size,
|
||||||
retention_days,
|
retention_days,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
range_label,
|
||||||
expires_at,
|
expires_at,
|
||||||
message,
|
message,
|
||||||
completed_at
|
completed_at
|
||||||
@@ -483,6 +598,9 @@ export const createPostExportJob = async (input) => {
|
|||||||
${postCount},
|
${postCount},
|
||||||
${chunkSize},
|
${chunkSize},
|
||||||
${retentionDays},
|
${retentionDays},
|
||||||
|
${exportDateRange.dateFrom},
|
||||||
|
${exportDateRange.dateTo},
|
||||||
|
${exportDateRange.rangeLabel},
|
||||||
${expiresAt},
|
${expiresAt},
|
||||||
${message},
|
${message},
|
||||||
${postCount > 0 ? null : new Date()}
|
${postCount > 0 ? null : new Date()}
|
||||||
@@ -502,7 +620,7 @@ export const createPostExportJob = async (input) => {
|
|||||||
partIndex: index + 1,
|
partIndex: index + 1,
|
||||||
postStart,
|
postStart,
|
||||||
postEnd,
|
postEnd,
|
||||||
fileName: `${siteName}_${postStart}-${postEnd}.zip`,
|
fileName: `${siteName}_${fileRangeName}_${postStart}-${postEnd}.zip`,
|
||||||
status: EXPORT_FILE_STATUS.PENDING
|
status: EXPORT_FILE_STATUS.PENDING
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -710,6 +828,10 @@ const markPostExportFileFailed = async (sql, fileId) => {
|
|||||||
const listPostsForExportFile = async (sql, job, file) => {
|
const listPostsForExportFile = async (sql, job, file) => {
|
||||||
const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1
|
const limit = Number(file.postEnd || 0) - Number(file.postStart || 0) + 1
|
||||||
const offset = Number(file.postStart || 1) - 1
|
const offset = Number(file.postStart || 1) - 1
|
||||||
|
const dateCondition = createPostExportDateCondition(sql, {
|
||||||
|
dateFrom: job.dateFrom ? new Date(job.dateFrom) : null,
|
||||||
|
dateTo: job.dateTo ? new Date(job.dateTo) : null
|
||||||
|
})
|
||||||
|
|
||||||
if (job.scope === 'author') {
|
if (job.scope === 'author') {
|
||||||
return sql`
|
return sql`
|
||||||
@@ -720,6 +842,7 @@ const listPostsForExportFile = async (sql, job, file) => {
|
|||||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||||
WHERE posts.author_id = ${job.requestedBy}
|
WHERE posts.author_id = ${job.requestedBy}
|
||||||
|
AND ${dateCondition}
|
||||||
GROUP BY posts.id
|
GROUP BY posts.id
|
||||||
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
@@ -734,6 +857,7 @@ const listPostsForExportFile = async (sql, job, file) => {
|
|||||||
FROM posts
|
FROM posts
|
||||||
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
LEFT JOIN post_tags ON post_tags.post_id = posts.id
|
||||||
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
LEFT JOIN tags ON tags.id = post_tags.tag_id
|
||||||
|
WHERE ${dateCondition}
|
||||||
GROUP BY posts.id
|
GROUP BY posts.id
|
||||||
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
ORDER BY COALESCE(posts.published_at, posts.created_at) ASC, posts.id ASC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
@@ -896,3 +1020,45 @@ export const getReadyPostExportFile = async (fileId) => {
|
|||||||
|
|
||||||
return mapPostExportFileRow(rows[0])
|
return mapPostExportFileRow(rows[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게시물 Export 작업과 생성 파일을 삭제한다.
|
||||||
|
* @param {string} jobId - Export 작업 ID
|
||||||
|
* @returns {Promise<boolean>} 삭제 여부
|
||||||
|
*/
|
||||||
|
export const deletePostExportJob = async (jobId) => {
|
||||||
|
const sql = getPostgresClient()
|
||||||
|
|
||||||
|
if (!sql) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = await getPostExportJobById(jobId)
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([EXPORT_STATUS.QUEUED, EXPORT_STATUS.PROCESSING].includes(job.status)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of job.files) {
|
||||||
|
if (!file.filePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = resolveExportFilePath(file.filePath)
|
||||||
|
|
||||||
|
if (absolutePath) {
|
||||||
|
await rm(absolutePath, { force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
DELETE FROM post_export_jobs
|
||||||
|
WHERE id = ${jobId}
|
||||||
|
`
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readBody } from 'h3'
|
import { createError, readBody } from 'h3'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||||
import {
|
import {
|
||||||
@@ -7,10 +7,101 @@ import {
|
|||||||
} from '../../../../repositories/post-export-repository'
|
} from '../../../../repositories/post-export-repository'
|
||||||
|
|
||||||
const postExportJobInputSchema = z.object({
|
const postExportJobInputSchema = z.object({
|
||||||
|
dateRangeMode: z.enum(['all', 'year', 'month', 'custom']).optional().default('all'),
|
||||||
|
year: z.number().int().min(1970).max(9999).optional(),
|
||||||
|
month: z.number().int().min(1).max(12).optional(),
|
||||||
|
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
dateTo: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
chunkSize: z.number().int().min(1).max(500).optional(),
|
chunkSize: z.number().int().min(1).max(500).optional(),
|
||||||
retentionDays: z.number().int().min(1).max(100).optional()
|
retentionDays: z.number().int().min(1).max(100).optional()
|
||||||
}).default({})
|
}).default({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KST 기준 날짜 시작 시각을 만든다.
|
||||||
|
* @param {string} value - YYYY-MM-DD 날짜
|
||||||
|
* @returns {Date} 날짜 시작 시각
|
||||||
|
*/
|
||||||
|
const createKstDateStart = (value) => new Date(`${value}T00:00:00+09:00`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KST 기준 다음 날짜 시작 시각을 만든다.
|
||||||
|
* @param {string} value - YYYY-MM-DD 날짜
|
||||||
|
* @returns {Date} 다음 날짜 시작 시각
|
||||||
|
*/
|
||||||
|
const createKstNextDateStart = (value) => {
|
||||||
|
const date = createKstDateStart(value)
|
||||||
|
date.setUTCDate(date.getUTCDate() + 1)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 자리 숫자 문자열을 만든다.
|
||||||
|
* @param {number} value - 숫자
|
||||||
|
* @returns {string} 두 자리 문자열
|
||||||
|
*/
|
||||||
|
const pad2 = (value) => String(value).padStart(2, '0')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 입력에서 Export 날짜 범위를 만든다.
|
||||||
|
* @param {z.infer<typeof postExportJobInputSchema>} input - 요청 입력
|
||||||
|
* @returns {{ dateFrom: Date|null, dateTo: Date|null, rangeLabel: string }} 날짜 범위
|
||||||
|
*/
|
||||||
|
const createExportDateRange = (input) => {
|
||||||
|
if (input.dateRangeMode === 'year') {
|
||||||
|
const year = input.year || new Date().getFullYear()
|
||||||
|
return {
|
||||||
|
dateFrom: createKstDateStart(`${year}-01-01`),
|
||||||
|
dateTo: createKstDateStart(`${year + 1}-01-01`),
|
||||||
|
rangeLabel: `${year}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dateRangeMode === 'month') {
|
||||||
|
const now = new Date()
|
||||||
|
const year = input.year || now.getFullYear()
|
||||||
|
const month = input.month || now.getMonth() + 1
|
||||||
|
const nextYear = month === 12 ? year + 1 : year
|
||||||
|
const nextMonth = month === 12 ? 1 : month + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom: createKstDateStart(`${year}-${pad2(month)}-01`),
|
||||||
|
dateTo: createKstDateStart(`${nextYear}-${pad2(nextMonth)}-01`),
|
||||||
|
rangeLabel: `${year}-${pad2(month)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dateRangeMode === 'custom') {
|
||||||
|
if (!input.dateFrom || !input.dateTo) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: '날짜 범위를 선택해 주세요.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFrom = createKstDateStart(input.dateFrom)
|
||||||
|
const dateTo = createKstNextDateStart(input.dateTo)
|
||||||
|
|
||||||
|
if (dateFrom >= dateTo) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: '시작일은 종료일보다 늦을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
rangeLabel: `${input.dateFrom}_${input.dateTo}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateFrom: null,
|
||||||
|
dateTo: null,
|
||||||
|
rangeLabel: '전체'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 게시물 Export 작업 요청 API
|
* 관리자 게시물 Export 작업 요청 API
|
||||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
@@ -19,11 +110,15 @@ const postExportJobInputSchema = z.object({
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const adminSession = requireAdminSession(event)
|
const adminSession = requireAdminSession(event)
|
||||||
const input = postExportJobInputSchema.parse(await readBody(event))
|
const input = postExportJobInputSchema.parse(await readBody(event))
|
||||||
|
const dateRange = createExportDateRange(input)
|
||||||
|
|
||||||
const job = await createPostExportJob({
|
const job = await createPostExportJob({
|
||||||
requestedBy: adminSession.userId,
|
requestedBy: adminSession.userId,
|
||||||
requestedEmail: adminSession.email,
|
requestedEmail: adminSession.email,
|
||||||
scope: 'all',
|
scope: 'all',
|
||||||
|
dateFrom: dateRange.dateFrom,
|
||||||
|
dateTo: dateRange.dateTo,
|
||||||
|
rangeLabel: dateRange.rangeLabel,
|
||||||
chunkSize: input.chunkSize,
|
chunkSize: input.chunkSize,
|
||||||
retentionDays: input.retentionDays
|
retentionDays: input.retentionDays
|
||||||
})
|
})
|
||||||
|
|||||||
32
server/routes/admin/api/posts/export-jobs/[jobId].delete.js
Normal file
32
server/routes/admin/api/posts/export-jobs/[jobId].delete.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { createError, getRouterParam } from 'h3'
|
||||||
|
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||||
|
import { deletePostExportJob } from '../../../../../repositories/post-export-repository'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관리자 게시물 Export 작업 삭제 API
|
||||||
|
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||||
|
* @returns {Promise<{ ok: boolean }>} 삭제 결과
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdminSession(event)
|
||||||
|
|
||||||
|
const jobId = getRouterParam(event, 'jobId')
|
||||||
|
|
||||||
|
if (!jobId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Export 작업 ID가 필요합니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await deletePostExportJob(jobId)
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Export 작업을 찾을 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user