VIP 멤버십 공개 범위 적용 v1.5.5

This commit is contained in:
2026-05-26 16:22:05 +09:00
parent 6333c4254f
commit 3843e16d9f
17 changed files with 169 additions and 48 deletions

View File

@@ -24,13 +24,22 @@ const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false) const deleteModalOpen = ref(false)
const isUpdatingPassword = ref(false) const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false) const isDeletingMember = ref(false)
const isUpdatingRole = ref(false)
const actionMessage = ref('') const actionMessage = ref('')
const actionError = ref('') const actionError = ref('')
const roleOptions = [
{ value: 'owner', label: '소유자' },
{ value: 'admin', label: '관리자' },
{ value: 'vip', label: 'VIP' },
{ value: 'member', label: '멤버' }
]
const form = reactive({ const form = reactive({
username: '', username: '',
email: '', email: '',
avatarUrl: '', avatarUrl: '',
roleCode: 'member',
labelsText: '', labelsText: '',
note: '' note: ''
}) })
@@ -53,6 +62,7 @@ const syncMemberForm = () => {
form.username = member.username || '' form.username = member.username || ''
form.email = member.email || '' form.email = member.email || ''
form.avatarUrl = member.avatarUrl || '' form.avatarUrl = member.avatarUrl || ''
form.roleCode = member.roleCode || 'member'
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : '' form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
form.note = member.note || '' form.note = member.note || ''
} }
@@ -77,6 +87,8 @@ const normalizedLabels = computed(() => [...new Set(
.filter(Boolean) .filter(Boolean)
)]) )])
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
/** /**
* 회원 저장 요청 본문을 문자열로 직렬화한다. * 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값 * @returns {string} 직렬화된 회원 입력값
@@ -328,6 +340,40 @@ const updateMemberPassword = async () => {
} }
} }
/**
* 관리자 권한으로 회원 등급을 변경한다.
* @returns {Promise<void>}
*/
const updateMemberRole = async () => {
if (isNewMember.value || isUpdatingRole.value) {
return
}
actionMessage.value = ''
actionError.value = ''
isUpdatingRole.value = true
try {
const updated = await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
emit('saved', {
...props.member,
...updated
})
actionMessage.value = '멤버 등급이 변경되었습니다.'
} catch (error) {
form.roleCode = props.member?.roleCode || 'member'
actionError.value = error?.data?.message || '멤버 등급 변경에 실패했습니다.'
} finally {
isUpdatingRole.value = false
}
}
/** /**
* 관리자 권한으로 회원을 삭제한다. * 관리자 권한으로 회원을 삭제한다.
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -481,6 +527,7 @@ watch(() => props.member, () => {
<div class="min-w-0"> <div class="min-w-0">
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2> <h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p> <p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
<p class="mt-1 text-xs font-semibold uppercase tracking-[0.04em] text-[#9aa4b2]">{{ currentRoleLabel }}</p>
</div> </div>
</div> </div>
@@ -532,6 +579,23 @@ watch(() => props.member, () => {
</label> </label>
</div> </div>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">멤버 등급</span>
<select
v-model="form.roleCode"
class="admin-member-form__select h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="isNewMember || isUpdatingRole"
@change="updateMemberRole"
>
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<span class="admin-member-form__hint mt-2 block text-sm text-[#8a95a5]">
VIP 이상 등급은 멤버십 게시물을 있습니다.
</span>
</label>
<label class="admin-member-form__field mt-5 block"> <label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span> <span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
<input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분"> <input v-model="form.labelsText" class="admin-member-form__input h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" placeholder="쉼표로 구분">

View File

@@ -1659,7 +1659,7 @@ defineExpose({
</svg> </svg>
</span> </span>
<span v-if="form.status === 'members'" class="admin-post-form__hint text-xs text-muted"> <span v-if="form.status === 'members'" class="admin-post-form__hint text-xs text-muted">
로그인한 회원에게만 공개됩니다. 등급별 제한은 이후 멤버십 등급 기능에서 확장합니다. VIP 이상 등급 회원에게만 공개됩니다.
</span> </span>
<span v-else-if="form.status === 'private'" class="admin-post-form__hint text-xs text-muted"> <span v-else-if="form.status === 'private'" class="admin-post-form__hint text-xs text-muted">
공개 사용자 화면에서는 보이지 않습니다. 공개 사용자 화면에서는 보이지 않습니다.

View File

@@ -0,0 +1,6 @@
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_user_role_check;
ALTER TABLE users
ADD CONSTRAINT users_user_role_check
CHECK (user_role IN ('owner', 'admin', 'vip', 'member'));

View File

@@ -1,5 +1,10 @@
# 업데이트 요약 # 업데이트 요약
## v1.5.5
- 멤버 등급에 VIP를 추가하고, 멤버십 게시물은 VIP 이상 등급에게만 공개되도록 정리했다.
- 관리자 멤버 상세에서 회원 등급을 직접 변경할 수 있게 했다.
## v1.5.4 ## v1.5.4
- 게시물에 멤버십·비공개 상태를 추가하고, 공개 화면에서는 상태에 맞는 글만 보이도록 정리했다. - 게시물에 멤버십·비공개 상태를 추가하고, 공개 화면에서는 상태에 맞는 글만 보이도록 정리했다.

View File

@@ -1,8 +1,12 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-26 v1.5.5 — 멤버십 공개 기준을 VIP 등급으로 고정
로그인 회원은 댓글 작성을 위해 필요한 기본 사용자 범위라서 멤버십 콘텐츠의 공개 기준으로 쓰기에는 너무 넓다. 따라서 `members` 상태 게시물은 단순 세션 존재가 아니라 `vip`, `admin`, `owner` 등급에만 노출되도록 바꾼다. `vip`는 관리자 권한은 없지만 멤버십 콘텐츠 접근 권한을 가진 등급이며, 관리자 멤버 상세 화면에서 직접 지정할 수 있게 했다.
## 2026-05-26 v1.5.4 — 게시물·페이지 공개 상태를 다시 확장 ## 2026-05-26 v1.5.4 — 게시물·페이지 공개 상태를 다시 확장
게시물은 초안·발행·예약만으로는 운영자가 숨겨야 하는 글과 회원에게만 보여야 하는 글을 구분하기 어렵다. 따라서 `private`는 공개 화면에서 완전히 숨기는 상태로 되살리고, `members`현재 구현 가능한 회원 세션 기준 공개 상태로 추가한다. 아직 등급 시스템이 없으므로 등급별 제한은 후속 멤버십 권한 모델에서 확장한다. 게시물은 초안·발행·예약만으로는 운영자가 숨겨야 하는 글과 회원에게만 보여야 하는 글을 구분하기 어렵다. 따라서 `private`는 공개 화면에서 완전히 숨기는 상태로 되살리고, `members`회원 전용 공개 상태로 추가한다. 이후 v1.5.5에서 이 회원 전용 공개 기준은 로그인 여부가 아니라 VIP 이상 등급 기준으로 확정했다.
고정 페이지도 운영 전이지만 HTML 랜딩 페이지를 붙여넣어 쓰는 구조라 공개 전 확인이나 비공개 보관 상태가 필요하다. 페이지는 게시물과 달리 멤버십 공개 요구가 아직 없으므로 `draft`, `published`, `private`만 두고, 공개 API와 HTML 문서 미들웨어는 `published`만 응답하게 정리한다. 고정 페이지도 운영 전이지만 HTML 랜딩 페이지를 붙여넣어 쓰는 구조라 공개 전 확인이나 비공개 보관 상태가 필요하다. 페이지는 게시물과 달리 멤버십 공개 요구가 아직 없으므로 `draft`, `published`, `private`만 두고, 공개 API와 HTML 문서 미들웨어는 `published`만 응답하게 정리한다.

View File

@@ -132,7 +132,7 @@
| pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) | | pages/admin/pages/index.vue | 페이지 목록, 상태 표시, 화면 기준 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 | | pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 |
| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/프로필 이미지** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 | | composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
@@ -143,7 +143,7 @@
| 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, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | | pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
## 공개 페이지 ## 공개 페이지
@@ -166,8 +166,8 @@
| 파일 | 기능 | | 파일 | 기능 |
|------|------| |------|------|
| server/api/posts.get.js | 공개 게시물 목록 API(`published`, 회원 세션`members`) | | server/api/posts.get.js | 공개 게시물 목록 API(`published`, VIP 이상 등급`members`) |
| server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, 회원 세션`members`) | | server/api/posts/[slug].get.js | 공개 게시물 상세 API(`published`, VIP 이상 등급`members`) |
| server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) | | server/api/pages.get.js | 공개 고정 페이지 목록 API(`published`만) |
| server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) | | server/api/pages/[slug].get.js | 공개 고정 페이지 상세 API(`published`만) |
| server/api/tags.get.js | 태그 목록 샘플 API | | server/api/tags.get.js | 태그 목록 샘플 API |
@@ -231,7 +231,7 @@
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API | | server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) | | server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API | | server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API | | server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`) |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 | | server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 | | server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 | | server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
@@ -281,7 +281,7 @@
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 | | db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 | | db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
| db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 | | db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 |
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 | | db/migrations/014_add_user_role_levels.sql | 회원 권한 단계(owner/admin/member) 컬럼 추가 |
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 | | db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 | | db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
| db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 | | db/migrations/030_analytics_daily_stats.sql | 사이트·게시물 일별 통계·일별 방문자 해시 테이블 |
@@ -291,6 +291,7 @@
| db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 | | db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 |
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 | | db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 | | db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
## 설정/배포 ## 설정/배포

View File

@@ -268,7 +268,7 @@ components/content/
| updated_at | DateTime | 수정일 | | updated_at | DateTime | 수정일 |
> API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다. > API 응답의 게시물 객체는 `isFeatured`와 `commentCount`를 함께 반환한다. `commentCount`는 `published` 상태 댓글 수를 기준으로 한다.
> 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 회원 세션이 있을 때만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다. > 공개 게시물 목록·상세는 `published` 상태만 기본 노출하며, `members` 상태는 VIP 이상 등급(`vip`/`admin`/`owner`) 회원에게만 노출한다. `private`와 `draft`는 공개 화면에서 노출하지 않는다.
### Users ### Users
@@ -280,7 +280,7 @@ components/content/
| password_hash | String | bcrypt 해시 비밀번호 | | password_hash | String | bcrypt 해시 비밀번호 |
| avatar_url | String | 프로필 썸네일 URL | | avatar_url | String | 프로필 썸네일 URL |
| is_admin | Boolean | 관리자 권한 여부 | | is_admin | Boolean | 관리자 권한 여부 |
| user_role | Enum | 권한 단계(`owner`/`admin`/`member`) | | user_role | Enum | 권한 단계(`owner`/`admin`/`vip`/`member`) |
| last_seen_at | DateTime nullable | 마지막 접속 시각 | | last_seen_at | DateTime nullable | 마지막 접속 시각 |
| last_seen_ip | String | 마지막 접속 IP | | last_seen_ip | String | 마지막 접속 IP |
| created_at | DateTime | 생성일 | | created_at | DateTime | 생성일 |
@@ -510,10 +510,10 @@ components/content/
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함) - `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다. - `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭) - `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`) - `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다. > 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
> 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 현재 회원 세션이 있는 사용자에게만 공개하며, 등급별 제한은 후속 멤버십 등급 기능에서 확장한다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다. > 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
> 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일). > 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. > 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. > 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다.
@@ -691,10 +691,10 @@ components/content/
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. - 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다. - 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. - 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다. - 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다. - 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다. - `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다. - 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다.
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다. - 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다. - 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다. - `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
@@ -742,20 +742,20 @@ components/content/
- 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록). - 관리자 미디어 업로드 API는 이미지·비디오·오디오·문서 확장자를 허용한다(에디터 슬래시·미디어 모달과 동일 목록).
- 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB). - 업로드 파일 크기 제한은 종류별 환경 변수를 따른다. 이미지·아바타·로고 등은 `MAX_FILE_SIZE`(기본 10MB), 비디오는 `MAX_VIDEO_FILE_SIZE`(기본 200MB), 오디오는 `MAX_AUDIO_FILE_SIZE`(기본 50MB), 문서·ZIP 등은 `MAX_DOCUMENT_FILE_SIZE`(기본 50MB).
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다. - 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
- 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **썸네일** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 썸네일 탭에서는 `/members/avatars/` 파일만 검색·탐색한다. - 관리자 미디어 화면 상단에 **미디어 라이브러리** 탭과 **프로필 이미지** 탭을 두어, 라이브러리 탭에서는 게시물·기타 이미지만, 프로필 이미지 탭에서는 `/members/avatars/` 파일만 검색·탐색한다.
- 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다. - 미디어 라이브러리 탭은 왼쪽 폴더 트리와 오른쪽 고밀도 썸네일 갤러리, 검색, 파일명 변경, 개별 삭제를 제공한다.
- 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다. - 관리자는 폴더 추가 버튼으로 모달에서 새 폴더·하위 폴더 이름을 입력해 만들 수 있으며, `미분류`·`썸네일`(및 그 하위)을 제외한 폴더는 목록에서 삭제할 수 있다. 폴더 삭제 시 해당 경로 및 하위 경로로 분류돼 있던 미디어 메타는 모두 `미분류`로 되돌린다.
- 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다. - 썸네일 본문(이미지·파일명) 한 번 클릭 시 상세(미리보기) 모달이 열리고, 썸네일 좌측 상단 **선택 토글**로 개별 선택한다. Shift+클릭으로 범위 선택이 가능하다.
- 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다. - 미디어 라이브러리 탭에서만 선택한 미디어를 폴더로 드래그하면 해당 미디어들의 폴더 경로가 일괄 변경된다.
- 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다). - 관리자 미디어 화면 검색은 **저장 파일명**과 게시물·페이지 **사용처 제목**만 대상으로 한다(URL 경로·논리 폴더명 문자열은 검색에 쓰지 않는다).
- 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(썸네일 탭), 용량 등 세부 정보는 상세 모달에서 표시한다. - 미디어 파일 경로, 사용 현황(라이브러리), 연결 회원(프로필 이미지 탭), 용량 등 세부 정보는 상세 모달에서 표시한다.
- API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다. - API 실패·클라이언트 검증 실패 등 사용자 피드백은 본문 상단 고정 배너가 아니라 `useAdminToast` 우측 상단 토스트로 표시해 모달에 가리지 않는다.
- 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통). - 상세 모달의 **다운로드**는 공개 `/uploads/...` URL을 `download` 속성으로 브라우저에 내려받는다(썸네일·게시물 이미지 공통).
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다. - 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다. - 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
- 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘·홈 커버 URL을 기준으로 표시한다. - 미디어 사용 현황은 게시물/페이지의 대표 이미지·본문 내 URL과 사이트 설정의 로고·파비콘·홈 커버 URL을 기준으로 표시한다.
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다. - 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
- 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 썸네일 탭에서 정리할 수 있다. - 회원 프로필 썸네일 파일은 `users.avatar_url`이 해당 URL을 가리킬 때만 관리자 화면에서 파일명 변경·삭제를 차단한다. 프로필에서 교체·해제된 파일은 디스크에 남으며 프로필 이미지 탭에서 정리할 수 있다.
--- ---

View File

@@ -7,7 +7,6 @@
## 2차 관리자 개발 ## 2차 관리자 개발
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결 - [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
- [ ] 멤버십 글 공개 범위를 회원 등급별로 제한하는 권한 모델 추가. 현재 `members` 게시물은 로그인 회원 세션 기준으로만 공개됨
## 프론트엔드 개발 ## 프론트엔드 개발

View File

@@ -1,9 +1,18 @@
# 업데이트 이력 # 업데이트 이력
## v1.5.5
- 회원 권한에 `VIP` 등급 추가.
- 공개 게시물 API: 멤버십 글은 로그인 여부가 아니라 VIP 이상 등급(`vip`/`admin`/`owner`)일 때만 조회되도록 수정.
- 관리자 멤버 상세: 멤버 등급 선택 UI 추가.
- 관리자 글쓰기: 멤버십 상태 안내 문구를 VIP 이상 공개 기준으로 수정.
- 관리자 미디어: 사용자 편집분을 포함해 썸네일 탭 문구를 프로필 이미지 기준으로 정리.
- DB: `users.user_role` 제약에 `vip`를 허용하는 마이그레이션 추가.
## v1.5.4 ## v1.5.4
- 게시물 상태: `멤버십`, `비공개` 상태 추가. - 게시물 상태: `멤버십`, `비공개` 상태 추가.
- 공개 게시물 API: 멤버십 글은 로그인 회원 세션이 있을 때만 조회되도록 수정. - 공개 게시물 API: 멤버십 글을 공개 기본 목록에서 분리하도록 수정.
- 공개 게시물 API: 비공개 글은 사용자 화면과 공개 API에서 숨기도록 정리. - 공개 게시물 API: 비공개 글은 사용자 화면과 공개 API에서 숨기도록 정리.
- 관리자 글쓰기: 상태 선택·툴바·목록 필터에 멤버십/비공개 상태 반영. - 관리자 글쓰기: 상태 선택·툴바·목록 필터에 멤버십/비공개 상태 반영.
- 고정 페이지 상태: `초안`, `공개`, `비공개` 상태 추가. - 고정 페이지 상태: `초안`, `공개`, `비공개` 상태 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.5.4", "version": "1.5.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "1.5.4", "version": "1.5.5",
"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.4", "version": "1.5.5",
"private": true, "private": true,
"type": "module", "type": "module",
"imports": { "imports": {

View File

@@ -633,7 +633,7 @@ const deleteMedia = async (item) => {
type="button" type="button"
@click="setActiveTab('thumbnails')" @click="setActiveTab('thumbnails')"
> >
썸네일 프로필 이미지
</button> </button>
</div> </div>
</div> </div>
@@ -750,11 +750,11 @@ const deleteMedia = async (item) => {
type="button" type="button"
@click="selectFolder('')" @click="selectFolder('')"
> >
<span>전체 썸네일</span> <span>전체 이미지</span>
<span>{{ thumbnailMediaItems.length }}</span> <span>{{ thumbnailMediaItems.length }}</span>
</button> </button>
<p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted"> <p class="admin-media__thumb-hint mt-4 text-xs leading-relaxed text-muted">
회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 목록에 남습니다. 목록이 바로 바뀌면 페이지를 새로고침하세요. 관리자는 필요 삭제·다운로드로 정리할 있습니다. 회원 프로필에서 쓰는 이미지는 연결 회원이 있을 때만 삭제·이름 변경이 막힙니다. <br> 프로필에서 바꾸거나 해제해도 <strong class="font-semibold text-ink">디스크 파일은 삭제되지 않으며</strong> 목록에 남습니다. <br>목록이 바로 바뀌면 페이지를 새로고침하세요. <br>관리자는 필요 삭제·다운로드로 정리할 있습니다.
</p> </p>
</aside> </aside>

View File

@@ -1,11 +1,15 @@
import { listPosts } from '../repositories/content-repository' import { listPosts } from '../repositories/content-repository'
import { canAccessMembershipContent } from '../repositories/member-repository'
import { getMemberSession } from '../utils/member-auth' import { getMemberSession } from '../utils/member-auth'
/** /**
* 공개 게시물 목록 API * 공개 게시물 목록 API
* @param {import('h3').H3Event} event - 요청 이벤트 * @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Array} 게시물 목록 * @returns {Promise<Array>} 게시물 목록
*/ */
export default defineEventHandler((event) => listPosts({ export default defineEventHandler(async (event) => {
includeMembers: Boolean(getMemberSession(event)) const session = getMemberSession(event)
})) const includeMembership = await canAccessMembershipContent(session?.userId)
return listPosts({ includeMembership })
})

