v1.12.23 대시보드 간소화 및 멤버 작성 이력 탭 추가

This commit is contained in:
2026-10-21 16:37:55 +09:00
parent c8e00c1190
commit 7c95797fd2
18 changed files with 287 additions and 251 deletions

View File

@@ -1,69 +1,37 @@
<script setup>
const props = defineProps({
memberId: { type: String, default: '' },
recent: { type: Boolean, default: false }
const { data, error, refresh } = await useFetch('/admin/api/comments', {
query: { pageSize: 5 },
default: () => ({ items: [], pagination: { total: 0 } })
})
const page = ref(1)
const query = computed(() => ({
...(props.memberId ? { memberId: props.memberId } : {}),
page: page.value,
pageSize: props.recent ? 5 : 10
}))
const { data, status, error, refresh } = await useFetch('/admin/api/comments', {
query,
default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } })
})
watch(() => props.memberId, () => { page.value = 1 })
let refreshTimer = null
onMounted(() => {
if (props.recent) refreshTimer = window.setInterval(() => refresh(), 30000)
})
onUnmounted(() => {
if (refreshTimer) window.clearInterval(refreshTimer)
})
onMounted(() => { refreshTimer = window.setInterval(() => refresh(), 30000) })
onUnmounted(() => { if (refreshTimer) window.clearInterval(refreshTimer) })
/**
* 댓글 작성 시각을 한국어 날짜로 표시한다.
* 최근 댓글의 작성 시각을 짧게 표시한다.
* @param {string} value - 작성 시각
* @returns {string} 표시 날짜
* @returns {string} 한국어 날짜·시각
*/
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul'
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Seoul'
}).format(new Date(value))
</script>
<template>
<section class="admin-comment-list rounded-xl border border-line bg-white p-5 md:p-6" :aria-busy="status === 'pending'">
<h2 class="admin-comment-list__heading text-lg font-semibold">
{{ recent ? '최근 댓글' : '작성한 댓글' }}
<span class="text-sm font-normal text-muted">전체 {{ data?.pagination.total || 0 }}개 · 답글 포함</span>
</h2>
<div v-if="error" class="admin-comment-list__error mt-4 text-sm text-red-700" role="alert">
댓글을 불러오지 못했습니다.
<button type="button" class="underline" @click="refresh()">다시 시도</button>
</div>
<p v-else-if="status === 'pending'" class="admin-comment-list__loading py-6 text-sm text-muted">댓글을 불러오는 중입니다.</p>
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-6 text-sm text-muted">작성한 댓글이 없습니다.</p>
<ul v-else class="admin-comment-list__items mt-3 divide-y divide-line">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item py-4">
<div class="admin-comment-list__meta flex flex-wrap items-center gap-2 text-xs text-muted">
<NuxtLink :to="`/admin/members/${comment.userId}`" class="font-semibold hover:underline">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt">{{ formatCreatedAt(comment.createdAt) }}</time>
<span v-if="comment.parentId">답글</span>
<span v-if="comment.status !== 'published'">{{ comment.status === 'pending' ? '승인 대기' : '차단됨' }}</span>
</div>
<p class="admin-comment-list__body my-2 whitespace-pre-wrap break-words text-sm">{{ comment.body }}</p>
<div class="admin-comment-list__post flex flex-wrap items-center gap-3 text-sm">
<NuxtLink :to="`/admin/posts/${comment.postId}`" class="min-w-0 break-words font-medium hover:underline">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-if="['published', 'members'].includes(comment.postStatus)" :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" target="_blank" rel="noopener noreferrer" class="shrink-0 text-muted underline">게시물 보기</NuxtLink>
<span v-else class="text-xs text-muted">미공개 게시물</span>
</div>
<section class="admin-comment-list border border-line bg-white px-4 py-3">
<h2 class="admin-comment-list__heading mb-1 text-xs font-semibold text-muted">최근 댓글 <span class="font-normal">· 전체 {{ data?.pagination.total || 0 }}개</span></h2>
<p v-if="error" class="admin-comment-list__error text-xs text-red-700" role="alert">
댓글을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button>
</p>
<p v-else-if="!data?.items.length" class="admin-comment-list__empty py-1 text-xs text-muted">작성한 댓글이 없습니다.</p>
<ul v-else class="admin-comment-list__items divide-y divide-line">
<li v-for="comment in data.items" :key="comment.id" class="admin-comment-list__item grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 text-xs md:grid-cols-[minmax(0,2fr)_7rem_8rem_minmax(0,3fr)]">
<NuxtLink :to="`/post/${encodeURIComponent(comment.postSlug)}#comments`" v-if="['published', 'members'].includes(comment.postStatus)" target="_blank" rel="noopener noreferrer" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-else :to="`/admin/posts/${comment.postId}`" class="admin-comment-list__post truncate font-medium hover:underline" :title="comment.postTitle">{{ comment.postTitle || '(제목 없음)' }}</NuxtLink>
<NuxtLink :to="`/admin/members/${comment.userId}`" class="admin-comment-list__author truncate text-muted hover:underline" :title="comment.username">{{ comment.username }}</NuxtLink>
<time :datetime="comment.createdAt" class="admin-comment-list__time text-muted">{{ formatCreatedAt(comment.createdAt) }}</time>
<p class="admin-comment-list__body truncate text-muted" :title="comment.body">{{ comment.body }}</p>
</li>
</ul>
<nav v-if="!recent && data?.pagination.pageCount > 1" class="admin-comment-list__pagination mt-4 flex items-center justify-between gap-3 text-sm" aria-label="댓글 페이지">
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page <= 1" @click="page = data.pagination.page - 1">이전</button>
<span>{{ data.pagination.page }} / {{ data.pagination.pageCount }} 페이지</span>
<button type="button" class="rounded border border-line px-3 py-2 disabled:opacity-40" :disabled="status === 'pending' || data.pagination.page >= data.pagination.pageCount" @click="page = data.pagination.page + 1">다음</button>
</nav>
</section>
</template>

