관리자와 회원 설정 계정 작업 정리

This commit is contained in:
2026-05-13 15:26:26 +09:00
parent 6481f958f5
commit bebf7ee1c9
18 changed files with 811 additions and 175 deletions

View File

@@ -1,6 +1,5 @@
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
import { getUserById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { getRequestIP } from 'h3'
/**
* 회원 세션 조회 API
@@ -9,10 +8,6 @@ import { getRequestIP } from 'h3'
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
await touchUserActivity({
userId: session.userId,
ip: String(getRequestIP(event) || '')
})
const user = await getUserById(session.userId)
if (!user) {
@@ -31,4 +26,3 @@ export default defineEventHandler(async (event) => {
avatarUrl: user.avatarUrl || ''
}
})

View File

@@ -1,15 +1,15 @@
import { getUserById } from '../../repositories/member-repository'
import { getUserProfileById } from '../../repositories/member-repository'
import { requireMemberSession } from '../../utils/member-auth'
import { createError } from 'h3'
/**
* 회원 프로필 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
*/
export default defineEventHandler(async (event) => {
const session = requireMemberSession(event)
const user = await getUserById(session.userId)
const user = await getUserProfileById(session.userId)
if (!user) {
throw createError({
@@ -22,7 +22,12 @@ export default defineEventHandler(async (event) => {
id: user.id,
email: user.email,
username: user.username,
avatarUrl: user.avatarUrl || ''
avatarUrl: user.avatarUrl || '',
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
previousLastSeenAt: user.previousLastSeenAt ? user.previousLastSeenAt.toISOString() : null,
previousLastSeenIp: user.previousLastSeenIp || '',
commentCount: Number(user.commentCount || 0)
}
})

View File

@@ -27,6 +27,7 @@ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
*/
const mapAdminMemberRow = (row) => {
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
const previousLastSeenAt = row.previousLastSeenAt ? row.previousLastSeenAt.toISOString() : null
const isActive = row.lastSeenAt
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
: false
@@ -45,6 +46,8 @@ const mapAdminMemberRow = (row) => {
updatedAt: row.updatedAt.toISOString(),
lastSeenAt,
lastSeenIp: row.lastSeenIp || '',
previousLastSeenAt,
previousLastSeenIp: row.previousLastSeenIp || '',
commentCount: Number(row.commentCount || 0),
activityStatus: isActive ? '활성' : '비활성',
role: getMemberRoleLabel(roleCode)
@@ -64,6 +67,8 @@ const mapAdminMemberRow = (row) => {
* @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
* @property {string} lastSeenIp - 최근 접속 IP
* @property {string | null} previousLastSeenAt - 이전 로그인 시각(ISO)
* @property {string} previousLastSeenIp - 이전 로그인 IP
*/
/**
@@ -100,7 +105,9 @@ export const getUserByEmail = async (email) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE lower(email) = lower(${email})
LIMIT 1
@@ -127,7 +134,9 @@ export const getUserById = async (id) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE id = ${id}
LIMIT 1
@@ -136,6 +145,38 @@ export const getUserById = async (id) => {
return rows?.[0] || null
}
/**
* 사용자 설정 화면용 회원 프로필을 조회한다.
* @param {string} id - 사용자 ID
* @returns {Promise<(Omit<MemberUser, 'passwordHash'> & { commentCount: number }) | null>} 회원 프로필
*/
export const getUserProfileById = async (id) => {
const sql = requireSql()
const rows = await sql`
SELECT
users.id,
users.username,
users.email,
users.avatar_url AS "avatarUrl",
users.is_admin AS "isAdmin",
users.user_role AS "role",
users.created_at AS "createdAt",
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
users.last_seen_ip AS "lastSeenIp",
users.previous_last_seen_at AS "previousLastSeenAt",
users.previous_last_seen_ip AS "previousLastSeenIp",
COALESCE(count(comments.id), 0)::int AS "commentCount"
FROM users
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
WHERE users.id = ${id}
GROUP BY users.id
LIMIT 1
`
return rows?.[0] || null
}
/**
* ID로 회원 조회(비밀번호 포함)
* @param {string} id - 사용자 ID
@@ -155,7 +196,9 @@ export const getUserByIdWithPassword = async (id) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE id = ${id}
LIMIT 1
@@ -198,7 +241,9 @@ export const createUser = async (input) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
})
@@ -223,6 +268,8 @@ export const touchUserActivity = async (input) => {
await sql`
UPDATE users
SET
previous_last_seen_at = last_seen_at,
previous_last_seen_ip = last_seen_ip,
last_seen_at = now(),
last_seen_ip = ${input.ip},
updated_at = now()
@@ -254,7 +301,9 @@ export const updateMemberProfile = async (input) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
return rows?.[0] || null
@@ -308,6 +357,38 @@ export const deleteMember = async (userId) => {
`
}
/**
* 관리자 화면에서 회원을 삭제한다.
* @param {{ actorUserId: string, targetUserId: string }} input - 삭제 정보
* @returns {Promise<Object>} 삭제된 회원
*/
export const deleteMemberByAdmin = async (input) => {
const target = await getMemberForAdmin(input.targetUserId)
if (!target) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
if (target.id === input.actorUserId) {
throw createError({
statusCode: 400,
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
})
}
if (target.roleCode === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
throw createError({
statusCode: 400,
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
})
}
await deleteMember(input.targetUserId)
return target
}
/**
* 소유자 권한 회원 수를 조회한다.
* @returns {Promise<number>} 소유자 회원 수
@@ -375,7 +456,7 @@ export const isEmailTaken = async (input) => {
/**
* 관리자용 회원 목록 조회(댓글 활동 포함)
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
*/
export const listMembersForAdmin = async () => {
const sql = requireSql()
@@ -393,6 +474,8 @@ export const listMembersForAdmin = async () => {
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
users.last_seen_ip AS "lastSeenIp",
users.previous_last_seen_at AS "previousLastSeenAt",
users.previous_last_seen_ip AS "previousLastSeenIp",
COALESCE(count(comments.id), 0)::int AS "commentCount"
FROM users
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
@@ -424,6 +507,8 @@ export const getMemberForAdmin = async (memberId) => {
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
users.last_seen_ip AS "lastSeenIp",
users.previous_last_seen_at AS "previousLastSeenAt",
users.previous_last_seen_ip AS "previousLastSeenIp",
COALESCE(count(comments.id), 0)::int AS "commentCount"
FROM users
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
@@ -519,7 +604,9 @@ export const getAdminUserByEmail = async (email) => {
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE lower(email) = lower(${email})
AND user_role = ANY(${PRIVILEGED_ROLES})

View File

@@ -1,8 +1,8 @@
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { createError, getRequestIP, readBody } from 'h3'
import bcrypt from 'bcrypt'
import { setAdminSession } from '../../../../utils/admin-auth'
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
import { setMemberSession } from '../../../../utils/member-auth'
const loginSchema = z.object({
@@ -47,6 +47,10 @@ export default defineEventHandler(async (event) => {
userId: adminUser.id,
email: adminUser.email
})
await touchUserActivity({
userId: adminUser.id,
ip: String(getRequestIP(event) || '')
})
return {
userId: adminUser.id,

View File

@@ -0,0 +1,32 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { deleteMemberByAdmin } from '../../../../repositories/member-repository'
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
/**
* 관리자 회원 삭제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: true }>} 삭제 결과
*/
export default defineEventHandler(async (event) => {
const session = requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
const deletedMember = await deleteMemberByAdmin({
actorUserId: session.userId,
targetUserId: memberId
})
if (deletedMember.avatarUrl) {
await removeManagedAvatarAsset(deletedMember.avatarUrl)
}
return { ok: true }
})

View File

@@ -0,0 +1,50 @@
import bcrypt from 'bcrypt'
import { createError, getRouterParam, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
const adminMemberPasswordSchema = z.object({
password: z.string().min(8).max(32)
})
/**
* 관리자 회원 비밀번호 직접 변경 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: true }>} 변경 결과
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
})
}
const member = await getMemberForAdmin(memberId)
if (!member) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
const passwordHash = await bcrypt.hash(parsedBody.data.password, 12)
await updateMemberPassword({
userId: memberId,
passwordHash
})
return { ok: true }
})