게시물 export 작업 기반 추가 v1.5.20
This commit is contained in:
47
db/migrations/040_post_export_jobs.sql
Normal file
47
db/migrations/040_post_export_jobs.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
CREATE TABLE IF NOT EXISTS post_export_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
requested_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
requested_email TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
scope TEXT NOT NULL DEFAULT 'all',
|
||||
post_count INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_size INTEGER NOT NULL DEFAULT 100,
|
||||
retention_days INTEGER NOT NULL DEFAULT 100,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + interval '100 days'),
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
CONSTRAINT post_export_jobs_status_check CHECK (status IN ('queued', 'processing', 'ready', 'failed', 'expired')),
|
||||
CONSTRAINT post_export_jobs_scope_check CHECK (scope IN ('all', 'author')),
|
||||
CONSTRAINT post_export_jobs_chunk_size_check CHECK (chunk_size > 0),
|
||||
CONSTRAINT post_export_jobs_retention_days_check CHECK (retention_days > 0 AND retention_days <= 100)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_jobs_status_created_at_idx
|
||||
ON post_export_jobs (status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_jobs_expires_at_idx
|
||||
ON post_export_jobs (expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS post_export_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_id UUID NOT NULL REFERENCES post_export_jobs(id) ON DELETE CASCADE,
|
||||
part_index INTEGER NOT NULL,
|
||||
post_start INTEGER NOT NULL,
|
||||
post_end INTEGER NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
file_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
CONSTRAINT post_export_files_status_check CHECK (status IN ('pending', 'processing', 'ready', 'failed', 'expired')),
|
||||
CONSTRAINT post_export_files_range_check CHECK (post_start > 0 AND post_end >= post_start),
|
||||
CONSTRAINT post_export_files_part_index_check CHECK (part_index > 0),
|
||||
CONSTRAINT post_export_files_job_part_unique UNIQUE (job_id, part_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS post_export_files_job_id_part_index_idx
|
||||
ON post_export_files (job_id, part_index ASC);
|
||||
@@ -1,5 +1,9 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.5.20
|
||||
|
||||
- 게시물 Export 작업을 관리자 설정에서 요청하고, 생성될 분할 zip 파일 계획을 확인할 수 있는 1차 기반을 추가했다.
|
||||
|
||||
## v1.5.19
|
||||
|
||||
- 게시물 Export를 대량 게시물에서도 안전하게 처리하도록 백그라운드 분할 생성과 다운로드 만료 정책 기준을 정리했다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 배포 가이드
|
||||
|
||||
> 로컬 기준 `npm run build`, `docker compose --env-file .env.production config --quiet`, `docker compose --env-file .env.production build sori-studio` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
> 로컬 기준 v1.5.20에서 `npm run lint`, `npm run build`, `npm run db:migrate:dev` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-06-01 v1.5.20 — Export 구현은 작업 등록부터 단계적으로 연결
|
||||
|
||||
대용량 Export는 한 번에 UI, zip 생성, 이메일, 다운로드까지 붙이면 실패 지점 파악이 어렵다. 먼저 `post_export_jobs`와 `post_export_files`로 요청과 분할 파일 계획을 영속화하고, 관리자 설정 화면에서는 이 계획을 확인할 수 있게 했다. 실제 zip 생성 워커와 다운로드 API는 같은 작업/파일 레코드를 기준으로 이어 붙이면 되므로 이후 단계에서 재시도·만료·이메일 알림을 독립적으로 구현할 수 있다.
|
||||
|
||||
## 2026-06-01 v1.5.19 — 대용량 Export는 요청과 다운로드를 분리
|
||||
|
||||
게시물이 수만 개까지 늘어나면 관리자가 Export 버튼을 누르는 순간 모든 게시물과 자산을 하나의 zip으로 만드는 방식은 요청 타임아웃, 메모리 사용량, 브라우저 다운로드 실패 가능성이 크다. Export는 요청 즉시 파일을 내려주는 기능이 아니라 백그라운드 작업으로 분리한다. 서버는 게시물 개수나 산출 용량 기준으로 여러 zip을 만들고, 준비가 끝나면 이메일로 알린다. 관리자 화면은 각 분할 파일을 독립적으로 다운로드할 수 있게 하며, 일괄 다운로드는 브라우저에서 순차 실행해 중간 실패 시 해당 범위부터 다시 받을 수 있게 한다. 산출물은 백업 생성물이라 서버 용량을 계속 차지하므로 최대 100일 후 만료·삭제한다.
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
| pages/admin/tags/new.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 | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||
@@ -203,6 +203,8 @@
|
||||
| server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API |
|
||||
| server/routes/admin/api/posts/[id].put.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.post.js | 관리자 게시물 Export 작업 요청 API |
|
||||
| server/routes/admin/api/pages.get.js | 관리자 고정 페이지 목록 API |
|
||||
| server/routes/admin/api/pages.post.js | 관리자 고정 페이지 생성 API |
|
||||
| server/routes/admin/api/pages/[id].get.js | 관리자 고정 페이지 상세 API |
|
||||
@@ -251,6 +253,7 @@
|
||||
| server/utils/media-library.js | 업로드 미디어·논리 폴더(`미분류`, 예약 `썸네일`)·`avatarOwner` 부착·아바타 삭제/이름변경 차단 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획 저장소 |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
| server/repositories/analytics-repository.js | 방문·게시물 통계 집계·관리자 요약 조회·방문자 해시 보관 정리 |
|
||||
@@ -297,6 +300,7 @@
|
||||
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
|
||||
| db/migrations/038_restore_owner_when_missing.sql | 소유자가 없는 경우 기존 관리자 중 가장 오래된 계정을 소유자로 복구 |
|
||||
| db/migrations/039_page_analytics_and_navigation_recommended_metadata.sql | 페이지 통계 테이블·추천 사이트 대체 텍스트/썸네일 컬럼 추가 |
|
||||
| db/migrations/040_post_export_jobs.sql | 게시물 Export 작업·분할 파일 계획 테이블 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
30
docs/spec.md
30
docs/spec.md
@@ -273,6 +273,30 @@ components/content/
|
||||
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
|
||||
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
|
||||
|
||||
### PostExportJobs / PostExportFiles
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| post_export_jobs.id | UUID | Export 작업 ID |
|
||||
| post_export_jobs.requested_by | UUID nullable | 요청 관리자 회원 ID |
|
||||
| post_export_jobs.requested_email | String | 요청 당시 관리자 이메일 |
|
||||
| post_export_jobs.status | Enum | `queued` / `processing` / `ready` / `failed` / `expired` |
|
||||
| post_export_jobs.scope | Enum | `all` / `author` |
|
||||
| post_export_jobs.post_count | Integer | 작업 대상 게시물 수 |
|
||||
| post_export_jobs.chunk_size | Integer | 분할당 게시물 수 |
|
||||
| post_export_jobs.retention_days | Integer | 보존 일수, 최대 100 |
|
||||
| post_export_jobs.expires_at | DateTime | 만료 예정 시각 |
|
||||
| post_export_jobs.message | Text | 작업 메시지 |
|
||||
| post_export_files.id | UUID | 분할 파일 ID |
|
||||
| post_export_files.job_id | UUID | FK → PostExportJobs |
|
||||
| post_export_files.part_index | Integer | 분할 순번 |
|
||||
| post_export_files.post_start | Integer | 해당 zip의 시작 게시물 순번 |
|
||||
| post_export_files.post_end | Integer | 해당 zip의 끝 게시물 순번 |
|
||||
| post_export_files.file_name | String | `사이트명_시작번호-끝번호.zip` 파일명 |
|
||||
| post_export_files.file_path | String | 실제 생성 파일 경로, 생성 전 빈 값 |
|
||||
| post_export_files.file_size_bytes | BigInt | 파일 크기, 생성 전 0 |
|
||||
| post_export_files.status | Enum | `pending` / `processing` / `ready` / `failed` / `expired` |
|
||||
|
||||
### Users
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -484,6 +508,8 @@ components/content/
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
- `PUT /admin/api/posts/:id` - 글 수정
|
||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||
- `GET /admin/api/posts/export-jobs` - 게시물 Export 작업 목록
|
||||
- `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. 현재 1차 구현은 작업 레코드와 100개 단위 분할 파일 계획을 만든다.
|
||||
- `GET /admin/api/pages` - 고정 페이지 목록
|
||||
- `POST /admin/api/pages` - 고정 페이지 작성
|
||||
- `GET /admin/api/pages/:id` - 고정 페이지 상세
|
||||
@@ -658,9 +684,9 @@ components/content/
|
||||
|
||||
### 사이트 설정
|
||||
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존·게시물 Import/Export는 현재 **메뉴·안내 카드만** 제공하고 저장 API는 연결하지 않는다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 읽기 전용으로 보여 주고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **POST 설정**·**어나운스 바**는 읽기 모드에서도 토글 UI를 비활성화 상태로 보여 주며, 켜짐 상태도 조작 가능한 활성 스위치처럼 보이지 않도록 낮은 대비와 `cursor-not-allowed`를 사용한다.
|
||||
- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 타임존은 메뉴·안내 카드만 제공한다. 게시물 Import/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로 다시 매핑한다.
|
||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 파일명은 기본적으로 `사이트명_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어간다. 모든 분할 파일 생성이 끝나면 요청 관리자에게 이메일로 완료 알림을 보내고, 관리자 화면에는 export 작업별 다운로드 목록을 표시한다. 사용자는 분할 파일을 각각 다운로드하거나, 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 일괄 다운로드가 중간에 멈추면 완료된 항목과 실패/미완료 항목을 구분해 해당 분할 파일부터 다시 받을 수 있어야 한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다.
|
||||
- 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 일정 개수 또는 산출 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 1차 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성한다. 파일명은 기본적으로 `사이트명_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어간다. 모든 분할 파일 생성이 끝나면 요청 관리자에게 이메일로 완료 알림을 보내고, 관리자 화면에는 export 작업별 다운로드 목록을 표시한다. 사용자는 분할 파일을 각각 다운로드하거나, 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 일괄 다운로드가 중간에 멈추면 완료된 항목과 실패/미완료 항목을 구분해 해당 분할 파일부터 다시 받을 수 있어야 한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하고, 만료된 파일과 작업 레코드는 정리 대상이 된다.
|
||||
- 사이트 설정은 `site_settings` 테이블의 단일 레코드로 관리한다.
|
||||
- 관리자는 사이트 이름, 설명, 사이트 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`).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] 게시물 Import/Export 1차 구현: 게시물별 폴더(`제목/제목.md`)와 `images/`, `files/` 로컬 자산 폴더를 포함한 Obsidian 호환 zip 백업 번들, 태그/상태/발행일/대표 이미지/SEO frontmatter 매핑, export 시 내부 `/uploads` URL을 상대 경로로 재작성, import 시 로컬 자산을 미디어 저장소로 복원하고 본문 경로를 새 `/uploads` URL로 재매핑
|
||||
- [ ] 게시물 Export 대용량 작업 구현: 백그라운드 export 작업 테이블, 게시물 개수/용량 기준 분할 zip 생성, `사이트명_시작번호-끝번호.zip` 파일명, 완료 이메일 알림, 분할 다운로드/일괄 순차 다운로드 UI, 실패 지점 재다운로드, 산출물 최대 100일 보존 및 만료 정리
|
||||
- [ ] 게시물 Export 대용량 작업 후속 구현: `post_export_jobs`/`post_export_files` 기준 zip 생성 워커, 내부 `/uploads` 자산 복사와 상대 경로 재작성, 완료 이메일 알림, 준비 완료 파일 다운로드 API, 일괄 순차 다운로드 UI, 실패 지점 재다운로드, 100일 만료 파일 정리
|
||||
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.20
|
||||
|
||||
- 게시물 Export: 백그라운드 작업 요청용 `post_export_jobs`와 분할 파일 계획용 `post_export_files` 테이블 추가.
|
||||
- 게시물 Export: 관리자 API에서 최근 Export 작업 목록 조회와 새 Export 작업 요청을 지원하도록 추가.
|
||||
- 관리자 사이트 설정: 게시물 Import/Export 섹션에서 Export 요청, 최근 작업, 분할 파일 계획을 확인할 수 있도록 수정.
|
||||
- 게시물 Export: zip 생성 워커·이메일 알림·다운로드 연결 전 1차 작업 등록 단계로 정리.
|
||||
|
||||
## v1.5.19
|
||||
|
||||
- 게시물 Export: 대용량 게시물 환경을 고려해 백그라운드 분할 생성, 이메일 완료 알림, 분할/일괄 다운로드, 100일 보존 정책 방향 문서화.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.5.19",
|
||||
"version": "1.5.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -20,6 +20,7 @@ const savingSpam = ref(false)
|
||||
const uploadingLogo = ref(false)
|
||||
const uploadingHomeCover = ref(false)
|
||||
const uploadingHomeCoverDark = ref(false)
|
||||
const requestingPostExport = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const toast = ref(null)
|
||||
const logoInputRef = ref(null)
|
||||
@@ -80,6 +81,12 @@ let toastTimer = null
|
||||
let scrollSpyFrame = null
|
||||
|
||||
const { data: settings } = await useFetch('/admin/api/settings')
|
||||
const {
|
||||
data: postExportJobs,
|
||||
refresh: refreshPostExportJobs
|
||||
} = await useFetch('/admin/api/posts/export-jobs', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: settings.value?.title || 'sori.studio',
|
||||
@@ -158,6 +165,62 @@ const hasAnnouncementChanges = computed(() => customizeAnnouncement.value && (
|
||||
const hasSpamChanges = computed(() => editSpam.value
|
||||
&& JSON.stringify(form.signupBlockedUsernames) !== JSON.stringify(spamSnapshot.signupBlockedUsernames))
|
||||
|
||||
/**
|
||||
* 최신 게시물 export 작업 목록
|
||||
* @returns {Array} export 작업 목록
|
||||
*/
|
||||
const normalizedPostExportJobs = computed(() => Array.isArray(postExportJobs.value) ? postExportJobs.value : [])
|
||||
|
||||
/**
|
||||
* export 상태 라벨 조회
|
||||
* @param {string} status - export 상태
|
||||
* @returns {string} 상태 라벨
|
||||
*/
|
||||
const getPostExportStatusLabel = (status) => ({
|
||||
queued: '대기 중',
|
||||
processing: '생성 중',
|
||||
ready: '준비 완료',
|
||||
failed: '실패',
|
||||
expired: '만료'
|
||||
}[status] || '알 수 없음')
|
||||
|
||||
/**
|
||||
* export 상태 배지 클래스 조회
|
||||
* @param {string} status - export 상태
|
||||
* @returns {string} Tailwind 클래스
|
||||
*/
|
||||
const getPostExportStatusClass = (status) => ({
|
||||
queued: 'bg-[#eef4ff] text-[#2f5fbb] ring-[#c9dafd]',
|
||||
processing: 'bg-[#fff7e8] text-[#9a6200] ring-[#f3d39b]',
|
||||
ready: 'bg-[#eaf8f0] text-[#147a45] ring-[#b9e7cd]',
|
||||
failed: 'bg-[#fff0f0] text-[#c53232] ring-[#f3c2c2]',
|
||||
expired: 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]'
|
||||
}[status] || 'bg-[#f1f3f5] text-[#657080] ring-[#dce0e5]')
|
||||
|
||||
/**
|
||||
* 바이트 값을 읽기 쉬운 용량으로 변환한다.
|
||||
* @param {number} value - 바이트 값
|
||||
* @returns {string} 용량 라벨
|
||||
*/
|
||||
const formatExportFileSize = (value) => {
|
||||
const bytes = Number(value || 0)
|
||||
|
||||
if (!bytes) {
|
||||
return '생성 대기'
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입 금지 닉네임 textarea 바인딩
|
||||
*/
|
||||
@@ -325,6 +388,37 @@ const showToast = (type, message) => {
|
||||
}, 3200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 export 작업을 요청한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const requestPostExport = async () => {
|
||||
if (requestingPostExport.value) {
|
||||
return
|
||||
}
|
||||
|
||||
requestingPostExport.value = true
|
||||
errorMessage.value = ''
|
||||
showToast('info', '게시물 Export 작업을 등록하는 중입니다.')
|
||||
|
||||
try {
|
||||
await $fetch('/admin/api/posts/export-jobs', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
chunkSize: 100,
|
||||
retentionDays: 100
|
||||
}
|
||||
})
|
||||
await refreshPostExportJobs()
|
||||
showToast('success', '게시물 Export 작업이 등록되었습니다.')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '게시물 Export 작업을 등록하지 못했습니다.'
|
||||
showToast('error', errorMessage.value)
|
||||
} finally {
|
||||
requestingPostExport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로고 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
@@ -1629,11 +1723,111 @@ onBeforeUnmount(() => {
|
||||
게시물 Import/Export
|
||||
</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
마크다운 등 형식으로 게시물을 가져오거나 보냅니다. (준비 중)
|
||||
게시물 백업을 서버 작업으로 등록하고, 준비된 분할 파일을 내려받습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-settings-screen__placeholder mt-4 rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]">
|
||||
이후 버전에서 일괄 가져오기·보내기 도구를 제공합니다.
|
||||
|
||||
<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="min-w-0">
|
||||
<p class="text-sm font-semibold text-[#15171a]">
|
||||
Obsidian 호환 백업 준비
|
||||
</p>
|
||||
<p class="mt-1 text-sm leading-relaxed text-[#657080]">
|
||||
100개 단위 분할 zip 계획을 만들고, 산출물은 최대 100일 동안 보관합니다.
|
||||
</p>
|
||||
</div>
|
||||
<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="requestingPostExport"
|
||||
@click="requestPostExport"
|
||||
>
|
||||
{{ requestingPostExport ? '요청 중...' : 'Export 요청' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-settings-screen__export-list mt-5">
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-[#15171a]">
|
||||
최근 Export 작업
|
||||
</h3>
|
||||
<button
|
||||
class="inline-flex h-8 cursor-pointer items-center justify-center rounded px-3 text-xs font-semibold text-[#5d6673] transition hover:bg-[#eceff2] hover:text-[#15171a]"
|
||||
type="button"
|
||||
@click="refreshPostExportJobs"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="normalizedPostExportJobs.length === 0"
|
||||
class="admin-settings-screen__export-empty rounded-lg border border-dashed border-[#dce0e5] bg-[#f7f8fa] px-4 py-8 text-center text-sm text-[#657080]"
|
||||
>
|
||||
아직 등록된 Export 작업이 없습니다.
|
||||
</div>
|
||||
<div v-else class="admin-settings-screen__export-items grid gap-3">
|
||||
<article
|
||||
v-for="job in normalizedPostExportJobs"
|
||||
:key="job.id"
|
||||
class="admin-settings-screen__export-item rounded-lg border border-[#e6e8eb] bg-white p-4"
|
||||
>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold ring-1 ring-inset"
|
||||
:class="getPostExportStatusClass(job.status)"
|
||||
>
|
||||
{{ getPostExportStatusLabel(job.status) }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-[#15171a]">
|
||||
게시물 {{ job.postCount }}개
|
||||
</span>
|
||||
<span class="text-sm text-[#9aa3ad]">
|
||||
{{ job.files.length }}개 파일
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-[#657080]">
|
||||
요청: {{ formatPostDateTime(job.createdAt) }} · 만료: {{ formatPostDateTime(job.expiresAt) }}
|
||||
</p>
|
||||
<p v-if="job.message" class="mt-2 text-sm text-[#657080]">
|
||||
{{ job.message }}
|
||||
</p>
|
||||
</div>
|
||||
<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]"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
일괄 다운로드 준비 중
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]">
|
||||
<div
|
||||
v-for="file in job.files"
|
||||
:key="file.id"
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#edf0f3] px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-[#15171a]">
|
||||
{{ file.fileName }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-[#9aa3ad]">
|
||||
{{ file.postStart }}-{{ file.postEnd }} · {{ formatExportFileSize(file.fileSizeBytes) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex h-8 cursor-not-allowed items-center justify-center rounded border border-[#e1e5ea] px-3 text-xs font-semibold text-[#a6b0bb]"
|
||||
type="button"
|
||||
disabled
|
||||
>
|
||||
다운로드 대기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
273
server/repositories/post-export-repository.js
Normal file
273
server/repositories/post-export-repository.js
Normal file
@@ -0,0 +1,273 @@
|
||||
import { getDefaultSiteSettings } from '../utils/site-settings'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
const EXPORT_STATUS = {
|
||||
QUEUED: 'queued',
|
||||
READY: 'ready'
|
||||
}
|
||||
|
||||
const EXPORT_FILE_STATUS = {
|
||||
PENDING: 'pending'
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 100
|
||||
const MAX_CHUNK_SIZE = 500
|
||||
const DEFAULT_RETENTION_DAYS = 100
|
||||
|
||||
/**
|
||||
* 파일명에 쓸 수 없는 문자를 정리한다.
|
||||
* @param {string} value - 원본 문자열
|
||||
* @returns {string} 파일명 안전 문자열
|
||||
*/
|
||||
const sanitizeFilenameSegment = (value) => String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 80) || 'sori.studio'
|
||||
|
||||
/**
|
||||
* export 작업 행을 응답 구조로 변환한다.
|
||||
* @param {Object} row - DB 행
|
||||
* @returns {Object} export 작업
|
||||
*/
|
||||
const mapPostExportJobRow = (row) => ({
|
||||
id: row.id,
|
||||
requestedBy: row.requested_by || null,
|
||||
requestedEmail: row.requested_email || '',
|
||||
status: row.status,
|
||||
scope: row.scope,
|
||||
postCount: Number(row.post_count || 0),
|
||||
chunkSize: Number(row.chunk_size || DEFAULT_CHUNK_SIZE),
|
||||
retentionDays: Number(row.retention_days || DEFAULT_RETENTION_DAYS),
|
||||
expiresAt: row.expires_at ? row.expires_at.toISOString() : null,
|
||||
message: row.message || '',
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
completedAt: row.completed_at ? row.completed_at.toISOString() : null,
|
||||
files: row.files || []
|
||||
})
|
||||
|
||||
/**
|
||||
* export 분할 파일 행을 응답 구조로 변환한다.
|
||||
* @param {Object} row - DB 행
|
||||
* @returns {Object} export 분할 파일
|
||||
*/
|
||||
const mapPostExportFileRow = (row) => ({
|
||||
id: row.id,
|
||||
jobId: row.job_id,
|
||||
partIndex: Number(row.part_index || 0),
|
||||
postStart: Number(row.post_start || 0),
|
||||
postEnd: Number(row.post_end || 0),
|
||||
fileName: row.file_name,
|
||||
filePath: row.file_path || '',
|
||||
fileSizeBytes: Number(row.file_size_bytes || 0),
|
||||
status: row.status,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
completedAt: row.completed_at ? row.completed_at.toISOString() : null
|
||||
})
|
||||
|
||||
/**
|
||||
* 요청된 분할 크기를 안전 범위로 보정한다.
|
||||
* @param {unknown} value - 입력 분할 크기
|
||||
* @returns {number} 보정된 분할 크기
|
||||
*/
|
||||
const normalizeChunkSize = (value) => {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_CHUNK_SIZE
|
||||
}
|
||||
|
||||
return Math.min(Math.max(Math.trunc(parsed), 1), MAX_CHUNK_SIZE)
|
||||
}
|
||||
|
||||
/**
|
||||
* export 산출물 보존 일수를 안전 범위로 보정한다.
|
||||
* @param {unknown} value - 입력 보존 일수
|
||||
* @returns {number} 보정된 보존 일수
|
||||
*/
|
||||
const normalizeRetentionDays = (value) => {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_RETENTION_DAYS
|
||||
}
|
||||
|
||||
return Math.min(Math.max(Math.trunc(parsed), 1), DEFAULT_RETENTION_DAYS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이트 이름을 조회한다.
|
||||
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
|
||||
* @returns {Promise<string>} 사이트 이름
|
||||
*/
|
||||
const getExportSiteName = async (sql) => {
|
||||
const rows = await sql`
|
||||
SELECT title
|
||||
FROM site_settings
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0]?.title || getDefaultSiteSettings().title
|
||||
}
|
||||
|
||||
/**
|
||||
* export 작업 목록 조회
|
||||
* @returns {Promise<Array>} export 작업 목록
|
||||
*/
|
||||
export const listPostExportJobs = async () => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
}
|
||||
|
||||
const jobRows = await sql`
|
||||
SELECT *
|
||||
FROM post_export_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20
|
||||
`
|
||||
|
||||
if (jobRows.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const jobIds = jobRows.map((row) => row.id)
|
||||
const fileRows = await sql`
|
||||
SELECT *
|
||||
FROM post_export_files
|
||||
WHERE job_id = ANY(${jobIds})
|
||||
ORDER BY job_id, part_index ASC
|
||||
`
|
||||
const filesByJobId = fileRows.reduce((groups, row) => {
|
||||
const key = row.job_id
|
||||
groups[key] = groups[key] || []
|
||||
groups[key].push(mapPostExportFileRow(row))
|
||||
return groups
|
||||
}, {})
|
||||
|
||||
return jobRows.map((row) => mapPostExportJobRow({
|
||||
...row,
|
||||
files: filesByJobId[row.id] || []
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 export 작업 요청 생성
|
||||
* @param {Object} input - 작업 요청 입력
|
||||
* @param {string} input.requestedBy - 요청 관리자 회원 ID
|
||||
* @param {string} input.requestedEmail - 요청 관리자 이메일
|
||||
* @param {'all'|'author'} [input.scope] - export 범위
|
||||
* @param {number} [input.chunkSize] - 분할당 게시물 수
|
||||
* @param {number} [input.retentionDays] - 산출물 보존 일수
|
||||
* @returns {Promise<Object>} 생성된 export 작업
|
||||
*/
|
||||
export const createPostExportJob = async (input) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const scope = input.scope === 'author' ? 'author' : 'all'
|
||||
const chunkSize = normalizeChunkSize(input.chunkSize)
|
||||
const retentionDays = normalizeRetentionDays(input.retentionDays)
|
||||
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
|
||||
const expiresAt = new Date(Date.now() + retentionDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const [createdJob] = await sql.begin(async (transaction) => {
|
||||
const [{ count }] = scope === 'author'
|
||||
? await transaction`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM posts
|
||||
WHERE author_id = ${input.requestedBy}
|
||||
`
|
||||
: await transaction`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM posts
|
||||
`
|
||||
const postCount = Number(count || 0)
|
||||
const status = postCount > 0 ? EXPORT_STATUS.QUEUED : EXPORT_STATUS.READY
|
||||
const message = postCount > 0
|
||||
? 'Export 작업이 대기열에 등록되었습니다.'
|
||||
: '내보낼 게시물이 없습니다.'
|
||||
const jobRows = await transaction`
|
||||
INSERT INTO post_export_jobs (
|
||||
requested_by,
|
||||
requested_email,
|
||||
status,
|
||||
scope,
|
||||
post_count,
|
||||
chunk_size,
|
||||
retention_days,
|
||||
expires_at,
|
||||
message,
|
||||
completed_at
|
||||
)
|
||||
VALUES (
|
||||
${input.requestedBy},
|
||||
${input.requestedEmail || ''},
|
||||
${status},
|
||||
${scope},
|
||||
${postCount},
|
||||
${chunkSize},
|
||||
${retentionDays},
|
||||
${expiresAt},
|
||||
${message},
|
||||
${postCount > 0 ? null : new Date()}
|
||||
)
|
||||
RETURNING *
|
||||
`
|
||||
const job = jobRows[0]
|
||||
|
||||
if (postCount > 0) {
|
||||
const partCount = Math.ceil(postCount / chunkSize)
|
||||
const fileInputs = Array.from({ length: partCount }, (_, index) => {
|
||||
const postStart = index * chunkSize + 1
|
||||
const postEnd = Math.min((index + 1) * chunkSize, postCount)
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
partIndex: index + 1,
|
||||
postStart,
|
||||
postEnd,
|
||||
fileName: `${siteName}_${postStart}-${postEnd}.zip`,
|
||||
status: EXPORT_FILE_STATUS.PENDING
|
||||
}
|
||||
})
|
||||
|
||||
for (const fileInput of fileInputs) {
|
||||
await transaction`
|
||||
INSERT INTO post_export_files (
|
||||
job_id,
|
||||
part_index,
|
||||
post_start,
|
||||
post_end,
|
||||
file_name,
|
||||
status
|
||||
)
|
||||
VALUES (
|
||||
${fileInput.jobId},
|
||||
${fileInput.partIndex},
|
||||
${fileInput.postStart},
|
||||
${fileInput.postEnd},
|
||||
${fileInput.fileName},
|
||||
${fileInput.status}
|
||||
)
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
return [job]
|
||||
})
|
||||
|
||||
return (await listPostExportJobs()).find((job) => job.id === createdJob.id) || mapPostExportJobRow({
|
||||
...createdJob,
|
||||
files: []
|
||||
})
|
||||
}
|
||||
13
server/routes/admin/api/posts/export-jobs.get.js
Normal file
13
server/routes/admin/api/posts/export-jobs.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { listPostExportJobs } from '../../../../repositories/post-export-repository'
|
||||
|
||||
/**
|
||||
* 관리자 게시물 Export 작업 목록 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array>} Export 작업 목록
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return listPostExportJobs()
|
||||
})
|
||||
27
server/routes/admin/api/posts/export-jobs.post.js
Normal file
27
server/routes/admin/api/posts/export-jobs.post.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { createPostExportJob } from '../../../../repositories/post-export-repository'
|
||||
|
||||
const postExportJobInputSchema = z.object({
|
||||
chunkSize: z.number().int().min(1).max(500).optional(),
|
||||
retentionDays: z.number().int().min(1).max(100).optional()
|
||||
}).default({})
|
||||
|
||||
/**
|
||||
* 관리자 게시물 Export 작업 요청 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Object>} 생성된 Export 작업
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const adminSession = requireAdminSession(event)
|
||||
const input = postExportJobInputSchema.parse(await readBody(event))
|
||||
|
||||
return createPostExportJob({
|
||||
requestedBy: adminSession.userId,
|
||||
requestedEmail: adminSession.email,
|
||||
scope: 'all',
|
||||
chunkSize: input.chunkSize,
|
||||
retentionDays: input.retentionDays
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user