View File

@@ -0,0 +1,70 @@
<script setup>
const props = defineProps({
memberId: { type: String, required: true },
kind: { type: String, required: true }
})
const isComments = computed(() => props.kind === 'comments')
const searchInput = ref('')
const filters = ref({ q: '', page: 1 })
const query = computed(() => ({
...filters.value, pageSize: 10,
...(isComments.value ? { memberId: props.memberId } : {})
}))
const { data, status, error, refresh } = await useFetch(
() => isComments.value ? '/admin/api/comments' : `/admin/api/members/${props.memberId}/posts`,
{ query, default: () => ({ items: [], pagination: { page: 1, pageCount: 1, total: 0 } }) }
)
const statusLabels = { published: '공개', draft: '초안', members: '멤버십', private: '비공개', pending: '승인 대기', blocked: '차단됨' }
/**
* 검색어와 페이지를 함께 적용해 검색 결과 첫 페이지를 조회한다.
* @returns {void}
*/
const applySearch = () => { filters.value = { q: searchInput.value.trim(), page: 1 } }
/**
* 전체 검색 결과에서 선택한 페이지를 조회한다.
* @param {number} page - 페이지 번호
* @returns {void}
*/
const changePage = (page) => { filters.value = { ...filters.value, page } }
/**
* 활동의 작성 시각을 한국어로 표시한다.
* @param {string} value - 작성 시각
* @returns {string} 날짜·시각
*/
const formatCreatedAt = (value) => new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'medium', timeStyle: 'short', timeZone: 'Asia/Seoul'
}).format(new Date(value))
</script>
<template>
<section class="admin-member-activity border border-line bg-white p-5" :aria-busy="status === 'pending'">
<div class="admin-member-activity__toolbar flex flex-wrap items-center justify-between gap-4">
<h2 class="admin-member-activity__heading text-base font-semibold">{{ isComments ? '작성한 댓글' : '작성한 글' }} <span class="text-xs font-normal text-muted">{{ data?.pagination.total || 0 }}개 · 최신순</span></h2>
<form class="admin-member-activity__search flex w-full max-w-full items-center gap-2 sm:w-auto" role="search" @submit.prevent="applySearch">
<AdminSearchInput root-class="min-w-0 flex-1" width-class="w-full sm:w-64" :value="searchInput" :label="isComments ? '작성한 댓글 검색' : '작성한 글 검색'" :placeholder="isComments ? '댓글 내용·게시물 제목 검색' : '제목·주소·본문 검색'" @input="searchInput = $event.target.value" />
<button type="submit" class="admin-member-activity__search-button h-10 shrink-0 rounded border border-line px-3 text-sm">검색</button>
</form>
</div>
<p v-if="error" class="admin-member-activity__error py-6 text-sm text-red-700" role="alert">목록을 불러오지 못했습니다. <button type="button" class="underline" @click="refresh()">다시 시도</button></p>
<p v-else-if="status === 'pending'" class="admin-member-activity__loading py-6 text-sm text-muted">목록을 불러오는 중입니다.</p>
<p v-else-if="!data?.items.length" class="admin-member-activity__empty py-6 text-sm text-muted">{{ filters.q ? '검색 결과가 없습니다.' : isComments ? '작성한 댓글이 없습니다.' : '작성한 글이 없습니다.' }}</p>
<ul v-else class="admin-member-activity__items mt-4 divide-y divide-line">
<li v-for="item in data.items" :key="item.id" class="admin-member-activity__item py-4 text-sm">
<div class="admin-member-activity__meta flex flex-wrap items-center gap-2 text-xs text-muted">
<time :datetime="item.createdAt">{{ formatCreatedAt(item.createdAt) }}</time>
<span v-if="isComments && item.parentId">답글</span>
<span>{{ statusLabels[item.status] || item.status }}</span>
</div>
<p v-if="isComments" class="admin-member-activity__body my-2 whitespace-pre-wrap break-words">{{ item.body }}</p>
<div class="admin-member-activity__post mt-2 flex flex-wrap items-center gap-3">
<NuxtLink :to="`/admin/posts/${isComments ? item.postId : item.id}`" class="break-words font-medium hover:underline">{{ (isComments ? item.postTitle : item.title) || '(제목 없음)' }}</NuxtLink>
<NuxtLink v-if="['published', 'members'].includes(isComments ? item.postStatus : item.status)" :to="`/post/${encodeURIComponent(isComments ? item.postSlug : item.slug)}${isComments ? '#comments' : ''}`" target="_blank" rel="noopener noreferrer" class="shrink-0 text-xs text-muted underline">게시물 보기</NuxtLink>
</div>
</li>
</ul>
<AdminPagination v-if="status !== 'pending' && !error" :page="data?.pagination.page" :page-count="data?.pagination.pageCount" :label="isComments ? '작성한 댓글 페이지' : '작성한 글 페이지'" @update:page="changePage" />
</section>
</template>

View File

