게시물 Export 용량 기준 분할 추가 v1.5.25

This commit is contained in:
2026-06-01 16:20:35 +09:00
parent 5735fd5046
commit 212bd3f34f
14 changed files with 316 additions and 41 deletions

View File

@@ -18,6 +18,7 @@ MAX_FILE_SIZE=10485760
MAX_VIDEO_FILE_SIZE=209715200 MAX_VIDEO_FILE_SIZE=209715200
MAX_AUDIO_FILE_SIZE=52428800 MAX_AUDIO_FILE_SIZE=52428800
MAX_DOCUMENT_FILE_SIZE=52428800 MAX_DOCUMENT_FILE_SIZE=52428800
POST_EXPORT_MAX_FILE_SIZE_BYTES=524288000
AVATAR_MIN_WIDTH=96 AVATAR_MIN_WIDTH=96
AVATAR_MIN_HEIGHT=96 AVATAR_MIN_HEIGHT=96
AVATAR_MAX_WIDTH=512 AVATAR_MAX_WIDTH=512

View File

@@ -0,0 +1,9 @@
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS max_file_size_bytes BIGINT NOT NULL DEFAULT 524288000;
ALTER TABLE post_export_jobs
ADD COLUMN IF NOT EXISTS error_detail TEXT NOT NULL DEFAULT '';
ALTER TABLE post_export_jobs
ADD CONSTRAINT post_export_jobs_max_file_size_bytes_check
CHECK (max_file_size_bytes >= 10485760);

View File

@@ -1,5 +1,11 @@
# 업데이트 요약 # 업데이트 요약
## v1.5.25
- 게시물 Export 분할을 고정 개수 대신 목표 ZIP 용량 기준으로 나누도록 개선했다.
- Export 요청 시 목표 ZIP 용량과 ZIP당 최대 게시물 수를 지정할 수 있게 했다.
- 실패한 Export 작업의 상세 오류를 관리자 화면에서 확인할 수 있게 했다.
## v1.5.24 ## v1.5.24
- 게시물 Export 준비 완료 파일을 한 번에 순차 다운로드할 수 있게 개선했다. - 게시물 Export 준비 완료 파일을 한 번에 순차 다운로드할 수 있게 개선했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드 # 배포 가이드
> 로컬 기준 v1.5.24에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. > 로컬 기준 v1.5.25에서 `npm run lint`, `npm run build`, `npm run db:migrate:dev` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형 ## 빌드 유형
@@ -233,6 +233,13 @@ docker compose --env-file .env.production up -d --build
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. | | `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
`RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다. `RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
### 게시물 Export 설정(선택)
| 변수 | 설명 |
|------|------|
| `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. |
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용 - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용

View File

