From 227b9cd5a10b949a31c6374623906599a81a35bb Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 29 Apr 2026 22:09:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EA=B2=80=EC=83=89,=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/user/api/AdminUserController.java | 60 +++++++++++++++++ .../dto/response/AdminUserListResponse.java | 33 ++++++++++ .../application/AdminUserQueryService.java | 40 +++++++++++ .../user/core/repository/UserRepository.java | 47 +++++++++++++ .../core/repository/UserRepositoryTest.java | 66 +++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java new file mode 100644 index 00000000..a7db38a9 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java @@ -0,0 +1,60 @@ +package org.devkor.apu.saerok_server.domain.admin.user.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse; +import org.devkor.apu.saerok_server.domain.admin.user.application.AdminUserQueryService; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin User API", description = "관리자 사용자 조회 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/users") +public class AdminUserController { + + private static final int MAX_PAGE_SIZE = 50; + + private final AdminUserQueryService queryService; + + @GetMapping + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "사용자 ID/닉네임 목록 조회", + description = "대상 공지 발송용 활성 사용자 ID와 닉네임 목록을 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = AdminUserListResponse.class)) + ) + } + ) + public AdminUserListResponse listUsers( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size + ) { + validatePagination(page, size); + return queryService.listUsers(q, page, size); + } + + private void validatePagination(int page, int size) { + if (page < 1) { + throw new BadRequestException("page는 1 이상의 숫자로 입력해 주세요."); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BadRequestException("size는 1 이상 50 이하의 숫자로 입력해 주세요."); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java new file mode 100644 index 00000000..cc6bc6db --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java @@ -0,0 +1,33 @@ +package org.devkor.apu.saerok_server.domain.admin.user.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "관리자 사용자 목록 응답") +public record AdminUserListResponse( + @Schema(description = "사용자 목록") + List users, + + @Schema(description = "현재 페이지", example = "1") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "전체 사용자 수", example = "120") + long totalElements, + + @Schema(description = "전체 페이지 수", example = "6") + int totalPages +) { + + public record Item( + @Schema(description = "사용자 ID", example = "501") + Long id, + + @Schema(description = "닉네임", example = "솔바람") + String nickname + ) { + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java new file mode 100644 index 00000000..d9ab9981 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java @@ -0,0 +1,40 @@ +package org.devkor.apu.saerok_server.domain.admin.user.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminUserQueryService { + + private final UserRepository userRepository; + + public AdminUserListResponse listUsers(String query, int page, int size) { + String normalizedQuery = normalizeQuery(query); + int offset = (page - 1) * size; + + List users = userRepository.findActiveNicknameUsers(normalizedQuery, offset, size); + long totalElements = userRepository.countActiveNicknameUsers(normalizedQuery); + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + + List items = users.stream() + .map(user -> new AdminUserListResponse.Item(user.getId(), user.getNickname())) + .toList(); + + return new AdminUserListResponse(items, page, size, totalElements, totalPages); + } + + private String normalizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + return query.trim(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java index 6db20445..7bb154f5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java @@ -62,6 +62,53 @@ public List findActiveUserIds(int offset, int limit) { .getResultList(); } + public List findActiveNicknameUsers(String nicknameQuery, int offset, int limit) { + String jpql = """ + SELECT u FROM User u + WHERE u.deletedAt IS NULL + AND u.signupStatus <> :withdrawn + AND u.nickname IS NOT NULL + AND TRIM(u.nickname) <> '' + """; + if (nicknameQuery != null) { + jpql += " AND u.nickname LIKE :nicknameQuery"; + } + jpql += " ORDER BY u.nickname ASC, u.id ASC"; + + var query = em.createQuery(jpql, User.class) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN) + .setFirstResult(offset) + .setMaxResults(limit); + + if (nicknameQuery != null) { + query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); + } + + return query.getResultList(); + } + + public long countActiveNicknameUsers(String nicknameQuery) { + String jpql = """ + SELECT COUNT(u) FROM User u + WHERE u.deletedAt IS NULL + AND u.signupStatus <> :withdrawn + AND u.nickname IS NOT NULL + AND TRIM(u.nickname) <> '' + """; + if (nicknameQuery != null) { + jpql += " AND u.nickname LIKE :nicknameQuery"; + } + + var query = em.createQuery(jpql, Long.class) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN); + + if (nicknameQuery != null) { + query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); + } + + return query.getSingleResult(); + } + public List findByIds(List ids) { if (ids == null || ids.isEmpty()) return List.of(); return em.createQuery( diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java index 79be47cd..1f53c113 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java @@ -138,6 +138,72 @@ void findActiveUserIds_empty() { assertThat(activeIds).isEmpty(); } + @Test @DisplayName("findActiveNicknameUsers - 활성 닉네임 사용자만 닉네임순으로 조회") + void findActiveNicknameUsers() { + User charlie = user("charlie@example.com", "charlie"); + charlie.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("bravo@example.com", "bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + User withdrawn = user("withdrawn-nickname@example.com", "alpha"); + withdrawn.setSignupStatus(SignupStatusType.WITHDRAWN); + User deleted = user("deleted-nickname@example.com", "beta"); + User noNickname = user("no-nickname@example.com", null); + noNickname.setSignupStatus(SignupStatusType.COMPLETED); + User blankNickname = user("blank-nickname@example.com", ""); + blankNickname.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); + + deleted.softDelete(); + em.flush(); em.clear(); + + List users = repo.findActiveNicknameUsers(null, 0, 20); + + assertThat(users) + .extracting(User::getNickname) + .containsExactly("bravo", "charlie"); + assertThat(repo.countActiveNicknameUsers(null)).isEqualTo(2); + } + + @Test @DisplayName("findActiveNicknameUsers - 닉네임 검색과 페이징") + void findActiveNicknameUsers_queryAndPagination() { + User alpha = user("alpha@example.com", "alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User alpine = user("alpine@example.com", "alpine"); + alpine.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("bravo-query@example.com", "bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List firstPage = repo.findActiveNicknameUsers("alp", 0, 1); + List secondPage = repo.findActiveNicknameUsers("alp", 1, 1); + + assertThat(firstPage) + .extracting(User::getNickname) + .containsExactly("alpha"); + assertThat(secondPage) + .extracting(User::getNickname) + .containsExactly("alpine"); + assertThat(repo.countActiveNicknameUsers("alp")).isEqualTo(2); + } + + @Test @DisplayName("findActiveNicknameUsers - 닉네임 중간 문자열 검색") + void findActiveNicknameUsers_containsQuery() { + User duli = user("duli@example.com", "둘리"); + duli.setSignupStatus(SignupStatusType.COMPLETED); + User pigeon = user("pigeon@example.com", "비둘기"); + pigeon.setSignupStatus(SignupStatusType.COMPLETED); + User magpie = user("magpie@example.com", "까치"); + magpie.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.findActiveNicknameUsers("둘", 0, 20); + + assertThat(users) + .extracting(User::getNickname) + .containsExactly("둘리", "비둘기"); + assertThat(repo.countActiveNicknameUsers("둘")).isEqualTo(2); + } + @Test @DisplayName("save - 중복 닉네임은 제약조건 위반") void save_duplicateNickname() { User u1 = user("user1@example.com");