@@ -21,6 +21,13 @@ const { data: adminSession } = await useFetch('/admin/api/auth/me', {
})
})
const selectedMemberTab = ref('info')
const memberTabs = [
{ value: 'info', label: '정보' },
{ value: 'posts', label: '작성한 글' },
{ value: 'comments', label: '작성한 댓글' }
]
const isNewMember = computed(() => props.mode === 'new')
const isSaving = ref(false)
const savedMemberSnapshot = ref('')
@@ -699,7 +706,7 @@ watch(() => props.member, () => {
{{ pageTitle }}
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
@@ -744,7 +751,21 @@ watch(() => props.member, () => {
</p>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<nav v-if="!isNewMember" class="admin-member-form__tabs mt-5 flex gap-6 border-b border-line" aria-label="멤버 상세 메뉴">
<button
v-for="tab in memberTabs" :key="tab.value" type="button"
class="admin-member-form__tab border-b-2 px-1 py-3 text-sm font-semibold"
:class="selectedMemberTab === tab.value ? 'border-ink text-ink' : 'border-transparent text-muted hover:text-ink'"
:aria-current="selectedMemberTab === tab.value ? 'page' : undefined"
@click="selectedMemberTab = tab.value"
>{{ tab.label }}</button>
</nav>
<AdminMemberActivityList
v-if="!isNewMember && selectedMemberTab !== 'info'"
:key="`${member.id}-${selectedMemberTab}`" :member-id="member.id" :kind="selectedMemberTab"
class="admin-member-form__content-tab mt-6"
/>
<div v-show="selectedMemberTab === 'info'" class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<div class="admin-member-form__avatar-control group relative h-20 w-20 shrink-0">
@@ -924,8 +945,6 @@ watch(() => props.member, () => {
</div>
</section>
<AdminCommentList v-if="!isNewMember && member?.id" :key="member.id" :member-id="member.id" />
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">

View File

@@ -64,7 +64,7 @@ sh scripts/migrate-production-db.sh status
v1.11.0은 `base-uri`, `object-src`, `frame-ancestors`만 강제한다. 광고 코드, 사용자 사이트 코드, 외부 이미지·미디어·iframe에 영향을 줄 수 있는 출처 정책은 계속 Report-Only이므로 기존 광고와 사용자 코드 실행을 차단하지 않는다. 전체 HTML 문서 `/pages/**`는 강제 CSP에서 제외하고 Report-Only 보고만 유지한다.
배포 후 관리자 대시보드의 `CSP 관찰` 표 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `empty`, `about:`, 브라우저 확장 프로그램 scheme은 실제 외부 origin 허용 후보가 아니라 브라우저·확장 프로그램·빈 iframe 계열 신호로 본다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다.
배포 후 관리자 진단 API `/admin/api/security/csp-violations` 또는 `csp_violation_aggregates`의 `policy_version='compat-v1'`, `effective_directive`, `blocked_origin`, `document_path`, `user_agent_family`, `violation_count`, `last_seen_at`을 관찰한다. URL 쿼리·fragment·차단 자산 경로와 User-Agent 원문은 저장되지 않는다. `empty`, `about:`, 브라우저 확장 프로그램 scheme은 실제 외부 origin 허용 후보가 아니라 브라우저·확장 프로그램·빈 iframe 계열 신호로 본다. `허용 후보`는 지시어별 외부 origin을 빠르게 묶어 보여주는 보조 정보일 뿐이므로, 반복 위반이 광고·사이트 코드의 정상 출처인지 확인한 뒤에만 강제 정책 확대를 별도 배포한다.
v1.12.21은 `최근 관찰 항목 누적` 표기로 집계 의미를 바로잡는다. 이는 최근 30일 발생 건수가 아니라 최근 보고된 행의 전체 누적값이다. `empty`·`unknown`만으로 iframe 보고의 원인을 확정하지 않는다. 현재 정책은 HTTPS 출처를 폭넓게 허용하므로 후보 부재를 정상 출처 조사 완료로 해석하지 않는다. 이번 변경은 관리자 UI·설명 개선으로 DB 마이그레이션이나 CSP 강제 정책 변경은 없다.

View File

@@ -1,3 +1,9 @@
## 2026-10-21 v1.12.23 — 대시보드는 최근 활동 요약, 멤버 이력은 탭으로 나눈다
대시보드는 새 댓글의 게시물·작성자·시각을 빠르게 파악하도록 최근 댓글을 작은 한 줄 목록으로 줄이고 CSP 관찰 표와 주기 조회를 제거한다. 이는 관리자 화면 정리이며, 제공된 누적 보고로 보안 정책 강화 여부를 확정한 결정은 아니다. 기존 수집·집계와 보안 헤더는 유지한다.
멤버 정보에 전체 댓글을 함께 표시하면 기본 정보 확인이 어려워지므로 정보·작성한 글·작성한 댓글 탭으로 구분한다. 작성 글은 기존 `posts.author_id`를 사용하고, 글과 댓글 모두 서버에서 작성자별 전체 이력을 검색·페이지 조회한다. 정보 폼은 탭을 옮겨도 유지해 미저장 입력이 사라지지 않도록 한다.
## 2026-10-21 v1.12.22 — 관리자 댓글 목록은 공개 조회와 분리한다
관리자는 회원별 작성 이력과 최근 댓글을 게시물 상태에 관계없이 확인해야 하므로 관리자 세션 전용 페이지 조회 API를 둔다. 공개 댓글 API의 접근 정책을 유지하고, 공통 목록 컴포넌트를 멤버 상세와 대시보드에서 재사용한다. 댓글 본문은 텍스트로 표시하고 게시물 편집과 공개 댓글 영역 이동을 구분한다.

View File

@@ -312,7 +312,7 @@
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·멤버별 댓글 페이지 목록, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약·정보/작성한 글/작성한 댓글 탭·전체 이력 검색 및 번호형 페이지네이션, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
@@ -366,7 +366,7 @@
| 파일 | 화면 |
|------|------|
| pages/admin/index.vue | 대시보드(최근 댓글 5개·작성자/게시물 이동·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록, CSP 관찰 목적·집계 의미·정책 한계 안내와 상위 보고) |
| pages/admin/index.vue | 대시보드(최근 댓글 5개 작은 행·게시글/작성자/시각/한 줄 내용·30초 갱신, 상단 요약: 현재 접속자·오늘 방문자·누적 방문자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 시 하단 선택 기간 상세 갱신, 자동 갱신 중 선택 기간 유지, 선택 기간 기준 왼쪽 인기 게시물·인기 페이지와 오른쪽 유입원·모바일/PC 묶음 디바이스(상위 기기별 방문자·조회·비율 한 줄 요약, OS별 방문자·비율), IP·줄바꿈된 열린 페이지 수·열린 페이지 목록 포함 접속자 목록) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, `F`/`ㄹ` 검색창 포커스, 관리자 전용 상단 고정 배지·정렬·행 메뉴 토글, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 별도 댓글 수 열, 제목 아래 주소·공개 보기 hover 아이콘, 백링크 개수 열과 고정 크기 참조 게시물 목록 모달·내부 검색·이동 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(고정 전용 API·추천 전용 API·삭제 전 참조 경고·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
@@ -568,7 +568,7 @@
| server/routes/admin/api/analytics/realtime.get.js | 관리자 실시간 접속자 API |
| server/routes/admin/api/analytics/traffic.get.js | 관리자 유입원·디바이스·키워드 통계 API |
| server/routes/admin/api/analytics/range-detail.get.js | 관리자 통계 추이 날짜·구간 상세 API |
| server/routes/admin/api/security/csp-violations.get.js | 관리자 대시보드 CSP Report-Only 위반 관찰 요약 API |
| server/routes/admin/api/security/csp-violations.get.js | 관리자 진단용 CSP Report-Only 집계 요약 API(대시보드 조회 제거) |
| server/routes/admin/api/adsense/summary.get.js | 관리자 대시보드 AdSense 예상 수익·페이지뷰·노출·클릭 요약 API |
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
| plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) |
@@ -638,7 +638,9 @@
| tests/markdown-html-block.test.js | 게시물 HTML sanitizer 공격 우회·허용 HTML 회귀 테스트 |
| tests/multipart-file-upload.test.js | 다중 multipart 디스크 스트리밍·파일 크기 제한 회귀 테스트 |
| docker-compose.yml | NAS 컨테이너 실행 초안, PostgreSQL 호스트 기본 loopback 바인딩 |
| components/admin/AdminCommentList.vue | 멤버별 댓글 10개 페이지 목록·대시보드 최신 댓글 5개·게시물/작성자 이동·조회 오류 재시도 |
| server/routes/admin/api/comments.get.js | 관리자 댓글 조회 인증·Zod query 검증 |
| components/admin/AdminCommentList.vue | 대시보드 최신 댓글 5개 작은 목록·게시글/작성자/시각/한 줄 내용·30초 갱신·조회 오류 재시도 |
| server/routes/admin/api/comments.get.js | 관리자 댓글 조회 인증·댓글 내용/게시물 제목 검색·Zod query 검증 |
| server/repositories/comment-repository.js | 공개 댓글 조회·작성·좋아요와 관리자 댓글 최신순 페이지 조회 |
| tests/playwright/e2e/admin-comments.spec.js | 멤버 댓글 페이지 이동·게시물 링크·대시보드 최근 댓글·게시글 댓글 열 E2E 검증 |
| tests/playwright/e2e/admin-comments.spec.js | 멤버 정보/작성 글/댓글 탭·검색·페이지 이동·원문 링크·작은 대시보드 댓글·CSP 제거·게시글 댓글 수 E2E 검증 |
| components/admin/AdminMemberActivityList.vue | 회원별 작성 글·댓글 탭의 전체 이력 최신순 검색·번호형 페이지네이션·게시물 이동 |
| server/routes/admin/api/members/[id]/posts.get.js | 관리자 회원 작성 글 조회 인증·Zod 검증·검색·페이지 API |

View File

@@ -68,7 +68,7 @@
- 호환 정책은 모든 경로에서 `Content-Security-Policy-Report-Only`로 유지한다. 사용자 사이트 코드·광고·외부 미디어를 차단하지 않은 채 script/style/img/font/media/frame/connect/worker/manifest 위반을 `/api/csp-report`로 보고하며 `Reporting-Endpoints`와 `Report-To`도 같은 origin 수집 주소를 가리킨다.
- `POST /api/csp-report`는 `application/csp-report`, `application/reports+json`, `application/json`, `application/*+json`만 허용하고 요청 본문은 32KB, Reporting API 배열은 최대 20건으로 제한한다. IP 원문을 보관하지 않는 해시 키 기준으로 분당 60회까지만 받는다.
- CSP 위반은 정책 버전, 쿼리·fragment를 제거한 문서 경로, 지시어, 경로·쿼리를 제거한 차단 origin, 브라우저 계열만 `csp_violation_aggregates`에 누적한다. 원문 URL과 User-Agent는 저장하지 않으며 저장 실패가 일반 응답을 실패시키지 않는다. 차단 출처가 비어 있으면 `empty`, `about:blank` 계열은 `about:`, 브라우저 확장 프로그램은 `chrome-extension:`·`moz-extension:`·`safari-web-extension:` 같은 scheme 단위로 분류한다.
- 관리자 대시보드는 CSP Report-Only 위반 집계가 1건 이상 있을 때만 `CSP 관찰` 표를 표시한다. 표는 최근 30일 내 보고된 상위 항목의 지시어, 보고된 출처, 문서 경로, 브라우저 계열, 누적 횟수, 최근 관찰 시각을 보여준다. 횟수는 최초 수집부터 누적이며 실제 최근 30일 발생 수가 아니다. 관찰 목적과 차단 없이 보고만 수집한다는 설명, `empty`·`unknown`의 의미, HTTPS 출처를 폭넓게 허용하는 현재 정책의 관찰 한계를 안내한다. `script-src-elem`, `style-src-attr`, `child-src` 같은 세부 지시어는 각각 `script-src`, `style-src`, `frame-src` 후보로 묶고, `http(s)` origin만 허용 후보로 별도 표시한다. 후보가 없어도 안전성이나 정책 강화 가능성을 확정하지 않는다.
- v1.12.23부터 대시보드의 CSP 관찰 표와 조회·자동 갱신은 제거한다. 기존 보안 헤더·Report-Only 수집·집계·관리자 진단 API는 유지한다. 누적 보고만으로 정책 강화 가능성을 확정하지 않는다.
### 홈 Featured (인덱스)
@@ -1089,10 +1089,15 @@ APP_PORT=43080
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
## 관리자 댓글 조회 (v1.12.22)
## 관리자 댓글·회원 작성 이력 조회 (v1.12.23)
- `GET /admin/api/comments`: 관리자 세션 필수. `memberId`(선택 UUID), `page`(기본 1, 1~1000000 정수), `pageSize`(기본 10, 1~100 정수). 잘못된 조건은 400, 미인증은 401.
- `GET /admin/api/comments`: 관리자 세션 필수. `memberId`(선택 UUID), `q`(기본 빈 문자열, trim, 최대 200자, 댓글 내용·게시물 제목 부분 검색), `page`(기본 1, 1~1000000 정수), `pageSize`(기본 10, 1~100 정수). 잘못된 조건은 400, 미인증은 401.
- 응답 `{ items, pagination: { page, pageSize, total, pageCount } }`. 항목은 `id`, `body`, `status`, `parentId`, `createdAt`, `userId`, `username`, `postId`, `postTitle`, `postSlug`, `postStatus` 제공. 모든 상태의 댓글·답글 포함, 작성 시각·ID 내림차순. 범위 초과 페이지는 마지막 페이지로 보정.
- 멤버 상세는 10개씩 이전·다음 이동. 대시보드는 전체 최신 댓글 5개를 30초마다 갱신. 조회 오류는 재시도, 조회 중은 로딩 표시.
- 멤버 상세는 `정보 / 작성한 글 / 작성한 댓글` 탭을 제공하며 정보로 진입한다. 정보 편집값은 탭 이동 시 유지한다. 이력은 선택한 탭에서만 조회하고, 10개씩 번호형 페이지네이션·검색 버튼/Enter로 검색한다. 검색 시 첫 페이지로 돌아가며 최신 작성순으로 전체 이력을 조회한다.
- 대시보드는 최신 댓글 5개를 30초마다 갱신한다. 게시물·작성자·짧은 시각·말줄임 한 줄 내용만 작은 행으로 표시한다. 조회 오류는 재시도하며 이력 조회 중은 로딩 표시.
- 댓글 내용은 일반 텍스트로 표시. 작성자는 멤버 상세, 게시물 제목은 관리자 편집으로 이동. 공개·멤버십 글은 새 창의 `/post/:slug#comments` 링크 제공(공개 화면의 기존 접근 권한 적용).
- 게시글 목록 댓글 열은 기존 `commentCount` 사용. 공개 상태 댓글·답글만 집계.
- `GET /admin/api/members/:id/posts`: 관리자 세션 필수. 회원 UUID와 `q`(trim, 최대 200자), `page`(1~1000000, 기본 1), `pageSize`(1~100, 기본 10) 검증. 잘못된 조건 400, 미인증 401.
- 작성 글은 `posts.author_id`로 필터하고 초안·비공개·멤버십·공개 전체 포함, `created_at DESC, id DESC` 순서. `q`는 제목·주소·요약·본문의 문자 그대로 부분 검색이며 댓글 검색도 `%`, `_`를 와일드카드로 취급하지 않는다.
- 작성 글 응답 `{ items: [{ id, title, slug, status, createdAt, publishedAt }], pagination: { page, pageSize, total, pageCount } }`. 범위 초과 페이지는 마지막으로 보정하고 미작성·검색 결과 없음은 빈 목록 반환.

View File

@@ -4,7 +4,7 @@
## 2차 관리자 개발
- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 작성 글 검색/페이지 API 검증은 통과. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인.
## 프론트엔드 개발

View File

@@ -1,5 +1,15 @@
# 업데이트 이력
## v1.12.23
- 대시보드 CSP 관찰 표·조회·자동 갱신 제거.
- 대시보드 최근 댓글을 게시글·작성자·시각·한 줄 내용의 작은 목록으로 축소.
- 멤버 상세 정보·작성한 글·작성한 댓글 탭 분리.
- 회원 작성 글·댓글 전체 이력 최신순 검색·번호형 페이지네이션 추가.
- 댓글 내용·게시물 제목 검색과 작성 글 제목·주소·본문 검색 API 추가.
- JavaScript 문법 검사·단위 테스트 273건·프로덕션 빌드·Chromium E2E 11건 검증 진행.
- API 통합 검증 11건 통과, 기존 미디어 사용처 검증 실패 1건·후속 미실행 1건 유지.
## v1.12.22
- 관리자 멤버 상세에 최신순 댓글·답글 목록과 서버 페이지네이션 추가.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -94,23 +94,6 @@ const {
})
})
const {
data: cspViolationSummary,
refresh: refreshCspViolationSummary
} = await useFetch('/admin/api/security/csp-violations', {
query: { days: 30, limit: 8 },
server: false,
default: () => ({
days: 30,
totalViolations: 0,
totalRows: 0,
recentViolations: 0,
latestSeenAt: null,
topViolations: [],
allowlistCandidates: []
})
})
const todayKey = getAnalyticsDayKey()
const publishedCount = computed(() => posts.value.filter((post) => post.status === 'published').length)
const draftCount = computed(() => posts.value.filter((post) => post.status === 'draft').length)
@@ -203,10 +186,6 @@ const chartPagination = computed(() => {
const adsenseCards = computed(() => adsenseSummary.value.periods || [])
const adsenseBalance = computed(() => adsenseSummary.value.balance || createEmptyAdsenseBalance())
const cspViolations = computed(() => cspViolationSummary.value?.topViolations || [])
const cspAllowlistCandidates = computed(() => cspViolationSummary.value?.allowlistCandidates || [])
const hasCspViolations = computed(() => Number(cspViolationSummary.value?.totalViolations || 0) > 0)
/**
* AdSense 통화 코드를 표시 가능한 값으로 보정한다.
* @returns {string} 통화 코드
@@ -266,29 +245,6 @@ const formatAdsenseRpm = (value, currencyCode = '') => {
}).format(Number(value || 0))
}
/**
* ISO 날짜를 관리자 대시보드용 시각으로 변환한다.
* @param {string | null | undefined} value - ISO 날짜
* @returns {string} 표시 문자열
*/
const formatDashboardDateTime = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
return new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
/**
* 통계 차트 이전 기간으로 이동한다.
* @returns {void}
@@ -665,7 +621,6 @@ onMounted(() => {
refreshSummary()
refreshTrendDetail()
refreshRealtime()
refreshCspViolationSummary()
}, 30000)
})
@@ -722,7 +677,7 @@ watch(trendRows, () => {
</p>
</section>
<AdminCommentList recent class="admin-dashboard__recent-comments" />
<AdminCommentList class="admin-dashboard__recent-comments" />
<section class="admin-dashboard__adsense">
<p
@@ -780,128 +735,6 @@ watch(trendRows, () => {
</div>
</section>
<section
v-if="hasCspViolations"
class="admin-dashboard__csp border border-line bg-white p-4"
>
<div class="admin-dashboard__csp-header flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="admin-dashboard__section-title text-sm font-semibold text-ink">
CSP 관찰
</h2>
<p class="admin-dashboard__section-description mt-1 text-xs text-muted">
최근 {{ cspViolationSummary.days }}일 내 보고된 항목 · 횟수는 최초 수집부터 누적
</p>
</div>
<div class="admin-dashboard__csp-summary text-right text-xs text-muted">
<p>
최근 관찰 항목 누적 {{ formatNumber(cspViolationSummary.recentViolations) }}회
</p>
<p class="mt-1">
전체 {{ formatNumber(cspViolationSummary.totalViolations) }}회 · 마지막 {{ formatDashboardDateTime(cspViolationSummary.latestSeenAt) }}
</p>
</div>
</div>
<div class="admin-dashboard__csp-explanation mt-4 rounded border border-line bg-paper p-3 text-xs leading-relaxed text-muted">
<p class="font-semibold text-ink">보안 정책을 강화하기 전, 사이트 기능과 충돌하는지 확인하는 관찰 기록입니다.</p>
<p class="mt-1">Report-Only는 차단 없이 보고만 수집합니다. 이 횟수가 공격 수나 실제 차단 수를 뜻하지는 않습니다.</p>
<details class="admin-dashboard__csp-help mt-2">
<summary class="cursor-pointer font-semibold text-ink">기록 해석과 관찰의 한계</summary>
<ul class="mt-2 list-disc space-y-1 pl-4">
<li>frame-src는 iframe 같은 삽입 화면에 관한 규칙입니다. empty는 보고에 출처가 없고, unknown은 출처를 식별하지 못했다는 뜻입니다. 이 값만으로 광고·확장 프로그램 등 원인을 확정할 수 없습니다.</li>
<li>현재 관찰 정책은 HTTPS 출처를 폭넓게 허용합니다. 보고가 없거나 허용 후보가 비어 있어도 모든 외부 출처를 확인한 것은 아닙니다.</li>
<li>최근 관찰 항목 누적은 최근 {{ cspViolationSummary.days }}일에 보고된 항목들의 전체 누적 합계입니다. 해당 기간에만 발생한 횟수는 현재 집계로 알 수 없습니다.</li>
</ul>
</details>
</div>
<div
v-if="cspAllowlistCandidates.length"
class="admin-dashboard__csp-candidates mt-4 border border-line bg-paper p-3"
>
<h3 class="text-xs font-semibold uppercase text-muted">
허용 후보
</h3>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="candidate in cspAllowlistCandidates"
:key="`${candidate.sourceDirective}-${candidate.blockedOrigin}`"
class="inline-flex max-w-full items-center gap-2 border border-line bg-white px-2 py-1 text-[11px] text-muted"
>
<strong class="shrink-0 text-ink">{{ candidate.sourceDirective }}</strong>
<span class="truncate">{{ candidate.blockedOrigin }}</span>
<span class="shrink-0 text-[#71717a]">{{ formatNumber(candidate.violationCount) }}회</span>
</span>
</div>
</div>
<p v-else class="admin-dashboard__csp-no-candidates mt-3 text-xs text-muted">
조회한 상위 보고에서 외부 출처 허용 후보를 찾지 못했습니다. 이 결과만으로 차단 정책을 강화하지 않습니다.
</p>
<div
v-if="cspViolations.length"
class="admin-dashboard__csp-table mt-4 overflow-x-auto"
>
<table class="min-w-full text-left text-xs">
<thead class="border-b border-line text-[11px] uppercase text-muted">
<tr>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
지시어
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
보고된 출처
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
문서
</th>
<th class="whitespace-nowrap px-2 py-2 font-semibold">
브라우저
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
누적 횟수
</th>
<th class="whitespace-nowrap px-2 py-2 text-right font-semibold">
최근
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in cspViolations"
:key="`${row.policyVersion}-${row.effectiveDirective}-${row.blockedOrigin}-${row.documentPath}-${row.userAgentFamily}`"
class="border-b border-line last:border-b-0"
>
<td class="whitespace-nowrap px-2 py-2 font-medium text-ink">
{{ row.effectiveDirective }}
</td>
<td class="max-w-[260px] truncate px-2 py-2 text-muted">
{{ row.blockedOrigin === 'empty' ? '출처 없음 (empty)' : row.blockedOrigin === 'unknown' ? '식별 불가 (unknown)' : row.blockedOrigin }}
</td>
<td class="max-w-[240px] truncate px-2 py-2 text-muted">
{{ row.documentPath }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-muted">
{{ row.userAgentFamily }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-right font-semibold text-ink">
{{ formatNumber(row.violationCount) }}
</td>
<td class="whitespace-nowrap px-2 py-2 text-right text-muted">
{{ formatDashboardDateTime(row.lastSeenAt) }}
</td>
</tr>
</tbody>
</table>
</div>
<p
v-else
class="admin-dashboard__csp-empty mt-4 border border-line bg-paper px-3 py-2 text-xs text-muted"
>
최근 {{ cspViolationSummary.days }}일에 보고된 CSP 관찰 항목은 없습니다.
</p>
</section>
<section class="admin-dashboard__charts border border-line bg-white p-4">
<div class="admin-dashboard__charts-header flex flex-wrap items-center justify-between gap-3">
<div>

View File

@@ -331,10 +331,10 @@ export const toggleCommentLike = async (input) => {
/**
* 관리자 댓글을 작성자 조건과 최신순 페이지 단위로 조회한다.
* @param {{ memberId?: string, page: number, pageSize: number }} options - 조회 조건
* @param {{ memberId?: string, q?: string, page: number, pageSize: number }} options - 조회 조건
* @returns {Promise<Object>} 댓글과 페이지 정보
*/
export const listAdminComments = async ({ memberId = null, page, pageSize }) => {
export const listAdminComments = async ({ memberId = null, q = '', page, pageSize }) => {
const sql = getSql()
const empty = { items: [], pagination: { page: 1, pageSize, total: 0, pageCount: 1 } }
if (!sql) return empty
@@ -343,7 +343,9 @@ export const listAdminComments = async ({ memberId = null, page, pageSize }) =>
await transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`
const [count] = await transaction`
SELECT COUNT(*)::int AS total FROM comments
WHERE (${memberId}::uuid IS NULL OR user_id = ${memberId}::uuid)
INNER JOIN posts ON posts.id = comments.post_id
WHERE (${memberId}::uuid IS NULL OR comments.user_id = ${memberId}::uuid)
AND (${q} = '' OR strpos(lower(concat_ws(' ', comments.body, posts.title)), lower(${q})) > 0)
`
const total = Number(count.total)
const pageCount = Math.max(1, Math.ceil(total / pageSize))
@@ -358,6 +360,7 @@ export const listAdminComments = async ({ memberId = null, page, pageSize }) =>
INNER JOIN users ON users.id = comments.user_id
INNER JOIN posts ON posts.id = comments.post_id
WHERE (${memberId}::uuid IS NULL OR comments.user_id = ${memberId}::uuid)
AND (${q} = '' OR strpos(lower(concat_ws(' ', comments.body, posts.title)), lower(${q})) > 0)
ORDER BY comments.created_at DESC, comments.id DESC
LIMIT ${pageSize} OFFSET ${(currentPage - 1) * pageSize}
`

View File

@@ -2906,3 +2906,34 @@ export const deleteAdminTag = async (id) => {
return Boolean(rows[0])
}
/**
* 회원이 작성한 전체 게시물을 최신 작성순으로 검색·페이지 조회한다.
* @param {{ memberId: string, q: string, page: number, pageSize: number }} options - 조회 조건
* @returns {Promise<Object>} 게시물 요약과 페이지 정보
*/
export const listAdminMemberPosts = async ({ memberId, q, page, pageSize }) => {
const sql = getPostgresClient()
if (!sql) return { items: [], pagination: { page: 1, pageSize, total: 0, pageCount: 1 } }
return sql.begin(async (transaction) => {
await transaction`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`
const [count] = await transaction`
SELECT COUNT(*)::int AS total FROM posts
WHERE author_id = ${memberId}
AND (${q} = '' OR strpos(lower(concat_ws(' ', title, slug, excerpt, content)), lower(${q})) > 0)
`
const total = Number(count.total)
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pageCount)
const items = await transaction`
SELECT id, title, slug, status, created_at AS "createdAt", published_at AS "publishedAt"
FROM posts
WHERE author_id = ${memberId}
AND (${q} = '' OR strpos(lower(concat_ws(' ', title, slug, excerpt, content)), lower(${q})) > 0)
ORDER BY created_at DESC, id DESC
LIMIT ${pageSize} OFFSET ${(currentPage - 1) * pageSize}
`
return { items, pagination: { page: currentPage, pageSize, total, pageCount } }
})
}

View File

@@ -5,6 +5,7 @@ import { listAdminComments } from '../../../repositories/comment-repository'
const querySchema = z.object({
memberId: z.string().uuid().optional(),
q: z.string().trim().max(200).default(''),
page: z.coerce.number().int().min(1).max(1000000).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(10)
})

View File

@@ -0,0 +1,25 @@
import { createError, getQuery, getRouterParam } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { listAdminMemberPosts } from '../../../../../repositories/content-repository'
const querySchema = z.object({
memberId: z.string().uuid(),
q: z.string().trim().max(200).default(''),
page: z.coerce.number().int().min(1).max(1000000).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(10)
})
/**
* 관리자 전용 회원 작성 글 목록을 조회한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 작성 글 페이지
*/
export default defineEventHandler((event) => {
requireAdminSession(event)
const result = querySchema.safeParse({ ...getQuery(event), memberId: getRouterParam(event, 'id') })
if (!result.success) {
throw createError({ statusCode: 400, message: '작성 글 조회 조건이 올바르지 않습니다.' })
}
return listAdminMemberPosts(result.data)
})

View File

@@ -254,6 +254,11 @@ test('관리자 댓글 조회는 인증·입력 검증과 멤버별 페이지·
expect(first.items[0]).toMatchObject({ body: '회원 답글', parentId: parent.id, postId: post.id, postTitle: post.title, postSlug: post.slug })
const second = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=2`)).json()
expect(second.items[0].body).toBe('회원 댓글')
const searched = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&q=${encodeURIComponent('회원 답글')}`)).json()
expect(searched.pagination.total).toBe(1)
expect(searched.items[0].body).toBe('회원 답글')
const literal = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&q=%25`)).json()
expect(literal.items).toEqual([])
const last = await (await adminApi.get(`/admin/api/comments?memberId=${adminUser.userId}&pageSize=1&page=999`)).json()
expect(last.pagination.page).toBe(2)
const recent = await (await adminApi.get('/admin/api/comments?pageSize=5')).json()
@@ -270,6 +275,45 @@ test('관리자 댓글 조회는 인증·입력 검증과 멤버별 페이지·
}
})
test('멤버 작성 글 조회는 작성자를 분리하고 전체 이력을 검색·페이지 조회한다', async ({ request: anonymousApi }) => {
const url = `/admin/api/members/${adminUser.userId}/posts`
expect((await anonymousApi.get(url)).status()).toBe(401)
for (const query of ['page=0', 'pageSize=101']) {
expect((await adminApi.get(`${url}?${query}`)).status()).toBe(400)
}
expect((await adminApi.get('/admin/api/members/invalid/posts')).status()).toBe(400)
const [author] = await database`
INSERT INTO users (username, email, password_hash)
VALUES ('작성 글 검증', 'author-history@example.com', 'unused') RETURNING id
`
try {
await database`
INSERT INTO posts (title, slug, content, status, author_id, created_at)
SELECT '작성 이력 ' || index, 'author-history-' || index, '검색 본문 ' || index,
'draft', ${author.id}, now() + index * interval '1 second'
FROM generate_series(1, 11) AS index
`
const authorUrl = `/admin/api/members/${author.id}/posts`
const first = await (await adminApi.get(authorUrl)).json()
expect(first.pagination).toMatchObject({ total: 11, pageCount: 2, page: 1 })
expect(first.items).toHaveLength(10)
expect(first.items[0].title).toBe('작성 이력 11')
const last = await (await adminApi.get(`${authorUrl}?page=999`)).json()
expect(last.pagination.page).toBe(2)
expect(last.items[0].title).toBe('작성 이력 1')
const search = await (await adminApi.get(`${authorUrl}?q=${encodeURIComponent('검색 본문 11')}`)).json()
expect(search.pagination.total).toBe(1)
expect(search.items[0].title).toBe('작성 이력 11')
const empty = await (await adminApi.get(`${authorUrl}?q=%25`)).json()
expect(empty.items).toEqual([])
const others = await (await adminApi.get(url)).json()
expect(others.items.some((item) => item.slug.startsWith('author-history-'))).toBe(false)
} finally {
await database`DELETE FROM posts WHERE author_id = ${author.id}`
await database`DELETE FROM users WHERE id = ${author.id}`
}
})
test('CSP 수집은 잘못된 형식과 32KB 초과 본문을 거부한다', async ({ request: anonymousApi }) => {
const unsupportedResponse = await anonymousApi.post('/api/csp-report', {
headers: {

View File

@@ -23,18 +23,37 @@ test('멤버 댓글 페이지 이동·게시물 링크와 대시보드 최근
}
await page.goto(`/admin/members/${admin.userId}`)
await page.waitForLoadState('networkidle')
const list = page.locator('.admin-comment-list')
await expect(list.locator('.admin-comment-list__item')).toHaveCount(10)
await expect(list.locator('.admin-comment-list__body').first()).toHaveText('화면 댓글 10')
await expect(page.locator('.admin-member-activity')).toHaveCount(0)
await page.getByRole('button', { name: '작성한 댓글', exact: true }).click()
const list = page.locator('.admin-member-activity')
await expect(list.locator('.admin-member-activity__item')).toHaveCount(10)
await expect(list.locator('.admin-member-activity__body').first()).toHaveText('화면 댓글 10')
await expect(list.getByRole('link', { name: '게시물 보기' }).first()).toHaveAttribute('href', '/post/comments-ui-test#comments')
await list.getByRole('button', { name: '다음' }).click()
await expect(list.locator('.admin-comment-list__item')).toHaveCount(1)
await expect(list.locator('.admin-comment-list__body')).toHaveText('화면 댓글 0')
await list.getByRole('button', { name: '이전' }).click()
await expect(list.locator('.admin-comment-list__item')).toHaveCount(10)
await list.getByRole('button', { name: '다음 페이지' }).click()
await expect(list.locator('.admin-member-activity__item')).toHaveCount(1)
await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0')
await list.getByRole('button', { name: '이전 페이지' }).click()
await expect(list.locator('.admin-member-activity__item')).toHaveCount(10)
await list.getByLabel('작성한 댓글 검색').fill('화면 댓글 0')
await list.getByRole('button', { name: '검색', exact: true }).click()
await expect(list.locator('.admin-member-activity__item')).toHaveCount(1)
await expect(list.locator('.admin-member-activity__body')).toHaveText('화면 댓글 0')
await list.getByLabel('작성한 댓글 검색').fill('결과없음')
await list.getByRole('button', { name: '검색', exact: true }).click()
await expect(list).toContainText('검색 결과가 없습니다.')
await page.getByRole('button', { name: '작성한 글', exact: true }).click()
await expect(page.locator('.admin-member-activity__item')).toHaveCount(1)
await page.getByLabel('작성한 글 검색').fill('없는글')
await page.locator('.admin-member-activity').getByRole('button', { name: '검색', exact: true }).click()
await expect(page.locator('.admin-member-activity')).toContainText('검색 결과가 없습니다.')
await page.getByRole('button', { name: '정보', exact: true }).click()
await expect(page.locator('.admin-member-form__body')).toBeVisible()
await page.goto('/admin')
await expect(page.locator('.admin-comment-list__item')).toHaveCount(5)
await expect(page.locator('.admin-comment-list__body').first()).toHaveText('화면 댓글 10')
await expect(page.locator('.admin-dashboard__csp')).toHaveCount(0)
const recentBox = await page.locator('.admin-comment-list').boundingBox()
expect(recentBox.height).toBeLessThan(250)
await page.goto('/admin/posts')
const row = page.locator('.admin-posts__row').filter({ hasText: '댓글 화면 검증 게시물' })
await expect(row.locator('.admin-posts__comment-count')).toHaveText('11')