@@ -1,5 +1,9 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-01 v1.5.25 — Export 분할은 용량 기준이 우선이다
기존 100개 단위 분할은 대략적인 안내값으로는 충분하지만, 게시물마다 첨부 자산 크기가 다르면 실제 ZIP 용량을 예측하기 어렵다. Export 계획은 게시물 본문 크기와 내부 업로드 자산 크기를 추산해 목표 ZIP 용량을 넘기기 전에 새 파일로 나누도록 바꿨다. ZIP당 최대 게시물 수는 용량 기준을 대신하는 규칙이 아니라 너무 많은 작은 글이 한 파일에 몰리는 상황을 막는 안전 상한으로만 남겼다. 실패 원인은 재시도 여부를 판단하는 운영 정보이므로 작업 카드에서 확인할 수 있게 별도 상세 로그로 저장한다.
## 2026-06-01 v1.5.24 — Export는 완료 후에도 관리 가능한 작업이어야 한다 ## 2026-06-01 v1.5.24 — Export는 완료 후에도 관리 가능한 작업이어야 한다
대용량 Export는 파일 생성이 끝난 뒤에도 운영자가 여러 ZIP을 안정적으로 내려받고, 실패한 지점부터 다시 이어가고, 오래된 산출물을 치울 수 있어야 한다. 브라우저 일괄 다운로드는 준비 완료 파일을 순차로 요청하는 방식으로 두어 중간에 막혀도 개별 파일 버튼으로 이어받을 수 있게 했다. 실패 작업 재시도는 이미 생성된 ZIP을 유지하고 나머지 분할 파일만 다시 대기 상태로 되돌린다. 만료된 완료·실패 작업은 목록 조회나 새 요청 시 자동 삭제해 100일 보존 정책이 실제 저장 공간 정리로 이어지게 했다. 대용량 Export는 파일 생성이 끝난 뒤에도 운영자가 여러 ZIP을 안정적으로 내려받고, 실패한 지점부터 다시 이어가고, 오래된 산출물을 치울 수 있어야 한다. 브라우저 일괄 다운로드는 준비 완료 파일을 순차로 요청하는 방식으로 두어 중간에 막혀도 개별 파일 버튼으로 이어받을 수 있게 했다. 실패 작업 재시도는 이미 생성된 ZIP을 유지하고 나머지 분할 파일만 다시 대기 상태로 되돌린다. 만료된 완료·실패 작업은 목록 조회나 새 요청 시 자동 삭제해 100일 보존 정책이 실제 저장 공간 정리로 이어지게 했다.

View File

@@ -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 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수·최근 작업·진행도·준비 완료 분할 파일 다운로드·브라우저 순차 일괄 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제, 진행 중 요청 버튼 잠금 |
| 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, 이름, 이메일, 레이블, 관리자 노트) |
@@ -307,6 +307,7 @@
| 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 날짜 범위 컬럼 추가 | | db/migrations/042_post_export_date_range.sql | 게시물 Export 날짜 범위 컬럼 추가 |
| db/migrations/043_post_export_size_and_error_detail.sql | 게시물 Export 목표 용량·실패 상세 로그 컬럼 추가 |
## 설정/배포 ## 설정/배포

View File