View File

@@ -1,4 +1,5 @@
import { getPostBySlug } from '../../repositories/content-repository' import { getPostBySlug } from '../../repositories/content-repository'
import { canAccessMembershipContent } from '../../repositories/member-repository'
import { getMemberSession } from '../../utils/member-auth' import { getMemberSession } from '../../utils/member-auth'
/** /**
@@ -8,8 +9,10 @@ import { getMemberSession } from '../../utils/member-auth'
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug') const slug = getRouterParam(event, 'slug')
const session = getMemberSession(event)
const includeMembership = await canAccessMembershipContent(session?.userId)
const post = await getPostBySlug(slug, { const post = await getPostBySlug(slug, {
includeMembers: Boolean(getMemberSession(event)) includeMembership
}) })
if (!post) { if (!post) {

View File

@@ -211,10 +211,10 @@ const syncPostTags = async (sql, postId, tags) => {
/** /**
* 공개 게시물 목록 조회 * 공개 게시물 목록 조회
* @param {{ includeMembers?: boolean }} [options] - 회원 전용 글 포함 여부 * @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
* @returns {Promise<Array>} 게시물 목록 * @returns {Promise<Array>} 게시물 목록
*/ */
export const listPosts = async ({ includeMembers = false } = {}) => { export const listPosts = async ({ includeMembership = false } = {}) => {
const sql = getPostgresClient() const sql = getPostgresClient()
if (!sql) { if (!sql) {
@@ -236,7 +236,7 @@ export const listPosts = async ({ includeMembers = false } = {}) => {
LEFT JOIN tags ON tags.id = post_tags.tag_id LEFT JOIN tags ON tags.id = post_tags.tag_id
WHERE ( WHERE (
posts.status = 'published' posts.status = 'published'
OR (${includeMembers} = true AND posts.status = 'members') OR (${includeMembership} = true AND posts.status = 'members')
) )
AND ( AND (
posts.status = 'members' posts.status = 'members'
@@ -445,10 +445,10 @@ export const deleteAdminPost = async (id) => {
/** /**
* 공개 게시물 상세 조회 * 공개 게시물 상세 조회
* @param {string} slug - 게시물 슬러그 * @param {string} slug - 게시물 슬러그
* @param {{ includeMembers?: boolean }} [options] - 회원 전용 글 포함 여부 * @param {{ includeMembership?: boolean }} [options] - VIP 전용 글 포함 여부
* @returns {Promise<Object | null>} 게시물 상세 * @returns {Promise<Object | null>} 게시물 상세
*/ */
export const getPostBySlug = async (slug, { includeMembers = false } = {}) => { export const getPostBySlug = async (slug, { includeMembership = false } = {}) => {
const sql = getPostgresClient() const sql = getPostgresClient()
if (!sql) { if (!sql) {
@@ -471,7 +471,7 @@ export const getPostBySlug = async (slug, { includeMembers = false } = {}) => {
WHERE posts.slug = ${slug} WHERE posts.slug = ${slug}
AND ( AND (
posts.status = 'published' posts.status = 'published'
OR (${includeMembers} = true AND posts.status = 'members') OR (${includeMembership} = true AND posts.status = 'members')
) )
AND ( AND (
posts.status = 'members' posts.status = 'members'

View File

@@ -4,10 +4,12 @@ import { getPostgresClient } from './postgres-client'
export const MEMBER_ROLE = { export const MEMBER_ROLE = {
OWNER: 'owner', OWNER: 'owner',
ADMIN: 'admin', ADMIN: 'admin',
VIP: 'vip',
MEMBER: 'member' MEMBER: 'member'
} }
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN] const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
const MEMBERSHIP_ACCESS_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP]
/** /**
* 회원 권한 표시 문자열을 반환한다. * 회원 권한 표시 문자열을 반환한다.
@@ -18,7 +20,9 @@ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
? '소유자' ? '소유자'
: roleCode === MEMBER_ROLE.ADMIN : roleCode === MEMBER_ROLE.ADMIN
? '관리자' ? '관리자'
: '멤버' : roleCode === MEMBER_ROLE.VIP
? 'VIP'
: '멤버'
/** /**
* 관리자 회원 행을 응답 객체로 변환한다. * 관리자 회원 행을 응답 객체로 변환한다.
@@ -62,7 +66,7 @@ const mapAdminMemberRow = (row) => {
* @property {string} passwordHash - 비밀번호 해시 * @property {string} passwordHash - 비밀번호 해시
* @property {string} avatarUrl - 아바타 URL * @property {string} avatarUrl - 아바타 URL
* @property {boolean} isAdmin - 관리자 여부 * @property {boolean} isAdmin - 관리자 여부
* @property {'owner' | 'admin' | 'member'} role - 권한 코드 * @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
* @property {string} createdAt - 생성 시각(ISO) * @property {string} createdAt - 생성 시각(ISO)
* @property {string} updatedAt - 수정 시각(ISO) * @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO) * @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
@@ -769,15 +773,41 @@ export const isPrivilegedMember = async (userId) => {
return Boolean(rows?.[0]) return Boolean(rows?.[0])
} }
/**
* 회원 전용 콘텐츠 접근 권한이 있는지 확인한다.
* @param {string} userId - 사용자 ID
* @returns {Promise<boolean>} VIP 이상 권한 여부
*/
export const canAccessMembershipContent = async (userId) => {
if (!userId) {
return false
}
const sql = getPostgresClient()
if (!sql) {
return false
}
const rows = await sql`
SELECT id
FROM users
WHERE id = ${userId}
AND user_role = ANY(${MEMBERSHIP_ACCESS_ROLES})
LIMIT 1
`
return Boolean(rows?.[0])
}
/** /**
* 관리자 화면에서 회원 권한을 변경한다. * 관리자 화면에서 회원 권한을 변경한다.
* @param {{ actorUserId: string, targetUserId: string, role: 'owner' | 'admin' | 'member' }} input - 변경 정보 * @param {{ actorUserId: string, targetUserId: string, role: 'owner' | 'admin' | 'vip' | 'member' }} input - 변경 정보
* @returns {Promise<{ id: string, roleCode: string, role: string, isAdmin: boolean }>} 변경 결과 * @returns {Promise<{ id: string, roleCode: string, role: string, isAdmin: boolean }>} 변경 결과
*/ */
export const updateMemberRoleByAdmin = async (input) => { export const updateMemberRoleByAdmin = async (input) => {
const sql = requireSql() const sql = requireSql()
const normalizedRole = String(input.role || '').trim() const normalizedRole = String(input.role || '').trim()
const allowedRoles = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.MEMBER] const allowedRoles = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP, MEMBER_ROLE.MEMBER]
if (!allowedRoles.includes(normalizedRole)) { if (!allowedRoles.includes(normalizedRole)) {
throw createError({ throw createError({
@@ -809,10 +839,10 @@ export const updateMemberRoleByAdmin = async (input) => {
}) })
} }
if (target.id === input.actorUserId && normalizedRole === MEMBER_ROLE.MEMBER) { if (target.id === input.actorUserId && !PRIVILEGED_ROLES.includes(normalizedRole)) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: '본인 계정을 멤버로 변경할 수 없습니다.' message: '본인 계정을 관리자 권한 없는 등급으로 변경할 수 없습니다.'
}) })
} }
@@ -855,11 +885,7 @@ export const updateMemberRoleByAdmin = async (input) => {
return { return {
id: updated.id, id: updated.id,
roleCode: updated.roleCode, roleCode: updated.roleCode,
role: updated.roleCode === MEMBER_ROLE.OWNER role: getMemberRoleLabel(updated.roleCode),
? '소유자'
: updated.roleCode === MEMBER_ROLE.ADMIN
? '관리자'
: '멤버',
isAdmin: Boolean(updated.isAdmin) isAdmin: Boolean(updated.isAdmin)
} }
} }

View File

@@ -4,7 +4,7 @@ import { requireAdminSession } from '../../../../../utils/admin-auth'
import { updateMemberRoleByAdmin } from '../../../../../repositories/member-repository' import { updateMemberRoleByAdmin } from '../../../../../repositories/member-repository'
const roleSchema = z.object({ const roleSchema = z.object({
role: z.enum(['owner', 'admin', 'member']) role: z.enum(['owner', 'admin', 'vip', 'member'])
}) })
/** /**