From ef7c1216e48a0cf27100c201d60cba4591f4f2fc Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 23 Jul 2026 18:35:29 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EC=84=BC?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/migrations/070_post_import_jobs.sql | 48 ++ docs/deploy.md | 4 +- docs/history.md | 4 + docs/map.md | 10 +- docs/spec.md | 8 +- docs/todo.md | 3 - docs/update.md | 10 + package-lock.json | 4 +- package.json | 2 +- pages/admin/settings/index.vue | 379 ++++++++-- server/plugins/post-import-worker.js | 22 + server/repositories/post-import-repository.js | 691 +++++++++++++++++- .../routes/admin/api/posts/import-jobs.get.js | 19 + .../api/posts/import-jobs/[jobId].delete.js | 32 + server/routes/admin/api/posts/import.post.js | 35 +- server/routes/uploads/[...path].get.js | 2 +- 16 files changed, 1178 insertions(+), 95 deletions(-) create mode 100644 db/migrations/070_post_import_jobs.sql create mode 100644 server/plugins/post-import-worker.js create mode 100644 server/routes/admin/api/posts/import-jobs.get.js create mode 100644 server/routes/admin/api/posts/import-jobs/[jobId].delete.js diff --git a/db/migrations/070_post_import_jobs.sql b/db/migrations/070_post_import_jobs.sql new file mode 100644 index 0000000..1d22a0b --- /dev/null +++ b/db/migrations/070_post_import_jobs.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS post_import_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 '', + original_file_name TEXT NOT NULL DEFAULT '', + zip_file_path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'queued', + total_count INTEGER NOT NULL DEFAULT 0, + processed_count INTEGER NOT NULL DEFAULT 0, + imported_count INTEGER NOT NULL DEFAULT 0, + skipped_count INTEGER NOT NULL DEFAULT 0, + failed_count INTEGER NOT NULL DEFAULT 0, + asset_count INTEGER NOT NULL DEFAULT 0, + reused_asset_count INTEGER NOT NULL DEFAULT 0, + warning_count INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + error_detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + CONSTRAINT post_import_jobs_status_check CHECK (status IN ('queued', 'processing', 'ready', 'failed')) +); + +CREATE INDEX IF NOT EXISTS post_import_jobs_status_created_at_idx + ON post_import_jobs (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS post_import_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES post_import_jobs(id) ON DELETE CASCADE, + item_index INTEGER NOT NULL, + title TEXT NOT NULL DEFAULT '', + slug TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + progress INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '', + post_id UUID REFERENCES posts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + CONSTRAINT post_import_items_status_check CHECK (status IN ('pending', 'processing', 'ready', 'skipped', 'failed')), + CONSTRAINT post_import_items_progress_check CHECK (progress >= 0 AND progress <= 100), + CONSTRAINT post_import_items_job_index_unique UNIQUE (job_id, item_index) +); + +CREATE INDEX IF NOT EXISTS post_import_items_job_id_item_index_idx + ON post_import_items (job_id, item_index ASC); diff --git a/docs/deploy.md b/docs/deploy.md index 5eec68e..8c74ad0 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> v1.11.0은 v1.10 개발 구간을 마감한 minor 릴리스다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile → (선택) shadow 관찰 → `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports` 쓰기 권한을 확인한다. +> v1.11.0은 v1.10 개발 구간을 마감한 minor 릴리스다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile → (선택) shadow 관찰 → `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. > 로컬 기준 v1.11.2에서 `npm run lint`와 `npm run build` 검증을 통과했다. ## 빌드 유형 @@ -311,7 +311,7 @@ docker compose --env-file .env.production up -d --build |------|------| | `POST_EXPORT_MAX_FILE_SIZE_BYTES` | 게시물 Export 분할 ZIP 목표 최대 용량. 기본값은 500MB이며 관리자 설정 화면 요청값이 있으면 그 값을 우선 사용한다. | -- 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다. +- 게시물 Import는 관리자 설정의 Import 패널에서 Export ZIP 파일을 업로드해 작업으로 실행한다. 1회 업로드 파일은 300MB 이하, Markdown 게시물은 최대 1000개까지 처리한다. 운영 배포 전 `070_post_import_jobs.sql` 적용과 `public/uploads/_import-jobs` 쓰기 권한을 확인한다. - 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계 - NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성 diff --git a/docs/history.md b/docs/history.md index 3013773..23b9221 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-23 v1.11.18 — 게시물 Import는 작업 단위로 상태를 남긴다 + +대량 ZIP Import를 단일 HTTP 요청 안에서 끝내면 브라우저 종료, 네트워크 단절, 서버 재시작 시 사용자가 어느 게시물까지 처리됐는지 확인하기 어렵다. 게시물 생성은 자산 저장 이후에만 수행하되, 참조 자산이 누락된 게시물은 불완전한 글을 만들지 않고 게시물별 오류로 남긴다. Import도 Export처럼 PostgreSQL job/item 테이블과 advisory lock 기반 워커를 사용해 진행 상태를 남기고, 같은 slug는 재시도 시에도 스킵해 중복 게시물 생성을 막는다. + ## 2026-07-23 v1.11.3 — 구형 블록 에디터 파일을 제거한다 Markdown-first 에디터가 현재 게시물 작성 흐름을 담당하고 있고, `AdminBlockEditor.vue`는 실제 코드에서 더 이상 참조되지 않는다. 이전 동작을 되돌리기 위한 맥락은 Git 이력과 문서에 남아 있으므로, 실행 경로에 연결되지 않은 큰 컴포넌트 파일을 계속 보관하기보다 제거해 관리자 에디터 구조를 단순하게 유지한다. diff --git a/docs/map.md b/docs/map.md index 282c778..bec2d6f 100644 --- a/docs/map.md +++ b/docs/map.md @@ -258,7 +258,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형 전체 화면, 카드별 필드만 PATCH하고 성공 필드만 폼에 반영하는 독립 저장, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **백업 현황** 텍스트 카드(콘텐츠 수량·미디어 용량·자동 생성 이미지·내보내기 파일·큰 파일), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·완료 요약·누락 자산 경고 표시, 진행 중 요청 버튼 잠금 | +| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, 카드별 필드만 PATCH하고 성공 필드만 폼에 반영하는 독립 저장, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 톤), 블로그 제목·설명 읽기 행, **사이트 정보**(로고 미등록 점선 박스·URL·저작권 읽기 행), **SNS 정보**(아이콘 프리셋+주소 목록형 관리), **메인 화면**(라이트·다크 커버 상하 개별 프리뷰·드롭존 업로드·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·hex 배경색·텍스트 정렬·읽기 모드 비활성 톤), **스팸 필터**(가입 금지 닉네임), **백업 현황** 텍스트 카드(콘텐츠 수량·미디어 용량·자동 생성 이미지·내보내기 파일·큰 파일), **게시물 내보내기** 독립 카드와 펼침형 전체·연도·월·직접 날짜 범위 작업 요청·목표 ZIP 용량·ZIP당 최대 게시물 수, 작업이 있을 때만 표시되는 **최근 내보내기 작업** 별도 카드(준비 완료 배지 숨김·진행 중 진행도·만료일·파일 체크 선택·전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·작업 삭제), **게시물 가져오기** 독립 카드와 펼침형 ZIP 드롭존·적용 버튼·작업 센터 모달(전체 진행률·게시물별 대기/진행중/완료/스킵/오류·완료 삭제), 진행 중 요청 버튼 잠금 | | lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | @@ -332,7 +332,9 @@ | server/routes/admin/api/posts/export-jobs/[jobId].delete.js | 관리자 게시물 Export 작업·생성 ZIP 삭제 API | | server/routes/admin/api/posts/export-jobs/[jobId]/retry.post.js | 관리자 게시물 Export 실패 작업 재시도 API | | server/routes/admin/api/posts/export-jobs/[fileId]/download.get.js | 관리자 게시물 Export 분할 ZIP 다운로드 API | -| server/routes/admin/api/posts/import.post.js | 관리자 게시물 Export ZIP Import API | +| server/routes/admin/api/posts/import.post.js | 관리자 게시물 Export ZIP Import 작업 생성 API | +| server/routes/admin/api/posts/import-jobs.get.js | 관리자 게시물 Import 작업 목록 조회·대기 작업 실행 예약 API | +| server/routes/admin/api/posts/import-jobs/[jobId].delete.js | 관리자 게시물 Import 완료·실패 작업 삭제 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 | @@ -401,7 +403,8 @@ | server/repositories/postgres-client.js | PostgreSQL 클라이언트 | | server/repositories/content-repository.js | 콘텐츠 조회 저장소 | | server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·원본 자산 store 방식 스트리밍 ZIP 생성, PostgreSQL advisory lock 기반 다중 인스턴스 단일 worker 저장소 | -| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성) | +| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성·Import job/item 상태 관리·작업 재개) | +| server/plugins/post-import-worker.js | 대기·중단된 게시물 Import 작업을 주기적으로 백그라운드 실행 | | server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/comment-repository.js | 댓글 조회/생성/좋아요 저장소, 공개·멤버십 게시물 접근 조건 통합 | | server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 | @@ -409,6 +412,7 @@ | server/utils/adsense-report.js | AdSense OAuth access token 발급·보고서 조회·대시보드 요약 생성 | | server/utils/zip-writer.js | `archiver` 기반 게시물 Export ZIP 디스크 스트리밍 생성 유틸리티 | | server/utils/zip-reader.js | `unzipper` 기반 게시물 Import ZIP 중앙 디렉터리·엔트리 제한 검증 및 스트림 유틸리티 | +| db/migrations/070_post_import_jobs.sql | 게시물 Import 작업·게시물별 상태 테이블 | | server/utils/multipart-file-upload.js | `busboy` 기반 단일·다중 multipart 파일 수·개별/전체 용량 제한과 임시 디스크 스트리밍 유틸리티 | | server/api/analytics/pageview.post.js | 공개 통계 수집 API | | server/api/analytics/heartbeat.post.js | 공개 heartbeat·체류·스크롤 수집 API | diff --git a/docs/spec.md b/docs/spec.md index 65ef042..fdab7b6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -684,7 +684,9 @@ components/content/ - `DELETE /admin/api/posts/export-jobs/:jobId` - 완료·실패한 게시물 Export 작업과 생성 ZIP 파일 삭제 - `POST /admin/api/posts/export-jobs/:jobId/retry` - 실패한 게시물 Export 작업을 준비 완료 파일은 유지한 채 나머지 분할 파일부터 재시도 - `GET /admin/api/posts/export-jobs/:fileId/download` - 준비 완료된 게시물 Export 분할 ZIP 파일 다운로드 -- `POST /admin/api/posts/import` - Export ZIP 파일 Import. multipart `file` 필드의 ZIP을 최대 300MB까지 임시 파일로 스트리밍하고 Markdown frontmatter를 게시물로 생성하며, ZIP 내부 `images/`·`files/` 자산을 엔트리별 스트림으로 업로드 URL에 저장한다. 같은 `slug`의 기존 게시물 또는 ZIP 내부 중복 게시물은 새 slug로 복제하지 않고 건너뛴다. 건너뛴 게시물의 자산은 저장하지 않으며, 같은 ZIP 자산 또는 같은 해시의 기존 저장 파일은 재사용한다. 엔트리는 최대 5,000개, 개별 300MB, 전체 비압축 2GB, 압축률 250배, Markdown 개별 5MB·합계 50MB로 제한한다. 응답은 `importedCount`, `skippedCount`, `assetCount`, `reusedAssetCount`, `warningCount`, `warnings`, `posts`, `skippedPosts`를 반환한다. +- `POST /admin/api/posts/import` - Export ZIP 파일 Import 작업 생성. multipart `file` 필드의 ZIP을 최대 300MB까지 작업 폴더에 스트리밍하고 `post_import_jobs` 작업을 생성한 뒤 즉시 작업 객체를 반환한다. 실제 Markdown 복원은 백그라운드 워커가 처리한다. +- `GET /admin/api/posts/import-jobs` - 최근 게시물 Import 작업 목록 조회. 대기 중이거나 중단된 작업이 있으면 백그라운드 실행을 다시 예약하고, 작업별 게시물 행(`items`)을 함께 반환한다. +- `DELETE /admin/api/posts/import-jobs/:jobId` - 완료·실패한 게시물 Import 작업과 보관 ZIP 삭제. 대기·진행 중 작업은 삭제하지 않는다. - `GET /admin/api/pages` - 고정 페이지 목록 - `POST /admin/api/pages` - 고정 페이지 작성 - `GET /admin/api/pages/:id` - 고정 페이지 상세 @@ -881,8 +883,8 @@ components/content/ ### 사이트 설정 -- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 콘텐츠·안전 영역의 **사이트 용량**은 그래프 없이 텍스트 카드로 표시하고, 기준 시각·콘텐츠 수량·파일 용량을 새로고침할 수 있다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import API를 호출한다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **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/posts/YYYY/MM/`에 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 기존 ZIP처럼 `show_featured_image`가 없으면 `false`로 가져온다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰거나 `slug-2`처럼 복제하지 않고 건너뛰며, 해당 게시물의 자산도 다시 저장하지 않는다. 같은 Import 실행 안에서 여러 게시물이 같은 ZIP 자산을 참조하면 한 번 저장한 URL을 재사용하고, 재시도 중 같은 파일명 계열과 같은 해시의 자산이 이미 있으면 기존 파일 URL을 재사용한다. ZIP 안에서 자산을 찾지 못한 경우 Import는 계속 진행하되 응답 경고로 누락 경로를 알려 준다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다. +- 관리자 사이트 설정 UI는 `/admin/settings`에서 제공한다. Ghost Admin과 유사하게 **전체 화면**으로 표시하며, 좌측 내비와 우측 본문을 **한 덩어리로 중앙 정렬**(`max-w` 래퍼, 본문 카드 영역은 약 760px 상한)하고, 페이지 배경은 밝은 회색·본문 열은 흰색으로 구분한다. 우측 본문을 스크롤하면 현재 보이는 구역에 맞춰 좌측 메뉴 활성 배경을 갱신한다. **우측 상단 고정** 닫기 버튼과 `Escape` 키로 설정 화면을 닫으며, 브라우저 히스토리가 있으면 `뒤로 가기`, 없으면 `/admin`으로 이동한다. 콘텐츠·안전 영역의 **사이트 용량**은 그래프 없이 텍스트 카드로 표시하고, 기준 시각·콘텐츠 수량·파일 용량을 새로고침할 수 있다. 게시물 백업 도구는 `게시물 내보내기`와 `게시물 가져오기` 독립 카드로 표시한다. 내보내기 기본 화면은 제목·설명·내보내기 버튼만 표시하고, 상세 설정은 버튼을 눌렀을 때만 펼쳐진다. 내보내기 상세는 전체·특정년·특정월·직접 지정 범위, 목표 ZIP 용량, ZIP당 최대 게시물 수 지정을 제공한다. 내보내기 작업 목록은 작업이 있을 때만 내보내기 설정 카드 아래 별도 카드로 표시하며, 완료 작업은 상태 배지를 숨기고 만료일과 준비 완료 파일 선택 목록을 중심으로 표시한다. 전체 선택·선택 파일 다운로드·실패 작업 재시도·실패 상세 오류·완료/실패 작업 삭제를 제공하며, 진행도 영역은 대기·생성 중 작업에서만 표시한다. 가져오기 기본 화면은 제목·설명·가져오기 버튼만 표시하고, 버튼을 누르면 ZIP 드롭존과 `적용` 버튼이 펼쳐진다. 가져오기는 파일 선택만으로 실행하지 않고 `적용` 버튼을 눌렀을 때만 Import 작업 생성 API를 호출한다. 생성된 Import 작업은 작업 센터 모달에서 전체 진행률과 게시물별 대기·진행중·완료·스킵·오류 상태를 표로 표시하며, 완료·실패 작업 기록은 모달에서 삭제할 수 있다. 대기 중·생성 중 작업이 있으면 새 내보내기 요청 버튼을 비활성화한다. **사이트 코드** 카드는 ads.txt, 공통 헤더 코드, 공통 푸터 코드의 등록 여부를 라벨/값 세로 행으로 보여 주고, `편집`을 눌렀을 때만 textarea와 저장/취소가 나타난다. **스팸 필터**는 가입 금지 닉네임을 저장한다. **블로그 제목·설명** 카드는 기본적으로 사이트 이름·설명을 라벨/값 세로 행으로 읽기 전용 표시하고, `편집`을 눌렀을 때만 입력 필드·미리보기·저장/취소가 나타난다. 제목·설명 편집 중 `Escape`는 설정 닫기 대신 편집 취소로 동작한다. **사이트 정보** 카드는 로고, 공개 URL, 푸터 저작권 문구를 관리하며, 읽기 화면의 로고는 등록 상태 문구 없이 이미지 또는 점선 미등록 박스로 표시한다. **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/posts/YYYY/MM/`에 저장한 뒤 본문 경로를 새 `/uploads/...` URL로 다시 매핑한다. 태그는 inline 배열(`tags: ["a"]`)과 Obsidian식 블록 배열(`tags:\n- a`)을 모두 읽는다. 기존 ZIP처럼 `show_featured_image`가 없으면 `false`로 가져온다. 같은 슬러그가 이미 있으면 기존 글을 덮어쓰거나 `slug-2`처럼 복제하지 않고 건너뛰며, 해당 게시물의 자산도 다시 저장하지 않는다. 같은 Import 실행 안에서 여러 게시물이 같은 ZIP 자산을 참조하면 한 번 저장한 URL을 재사용하고, 재시도 중 같은 파일명 계열과 같은 해시의 자산이 이미 있으면 기존 파일 URL을 재사용한다. ZIP 안에서 참조 자산을 찾지 못한 게시물은 불완전한 글을 만들지 않고 해당 게시물 행을 오류로 남긴 뒤 다음 게시물을 계속 처리한다. 1회 Import는 최대 1000개 Markdown 게시물까지 처리한다. - 대용량 게시물 Export는 즉시 응답 다운로드가 아니라 백그라운드 작업으로 처리한다. 관리자가 Export를 요청하면 서버는 작업 레코드를 만들고, 게시물을 목표 zip 용량 기준으로 나누어 여러 zip 파일을 순차 생성한다. 계획 단계에서는 본문 문자열 바이트와 내부 `/uploads` 자산 파일 크기를 합산해 `max_file_size_bytes`를 넘기기 전에 새 분할 파일을 만들며, `chunk_size`는 한 ZIP에 들어갈 게시물 수의 안전 상한으로만 사용한다. 현재 구현은 요청 시 `post_export_jobs` 작업과 `post_export_files` 분할 파일 계획을 생성하고, worker가 대기 작업을 순차 실행해 `processed_count`, `current_part_index`, `progress_message`, `started_at`을 갱신한다. worker는 서버 시작과 30초 주기로 `queued` 및 재시작으로 중단된 `processing` 작업을 찾고, 작업 ID별 PostgreSQL advisory lock을 전용 연결에 유지해 여러 앱 인스턴스가 같은 작업을 동시에 생성하지 못하게 한다. 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` 테이블의 단일 레코드로 관리한다. - 설정 카드 저장은 카드가 담당하는 필드만 `PATCH /admin/api/settings`로 전달하며, 서버는 전달된 컬럼만 단일 `UPDATE`로 수정한다. 저장 성공 응답도 해당 필드만 현재 폼에 반영한다. diff --git a/docs/todo.md b/docs/todo.md index 926fdd8..9b6e5cb 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -2,9 +2,6 @@ ## v1.11 내부 개선 -- 게시물 Import 장기 작업화 검토. - - 현재 Import는 단일 요청 안에서 진행 중 안내와 중복 건너뛰기를 제공함. - - 수백~수천 개 ZIP의 정확한 퍼센트 진행률·일시 중단 복구·작업 이력 관리는 Export처럼 job 테이블 기반 비동기 처리로 전환 검토. - 거대 컴포넌트 책임 분리 진행. - `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리. - `pages/admin/settings/index.vue`: 설정 카드별 저장·상태·검증 로직을 카드 컴포넌트 또는 composable 단위로 정리. diff --git a/docs/update.md b/docs/update.md index a4bac82..7b9d169 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,15 @@ # 업데이트 이력 +## v1.11.18 + +- 게시물 Import를 단일 요청 처리에서 DB job 기반 백그라운드 작업으로 전환. +- 관리자 설정의 게시물 가져오기에 작업 센터 모달 추가. +- Import 작업 센터에서 게시물별 대기·진행중·완료·스킵·오류 상태와 진행률 표시. +- Import 작업 원본 ZIP을 작업 폴더에 보관하고 완료·실패 작업 삭제 시 함께 제거하도록 추가. +- Import 참조 자산 누락 시 게시물을 생성하지 않고 해당 게시물 행을 오류로 남기도록 수정. +- 서버 시작·주기 확인 시 대기 중이거나 중단된 Import 작업 재개 처리 추가. +- 작업용 `_import-jobs` ZIP 경로가 공개 업로드 라우트에서 제공되지 않도록 차단. + ## v1.11.17 - 게시물 Import에서 이미 같은 slug가 있는 게시물은 새 slug로 복제하지 않고 건너뛰도록 수정. diff --git a/package-lock.json b/package-lock.json index d791f15..2f5681b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.17", + "version": "1.11.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.17", + "version": "1.11.18", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e2803ec..e52c5e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.17", + "version": "1.11.18", "private": true, "type": "module", "imports": { diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index 5151532..4fb61f8 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -67,10 +67,12 @@ const deletingPostExportJobIds = ref([]) const downloadingPostExportJobIds = ref([]) const selectedPostExportFileIds = ref({}) const retryingPostExportJobIds = ref([]) +const deletingPostImportJobIds = ref([]) const importingPosts = ref(false) const postImportFile = ref(null) const postImportFileName = ref('') -const postImportResult = ref(null) +const selectedPostImportJobId = ref('') +const isPostImportJobModalOpen = ref(false) const postExportDateRangeMode = ref('all') const postExportYear = ref(new Date().getFullYear()) const postExportMonth = ref(new Date().getMonth() + 1) @@ -122,28 +124,6 @@ const postExportDateRangeModeOptions = [ { value: 'custom', label: '직접 지정' } ] -const postImportStatusSummary = computed(() => { - if (!postImportResult.value) { - return '' - } - - const result = postImportResult.value - const segments = [ - `게시물 ${Number(result.importedCount || 0)}개 가져옴` - ] - - if (Number(result.skippedCount || 0) > 0) { - segments.push(`${Number(result.skippedCount || 0)}개 건너뜀`) - } - - segments.push(`신규 자산 ${Number(result.assetCount || 0)}개`) - - if (Number(result.reusedAssetCount || 0) > 0) { - segments.push(`재사용 자산 ${Number(result.reusedAssetCount || 0)}개`) - } - - return segments.join(', ') -}) /** 블로그 제목·설명 카드 편집 모드 여부 */ const editTitleDesc = ref(false) /** 사이트 정보 카드 편집 모드 여부 */ @@ -234,6 +214,7 @@ const adsSnapshot = reactive({ }) let scrollSpyFrame = null let postExportRefreshTimer = null +let postImportRefreshTimer = null const { data: settings } = await useFetch('/admin/api/settings') const { @@ -242,6 +223,12 @@ const { } = await useFetch('/admin/api/posts/export-jobs', { default: () => [] }) +const { + data: postImportJobs, + refresh: refreshPostImportJobs +} = await useFetch('/admin/api/posts/import-jobs', { + default: () => [] +}) const { data: backupSummary, pending: backupSummaryPending, @@ -403,6 +390,12 @@ const hasAdsChanges = computed(() => editAds.value && ( */ const normalizedPostExportJobs = computed(() => Array.isArray(postExportJobs.value) ? postExportJobs.value : []) +/** + * 최신 게시물 Import 작업 목록 + * @returns {Array} Import 작업 목록 + */ +const normalizedPostImportJobs = computed(() => Array.isArray(postImportJobs.value) ? postImportJobs.value : []) + /** * 진행 중인 게시물 export 작업이 있는지 확인한다. * @returns {boolean} 진행 중 작업 여부 @@ -411,6 +404,20 @@ const hasActivePostExportJobs = computed(() => normalizedPostExportJobs.value.so job.status === 'queued' || job.status === 'processing' ))) +/** + * 진행 중인 게시물 Import 작업이 있는지 확인한다. + * @returns {boolean} 진행 중 작업 여부 + */ +const hasActivePostImportJobs = computed(() => normalizedPostImportJobs.value.some((job) => ( + job.status === 'queued' || job.status === 'processing' +))) + +/** + * 선택된 Import 작업 + * @returns {Object|null} Import 작업 + */ +const selectedPostImportJob = computed(() => normalizedPostImportJobs.value.find((job) => job.id === selectedPostImportJobId.value) || normalizedPostImportJobs.value[0] || null) + /** * Export 연도 선택지 * @returns {Array} 연도 목록 @@ -1026,7 +1033,6 @@ const selectPostImportFile = (file) => { postImportFile.value = file postImportFileName.value = file.name - postImportResult.value = null return true } @@ -1069,30 +1075,149 @@ const importSelectedPostFile = async () => { } importingPosts.value = true - postImportResult.value = null showToast('info', '게시물 가져오기를 시작합니다.') try { const formData = new FormData() formData.append('file', postImportFile.value) - const result = await $fetch('/admin/api/posts/import', { + const job = await $fetch('/admin/api/posts/import', { method: 'POST', body: formData }) - postImportResult.value = result + selectedPostImportJobId.value = job.id + isPostImportJobModalOpen.value = true postImportFile.value = null postImportFileName.value = '' - showToast('success', postImportStatusSummary.value || '게시물 가져오기가 완료되었습니다.') + await refreshPostImportJobs() + showToast('success', '게시물 가져오기 작업을 시작했습니다.') } catch (error) { - const message = error?.data?.message || '게시물 가져오기를 완료하지 못했습니다.' + const message = error?.data?.message || '게시물 가져오기 작업을 시작하지 못했습니다.' showToast('error', message) } finally { importingPosts.value = false } } +/** + * Import 작업 센터를 연다. + * @param {Object|null} job - 선택 작업 + * @returns {void} + */ +const openPostImportJobModal = (job = null) => { + selectedPostImportJobId.value = job?.id || normalizedPostImportJobs.value[0]?.id || '' + isPostImportJobModalOpen.value = true +} + +/** + * Import 작업 센터를 닫는다. + * @returns {void} + */ +const closePostImportJobModal = () => { + isPostImportJobModalOpen.value = false +} + +/** + * Import 작업 진행률을 계산한다. + * @param {Object} job - Import 작업 + * @returns {number} 진행률 + */ +const getPostImportProgressPercent = (job) => { + const totalCount = Number(job?.totalCount || 0) + + if (job?.status === 'ready') { + return 100 + } + + if (totalCount <= 0) { + return job?.status === 'processing' ? 5 : 0 + } + + return Math.min(Math.round((Number(job.processedCount || 0) / totalCount) * 100), 100) +} + +/** + * Import 작업 진행 라벨을 만든다. + * @param {Object} job - Import 작업 + * @returns {string} 진행 라벨 + */ +const getPostImportProgressLabel = (job) => { + const processedCount = Number(job?.processedCount || 0) + const totalCount = Number(job?.totalCount || 0) + + if (totalCount <= 0) { + return '준비 중' + } + + return `${processedCount.toLocaleString()} / ${totalCount.toLocaleString()}` +} + +/** + * Import 작업 상태 라벨을 반환한다. + * @param {string} status - 상태 + * @returns {string} 상태 라벨 + */ +const getPostImportStatusLabel = (status) => ({ + queued: '대기', + processing: '진행중', + ready: '완료', + skipped: '스킵', + failed: '오류', + pending: '대기' +})[status] || '대기' + +/** + * Import 작업 상태 스타일을 반환한다. + * @param {string} status - 상태 + * @returns {string} 클래스 문자열 + */ +const getPostImportStatusClass = (status) => ({ + queued: 'bg-[#eef3ff] text-[#2458b8]', + processing: 'bg-[#eef3ff] text-[#2458b8]', + ready: 'bg-[#eefbf3] text-[#147a45]', + skipped: 'bg-[#f3f5f7] text-[#657080]', + failed: 'bg-[#fff1f2] text-[#c01f32]', + pending: 'bg-[#f3f5f7] text-[#657080]' +})[status] || 'bg-[#f3f5f7] text-[#657080]' + +/** + * Import 작업 삭제 중 여부를 확인한다. + * @param {string} jobId - 작업 ID + * @returns {boolean} 삭제 중 여부 + */ +const isDeletingPostImportJob = (jobId) => deletingPostImportJobIds.value.includes(jobId) + +/** + * Import 작업을 삭제한다. + * @param {Object} job - Import 작업 + * @returns {Promise} + */ +const deletePostImportJob = async (job) => { + if (!job?.id || isDeletingPostImportJob(job.id)) { + return + } + + deletingPostImportJobIds.value = [...deletingPostImportJobIds.value, job.id] + showToast('info', '가져오기 작업 기록을 삭제하는 중입니다.') + + try { + await $fetch(`/admin/api/posts/import-jobs/${job.id}`, { + method: 'DELETE' + }) + await refreshPostImportJobs() + if (selectedPostImportJobId.value === job.id) { + selectedPostImportJobId.value = normalizedPostImportJobs.value[0]?.id || '' + } + showToast('success', '가져오기 작업 기록을 삭제했습니다.') + } catch (error) { + errorMessage.value = error?.data?.message || '가져오기 작업 기록을 삭제하지 못했습니다.' + showToast('error', errorMessage.value) + } finally { + deletingPostImportJobIds.value = deletingPostImportJobIds.value.filter((id) => id !== job.id) + } +} + /** * Export 파일 다운로드 URL을 만든다. * @param {Object} file - Export 파일 @@ -2228,6 +2353,11 @@ onMounted(() => { refreshPostExportJobs() } }, 5000) + postImportRefreshTimer = window.setInterval(() => { + if (hasActivePostImportJobs.value || isPostImportJobModalOpen.value) { + refreshPostImportJobs() + } + }, 2000) nextTick(() => { updateActiveSectionFromScroll() }) @@ -2236,6 +2366,7 @@ onMounted(() => { onBeforeUnmount(() => { window.clearInterval(postExportRefreshTimer) + window.clearInterval(postImportRefreshTimer) if (scrollSpyFrame) { cancelAnimationFrame(scrollSpyFrame) } @@ -4003,9 +4134,17 @@ onBeforeUnmount(() => { role="status" aria-live="polite" > - 게시물을 가져오는 중입니다. 대량 ZIP은 시간이 걸릴 수 있으니 이 화면을 닫지 마세요. + ZIP 파일을 서버 작업 대기열에 등록하는 중입니다.
+
-
- {{ postImportStatusSummary }} -
    -
  • - 건너뜀: {{ post.title }} ({{ post.slug }}) -
  • -
-
-
-

- 경고 {{ postImportResult.warningCount }}개가 있습니다. -

-
    -
  • - {{ warning }} -
  • -
-
@@ -4135,6 +4244,156 @@ onBeforeUnmount(() => { +