@@ -285,7 +285,8 @@ components/content/
| post_export_jobs.post_count | Integer | 작업 대상 게시물 수 | | post_export_jobs.post_count | Integer | 작업 대상 게시물 수 |
| post_export_jobs.processed_count | Integer | 처리 완료 게시물 수 | | post_export_jobs.processed_count | Integer | 처리 완료 게시물 수 |
| 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 | ZIP당 최대 게시물 수 안전 상한 |
| post_export_jobs.max_file_size_bytes | BigInt | 분할 ZIP 목표 최대 용량 바이트 |
| 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_from | DateTime nullable | Export 대상 게시물 기준일 시작 시각, null이면 시작 제한 없음 |
| post_export_jobs.date_to | DateTime nullable | Export 대상 게시물 기준일 종료 시각(exclusive), null이면 종료 제한 없음 | | post_export_jobs.date_to | DateTime nullable | Export 대상 게시물 기준일 종료 시각(exclusive), null이면 종료 제한 없음 |
@@ -293,6 +294,7 @@ components/content/
| 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 | 진행 상세 메시지 |
| post_export_jobs.error_detail | Text | 실패 시 상세 오류 로그 |
| post_export_jobs.started_at | DateTime nullable | 작업 시작 시각 | | post_export_jobs.started_at | DateTime nullable | 작업 시작 시각 |
| post_export_files.id | UUID | 분할 파일 ID | | post_export_files.id | UUID | 분할 파일 ID |
| post_export_files.job_id | UUID | FK → PostExportJobs | | post_export_files.job_id | UUID | FK → PostExportJobs |
@@ -516,7 +518,7 @@ 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 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위를 받아 작업 레코드와 100개 단위 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다. - `POST /admin/api/posts/export-jobs` - 게시물 Export 작업 요청. `dateRangeMode=all|year|month|custom`와 연도·월·날짜 범위, `maxFileSizeBytes`, `chunkSize`를 받아 작업 레코드와 용량 기준 분할 파일 계획을 만들고 백그라운드 ZIP 생성을 시작한다.
- `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제 - `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제
- `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도 - `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도
- `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드 - `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드
@@ -694,9 +696,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 요청, 목표 ZIP 용량과 ZIP당 최대 게시물 수 지정, 최근 작업 목록, 진행도, 준비 완료 분할 파일 다운로드, 브라우저 순차 일괄 다운로드, 실패 작업 재시도, 실패 상세 오류, 완료·실패 작업 삭제를 표시하며 대기 중·생성 중 작업이 있으면 새 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`을 갱신한다. Export 대상은 전체 또는 `COALESCE(published_at, created_at)` 기준 특정년·특정월·직접 지정 날짜 범위로 제한할 수 있다. 설정 화면은 대기 중·생성 중 작업이 있을 때 5초 간격으로 작업 목록을 새로고침한다. 파일명은 기본적으로 `사이트명_범위_시작번호-끝번호.zip` 형식을 사용한다(예: `sori.studio_2026-05_1-100.zip`). 각 zip에는 해당 범위 게시물 폴더와 자산 폴더가 들어가며, 준비 완료된 분할 파일은 관리자 다운로드 API로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다. 산출 ZIP 용량 기준 분할은 후속 구현 대상이다. - 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 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로 받을 수 있다. 준비 완료 파일은 일괄 다운로드 버튼으로 브라우저에서 순차 다운로드할 수 있다. 실패 작업은 이미 준비된 파일을 유지하고 실패·대기 파일만 다시 생성하도록 재시도할 수 있으며, 실패 상세 로그는 작업 카드에서 확인한다. Resend 환경 변수가 설정되어 있으면 모든 분할 파일 생성 완료 후 요청 관리자 이메일로 알림을 보낸다. 완료·실패한 작업은 관리자가 즉시 삭제할 수 있고, 삭제 시 DB 레코드와 생성 ZIP 파일을 함께 정리한다. 서버 용량 보호를 위해 export 산출물 보존 기간은 최대 100일로 제한하며, 만료된 완료·실패 작업은 목록 조회나 새 요청 시 생성 ZIP 파일과 함께 자동 정리된다.
- 사이트 설정은 `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`).

View File

@@ -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 대용량 작업 후속 구현: 산출 ZIP 용량 기준 분할 고도화, Export 작업 실패 원인 상세 로그 보관 - [ ] 게시물 Import 1차 구현 전에 Export ZIP 샘플을 실제 Obsidian vault에서 열어 경로·frontmatter 호환성 확인
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토 - [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
## 2차 관리자 개발 ## 2차 관리자 개발

View File

@@ -1,5 +1,12 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.25
- 게시물 Export: 분할 ZIP 계획을 고정 100개 단위가 아니라 목표 ZIP 용량 기준으로 나누도록 수정.
- 게시물 Export: 설정 화면에서 목표 ZIP 용량과 ZIP당 최대 게시물 수를 지정할 수 있도록 추가.
- 게시물 Export: 작업 레코드에 `max_file_size_bytes`, `error_detail` 저장 추가.
- 게시물 Export: 실패 작업 카드에서 상세 오류 원인을 확인할 수 있도록 추가.
## v1.5.24 ## v1.5.24
- 게시물 Export: 준비 완료 분할 파일을 브라우저에서 순차적으로 일괄 다운로드하는 버튼 추가. - 게시물 Export: 준비 완료 분할 파일을 브라우저에서 순차적으로 일괄 다운로드하는 버튼 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.5.24", "version": "1.5.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "1.5.24", "version": "1.5.25",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.5.24", "version": "1.5.25",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -29,6 +29,8 @@ const postExportYear = ref(new Date().getFullYear())
const postExportMonth = ref(new Date().getMonth() + 1) const postExportMonth = ref(new Date().getMonth() + 1)
const postExportDateFrom = ref('') const postExportDateFrom = ref('')
const postExportDateTo = ref('') const postExportDateTo = ref('')
const postExportChunkSize = ref(500)
const postExportMaxFileSizeMb = ref(500)
const errorMessage = ref('') const errorMessage = ref('')
const toast = ref(null) const toast = ref(null)
const logoInputRef = ref(null) const logoInputRef = ref(null)
@@ -212,6 +214,15 @@ const canRequestPostExport = computed(() => {
return false return false
} }
const hasValidSplitOptions = postExportChunkSize.value >= 1
&& postExportChunkSize.value <= 500
&& postExportMaxFileSizeMb.value >= 10
&& postExportMaxFileSizeMb.value <= 2048
if (!hasValidSplitOptions) {
return false
}
if (postExportDateRangeMode.value === 'custom') { if (postExportDateRangeMode.value === 'custom') {
return Boolean( return Boolean(
postExportDateFrom.value postExportDateFrom.value
@@ -236,6 +247,10 @@ const postExportRequestTitle = computed(() => {
return '올바른 시작일과 종료일을 선택해 주세요.' return '올바른 시작일과 종료일을 선택해 주세요.'
} }
if (!canRequestPostExport.value) {
return 'ZIP당 최대 게시물 수와 목표 용량을 확인해 주세요.'
}
return '게시물 Export 작업을 요청합니다.' return '게시물 Export 작업을 요청합니다.'
}) })
@@ -245,7 +260,8 @@ const postExportRequestTitle = computed(() => {
*/ */
const createPostExportRequestBody = () => { const createPostExportRequestBody = () => {
const base = { const base = {
chunkSize: 100, chunkSize: Number(postExportChunkSize.value),
maxFileSizeBytes: Number(postExportMaxFileSizeMb.value) * 1024 * 1024,
retentionDays: 100, retentionDays: 100,
dateRangeMode: postExportDateRangeMode.value dateRangeMode: postExportDateRangeMode.value
} }
@@ -2023,7 +2039,7 @@ onBeforeUnmount(() => {
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 백업을 만듭니다. 전체·연도··직접 범위로 게시물을 골라 목표 용량 기준 ZIP 백업을 만듭니다.
</p> </p>
</div> </div>
<div class="grid gap-3"> <div class="grid gap-3">
@@ -2099,6 +2115,30 @@ onBeforeUnmount(() => {
> >
</label> </label>
</div> </div>
<div class="admin-settings-screen__export-split grid gap-2 md:grid-cols-2">
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
목표 ZIP 용량 (MB)
<input
v-model.number="postExportMaxFileSizeMb"
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="number"
min="10"
max="2048"
step="10"
>
</label>
<label class="grid gap-1 text-xs font-semibold text-[#5d6673]">
ZIP당 최대 게시물
<input
v-model.number="postExportChunkSize"
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="number"
min="1"
max="500"
step="1"
>
</label>
</div>
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button <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]" 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]"
@@ -2160,6 +2200,9 @@ onBeforeUnmount(() => {
<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>
<p class="mt-1 text-xs text-[#9aa3ad]">
목표 용량 {{ formatExportFileSize(job.maxFileSizeBytes) }} · 최대 {{ job.chunkSize }}/ZIP
</p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<button <button
@@ -2211,6 +2254,16 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<details
v-if="job.status === 'failed' && job.errorDetail"
class="admin-settings-screen__export-error mt-4 rounded-lg border border-[#ffd5d5] bg-[#fff7f7] p-3 text-xs text-[#8f2d2d]"
>
<summary class="cursor-pointer font-semibold">
실패 원인 보기
</summary>
<pre class="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">{{ job.errorDetail }}</pre>
</details>
<div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]"> <div v-if="job.files.length > 0" class="admin-settings-screen__export-files mt-4 overflow-hidden rounded-md border border-[#edf0f3]">
<div <div
v-for="file in job.files" v-for="file in job.files"

View File

@@ -20,9 +20,12 @@ const EXPORT_FILE_STATUS = {
FAILED: 'failed' FAILED: 'failed'
} }
const DEFAULT_CHUNK_SIZE = 100 const DEFAULT_CHUNK_SIZE = 500
const MAX_CHUNK_SIZE = 500 const MAX_CHUNK_SIZE = 500
const DEFAULT_RETENTION_DAYS = 100 const DEFAULT_RETENTION_DAYS = 100
const DEFAULT_MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024
const MIN_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024
const MAX_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads') const UPLOAD_ROOT = join(process.cwd(), 'public', 'uploads')
const EXPORT_ROOT_NAME = 'exports' const EXPORT_ROOT_NAME = 'exports'
const runningPostExportJobIds = new Set() const runningPostExportJobIds = new Set()
@@ -162,6 +165,13 @@ const collectLocalUploadUrls = (content) => {
return [...new Set(matches.filter(isLocalUploadUrl))] return [...new Set(matches.filter(isLocalUploadUrl))]
} }
/**
* UTF-8 바이트 길이를 계산한다.
* @param {unknown} value - 원본 값
* @returns {number} 바이트 길이
*/
const getUtf8ByteLength = (value) => Buffer.byteLength(String(value || ''), 'utf8')
/** /**
* 게시물 frontmatter를 만든다. * 게시물 frontmatter를 만든다.
* @param {Object} post - 게시물 * @param {Object} post - 게시물
@@ -296,6 +306,7 @@ const mapPostExportJobRow = (row) => ({
processedCount: Number(row.processed_count || 0), processedCount: Number(row.processed_count || 0),
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),
maxFileSizeBytes: Number(row.max_file_size_bytes || DEFAULT_MAX_FILE_SIZE_BYTES),
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, dateFrom: row.date_from ? row.date_from.toISOString() : null,
dateTo: row.date_to ? row.date_to.toISOString() : null, dateTo: row.date_to ? row.date_to.toISOString() : null,
@@ -303,6 +314,7 @@ const mapPostExportJobRow = (row) => ({
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 || '',
errorDetail: row.error_detail || '',
createdAt: row.created_at.toISOString(), createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(), updatedAt: row.updated_at.toISOString(),
startedAt: row.started_at ? row.started_at.toISOString() : null, startedAt: row.started_at ? row.started_at.toISOString() : null,
@@ -360,6 +372,21 @@ 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)
} }
/**
* Export 분할 ZIP 최대 용량을 안전 범위로 보정한다.
* @param {unknown} value - 입력 바이트
* @returns {number} 보정된 최대 용량
*/
const normalizeMaxFileSizeBytes = (value) => {
const parsed = Number(value || getRuntimeEnvValue('POST_EXPORT_MAX_FILE_SIZE_BYTES', 'postExportMaxFileSizeBytes', DEFAULT_MAX_FILE_SIZE_BYTES))
if (!Number.isFinite(parsed)) {
return DEFAULT_MAX_FILE_SIZE_BYTES
}
return Math.min(Math.max(Math.trunc(parsed), MIN_MAX_FILE_SIZE_BYTES), MAX_MAX_FILE_SIZE_BYTES)
}
/** /**
* 날짜 입력을 안전하게 보정한다. * 날짜 입력을 안전하게 보정한다.
* @param {unknown} value - 날짜 입력 * @param {unknown} value - 날짜 입력
@@ -477,6 +504,161 @@ const getExportSiteName = async (sql) => {
return rows[0]?.title || getDefaultSiteSettings().title return rows[0]?.title || getDefaultSiteSettings().title
} }
/**
* 게시물에 포함된 내부 업로드 파일 크기를 추산한다.
* @param {Object} post - 게시물
* @returns {Promise<number>} 내부 자산 바이트 합계
*/
const estimatePostAssetBytes = async (post) => {
const urls = new Set(collectLocalUploadUrls(post.content))
if (isLocalUploadUrl(post.featured_image)) {
urls.add(post.featured_image)
}
if (isLocalUploadUrl(post.og_image)) {
urls.add(post.og_image)
}
let total = 0
for (const url of urls) {
const diskPath = resolveUploadUrlToDiskPath(url)
if (!diskPath) {
continue
}
try {
const fileStat = await stat(diskPath)
if (fileStat.isFile()) {
total += fileStat.size
}
} catch {}
}
return total
}
/**
* 게시물 하나가 ZIP 안에서 차지할 대략적인 크기를 추산한다.
* @param {Object} post - 게시물
* @returns {Promise<number>} 추산 바이트
*/
const estimatePostExportBytes = async (post) => {
const frontmatterReserve = 4096
const zipEntryReserve = 2048
const contentBytes = getUtf8ByteLength(post.content)
const metaBytes = getUtf8ByteLength([
post.title,
post.slug,
post.excerpt,
post.seo_title,
post.seo_description,
post.canonical_url
].join('\n'))
const assetBytes = await estimatePostAssetBytes(post)
return Math.max(frontmatterReserve + zipEntryReserve + contentBytes + metaBytes + assetBytes, 1)
}
/**
* Export 계획 대상 게시물을 순서대로 조회한다.
* @param {ReturnType<import('postgres')>} sql - PostgreSQL 클라이언트
* @param {Object} input - 조회 입력
* @returns {Promise<Array>} 계획 대상 게시물 목록
*/
const listPostsForExportPlanning = async (sql, input) => {
const dateCondition = createPostExportDateCondition(sql, input.exportDateRange)
if (input.scope === 'author') {
return sql`
SELECT
id,
title,
slug,
content,
excerpt,
seo_title,
seo_description,
canonical_url,
featured_image,
og_image,
COALESCE(published_at, created_at) AS export_date
FROM posts
WHERE author_id = ${input.requestedBy}
AND ${dateCondition}
ORDER BY COALESCE(published_at, created_at) ASC, id ASC
`
}
return sql`
SELECT
id,
title,
slug,
content,
excerpt,
seo_title,
seo_description,
canonical_url,
featured_image,
og_image,
COALESCE(published_at, created_at) AS export_date
FROM posts
WHERE ${dateCondition}
ORDER BY COALESCE(published_at, created_at) ASC, id ASC
`
}
/**
* 게시물 수와 추산 용량을 함께 사용해 Export 분할 계획을 만든다.
* @param {Array<Object>} posts - 순서가 확정된 게시물 목록
* @param {Object} input - 계획 옵션
* @returns {Promise<Array<{ partIndex: number, postStart: number, postEnd: number }>>} 분할 계획
*/
const createPostExportFilePlan = async (posts, input) => {
const plan = []
let currentStart = 1
let currentEnd = 0
let currentCount = 0
let currentBytes = 0
for (let index = 0; index < posts.length; index += 1) {
const postNumber = index + 1
const postBytes = await estimatePostExportBytes(posts[index])
const shouldStartNext = currentCount > 0 && (
currentCount >= input.chunkSize
|| currentBytes + postBytes > input.maxFileSizeBytes
)
if (shouldStartNext) {
plan.push({
partIndex: plan.length + 1,
postStart: currentStart,
postEnd: currentEnd
})
currentStart = postNumber
currentCount = 0
currentBytes = 0
}
currentEnd = postNumber
currentCount += 1
currentBytes += postBytes
}
if (currentCount > 0) {
plan.push({
partIndex: plan.length + 1,
postStart: currentStart,
postEnd: currentEnd
})
}
return plan
}
/** /**
* Export 완료 안내 이메일을 보낸다. * Export 완료 안내 이메일을 보낸다.
* @param {Object} job - Export 작업 * @param {Object} job - Export 작업
@@ -639,6 +821,7 @@ export const listQueuedPostExportJobIds = async () => {
* @param {Date|string|null} [input.dateTo] - 종료일 * @param {Date|string|null} [input.dateTo] - 종료일
* @param {string} [input.rangeLabel] - 범위 라벨 * @param {string} [input.rangeLabel] - 범위 라벨
* @param {number} [input.chunkSize] - 분할당 게시물 수 * @param {number} [input.chunkSize] - 분할당 게시물 수
* @param {number} [input.maxFileSizeBytes] - 분할 ZIP 최대 추산 용량
* @param {number} [input.retentionDays] - 산출물 보존 일수 * @param {number} [input.retentionDays] - 산출물 보존 일수
* @returns {Promise<Object>} 생성된 export 작업 * @returns {Promise<Object>} 생성된 export 작업
*/ */
@@ -653,6 +836,7 @@ 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 maxFileSizeBytes = normalizeMaxFileSizeBytes(input.maxFileSizeBytes)
const retentionDays = normalizeRetentionDays(input.retentionDays) const retentionDays = normalizeRetentionDays(input.retentionDays)
const exportDateRange = normalizeExportDateRange(input) const exportDateRange = normalizeExportDateRange(input)
const siteName = sanitizeFilenameSegment(await getExportSiteName(sql)) const siteName = sanitizeFilenameSegment(await getExportSiteName(sql))
@@ -660,20 +844,18 @@ export const createPostExportJob = async (input) => {
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 planningPosts = await listPostsForExportPlanning(transaction, {
const [{ count }] = scope === 'author' scope,
? await transaction` requestedBy: input.requestedBy,
SELECT COUNT(*)::int AS count exportDateRange
FROM posts })
WHERE author_id = ${input.requestedBy} const postCount = planningPosts.length
AND ${dateCondition} const filePlan = postCount > 0
` ? await createPostExportFilePlan(planningPosts, {
: await transaction` chunkSize,
SELECT COUNT(*)::int AS count maxFileSizeBytes
FROM posts })
WHERE ${dateCondition} : []
`
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
const message = postCount > 0 const message = postCount > 0
? 'Export 작업이 대기열에 등록되었습니다.' ? 'Export 작업이 대기열에 등록되었습니다.'
@@ -686,6 +868,7 @@ export const createPostExportJob = async (input) => {
scope, scope,
post_count, post_count,
chunk_size, chunk_size,
max_file_size_bytes,
retention_days, retention_days,
date_from, date_from,
date_to, date_to,
@@ -701,6 +884,7 @@ export const createPostExportJob = async (input) => {
${scope}, ${scope},
${postCount}, ${postCount},
${chunkSize}, ${chunkSize},
${maxFileSizeBytes},
${retentionDays}, ${retentionDays},
${exportDateRange.dateFrom}, ${exportDateRange.dateFrom},
${exportDateRange.dateTo}, ${exportDateRange.dateTo},
@@ -714,20 +898,14 @@ export const createPostExportJob = async (input) => {
const job = jobRows[0] const job = jobRows[0]
if (postCount > 0) { if (postCount > 0) {
const partCount = Math.ceil(postCount / chunkSize) const fileInputs = filePlan.map((part) => ({
const fileInputs = Array.from({ length: partCount }, (_, index) => {
const postStart = index * chunkSize + 1
const postEnd = Math.min((index + 1) * chunkSize, postCount)
return {
jobId: job.id, jobId: job.id,
partIndex: index + 1, partIndex: part.partIndex,
postStart, postStart: part.postStart,
postEnd, postEnd: part.postEnd,
fileName: `${siteName}_${fileRangeName}_${postStart}-${postEnd}.zip`, fileName: `${siteName}_${fileRangeName}_${part.postStart}-${part.postEnd}.zip`,
status: EXPORT_FILE_STATUS.PENDING status: EXPORT_FILE_STATUS.PENDING
} }))
})
for (const fileInput of fileInputs) { for (const fileInput of fileInputs) {
await transaction` await transaction`
@@ -808,6 +986,7 @@ const markPostExportJobProcessing = async (sql, jobId) => {
SET status = ${EXPORT_STATUS.PROCESSING}, SET status = ${EXPORT_STATUS.PROCESSING},
started_at = COALESCE(started_at, now()), started_at = COALESCE(started_at, now()),
progress_message = 'Export 파일 생성을 시작했습니다.', progress_message = 'Export 파일 생성을 시작했습니다.',
error_detail = '',
updated_at = now() updated_at = now()
WHERE id = ${jobId} WHERE id = ${jobId}
AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING}) AND status IN (${EXPORT_STATUS.QUEUED}, ${EXPORT_STATUS.PROCESSING})
@@ -846,6 +1025,7 @@ const markPostExportJobReady = async (sql, jobId, postCount) => {
current_part_index = null, current_part_index = null,
message = '게시물 Export 파일 생성이 완료되었습니다.', message = '게시물 Export 파일 생성이 완료되었습니다.',
progress_message = '생성이 완료되었습니다.', progress_message = '생성이 완료되었습니다.',
error_detail = '',
completed_at = now(), completed_at = now(),
updated_at = now() updated_at = now()
WHERE id = ${jobId} WHERE id = ${jobId}
@@ -861,12 +1041,14 @@ const markPostExportJobReady = async (sql, jobId, postCount) => {
*/ */
const markPostExportJobFailed = async (sql, jobId, error) => { const markPostExportJobFailed = async (sql, jobId, error) => {
const message = error instanceof Error ? error.message : '알 수 없는 오류' const message = error instanceof Error ? error.message : '알 수 없는 오류'
const stack = error instanceof Error && error.stack ? error.stack : message
await sql` await sql`
UPDATE post_export_jobs UPDATE post_export_jobs
SET status = ${EXPORT_STATUS.FAILED}, SET status = ${EXPORT_STATUS.FAILED},
message = ${`게시물 Export 생성 실패: ${message}`}, message = ${`게시물 Export 생성 실패: ${message}`},
progress_message = ${message}, progress_message = ${message},
error_detail = ${stack.slice(0, 8000)},
current_part_index = null, current_part_index = null,
completed_at = now(), completed_at = now(),
updated_at = now() updated_at = now()
@@ -1144,6 +1326,7 @@ export const retryPostExportJob = async (jobId) => {
SET status = ${EXPORT_STATUS.QUEUED}, SET status = ${EXPORT_STATUS.QUEUED},
message = '실패한 Export 작업을 다시 대기열에 등록했습니다.', message = '실패한 Export 작업을 다시 대기열에 등록했습니다.',
progress_message = '준비 완료 파일은 유지하고 실패 지점부터 다시 생성합니다.', progress_message = '준비 완료 파일은 유지하고 실패 지점부터 다시 생성합니다.',
error_detail = '',
current_part_index = null, current_part_index = null,
completed_at = null, completed_at = null,
updated_at = now() updated_at = now()

View File

@@ -13,6 +13,7 @@ const postExportJobInputSchema = z.object({
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
dateTo: 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(),
maxFileSizeBytes: z.number().int().min(10485760).max(2147483648).optional(),
retentionDays: z.number().int().min(1).max(100).optional() retentionDays: z.number().int().min(1).max(100).optional()
}).default({}) }).default({})
@@ -120,6 +121,7 @@ export default defineEventHandler(async (event) => {
dateTo: dateRange.dateTo, dateTo: dateRange.dateTo,
rangeLabel: dateRange.rangeLabel, rangeLabel: dateRange.rangeLabel,
chunkSize: input.chunkSize, chunkSize: input.chunkSize,
maxFileSizeBytes: input.maxFileSizeBytes,
retentionDays: input.retentionDays retentionDays: input.retentionDays
}) })