diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java index 263a2b54..d8a7695d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java @@ -16,6 +16,9 @@ @RequiredArgsConstructor public class AdminNotificationWorker { + private static final String CONTENT_DELETED_TITLE = "콘텐츠 삭제 안내"; + private static final String CONTENT_DELETED_BODY_FORMAT = "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: %s"; + private final NotifySystemService notifySystemService; @Async("pushNotificationExecutor") @@ -42,10 +45,14 @@ public void handle(AdminNotificationEvent.AdminMessageSent event) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(AdminNotificationEvent.ContentDeletedByReport event) { try { - Map extras = Map.of("reason", event.reason()); + Map extras = Map.of( + "title", CONTENT_DELETED_TITLE, + "body", CONTENT_DELETED_BODY_FORMAT.formatted(event.reason()), + "reason", event.reason() + ); notifySystemService.notifyUser( event.contentOwnerId(), - NotificationType.SYSTEM_CONTENT_DELETED, + NotificationType.SYSTEM_ADMIN_MESSAGE, null, extras ); 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 index d9ab9981..6d8f1061 100644 --- 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 @@ -18,10 +18,11 @@ public class AdminUserQueryService { public AdminUserListResponse listUsers(String query, int page, int size) { String normalizedQuery = normalizeQuery(query); + Long idQuery = parseIdQuery(normalizedQuery); int offset = (page - 1) * size; - List users = userRepository.findActiveNicknameUsers(normalizedQuery, offset, size); - long totalElements = userRepository.countActiveNicknameUsers(normalizedQuery); + List users = userRepository.searchActiveUsers(normalizedQuery, idQuery, offset, size); + long totalElements = userRepository.countSearchActiveUsers(normalizedQuery, idQuery); int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); List items = users.stream() @@ -37,4 +38,16 @@ private String normalizeQuery(String query) { } return query.trim(); } + + private Long parseIdQuery(String query) { + if (query == null) { + return null; + } + try { + long value = Long.parseLong(query); + return value > 0 ? value : null; + } catch (NumberFormatException ignored) { + return null; + } + } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index 98c9d22c..84f01bd8 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -19,5 +19,4 @@ public enum NotificationType { // ---- System Notification Types ---- SYSTEM_PUBLISHED_ANNOUNCEMENT, SYSTEM_ADMIN_MESSAGE, - SYSTEM_CONTENT_DELETED, } 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 7bb154f5..f90bd073 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 @@ -1,6 +1,7 @@ package org.devkor.apu.saerok_server.domain.user.core.repository; import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.user.core.entity.SignupStatusType; import org.devkor.apu.saerok_server.domain.user.core.entity.User; @@ -62,17 +63,13 @@ public List findActiveUserIds(int offset, int limit) { .getResultList(); } - public List findActiveNicknameUsers(String nicknameQuery, int offset, int limit) { + public List searchActiveUsers(String nicknameQuery, Long idQuery, 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 += buildSearchPredicate(nicknameQuery, idQuery); jpql += " ORDER BY u.nickname ASC, u.id ASC"; var query = em.createQuery(jpql, User.class) @@ -80,33 +77,50 @@ AND TRIM(u.nickname) <> '' .setFirstResult(offset) .setMaxResults(limit); - if (nicknameQuery != null) { - query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); - } + bindSearchParameters(query, nicknameQuery, idQuery); return query.getResultList(); } - public long countActiveNicknameUsers(String nicknameQuery) { + public long countSearchActiveUsers(String nicknameQuery, Long idQuery) { 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"; - } + jpql += buildSearchPredicate(nicknameQuery, idQuery); var query = em.createQuery(jpql, Long.class) .setParameter("withdrawn", SignupStatusType.WITHDRAWN); + bindSearchParameters(query, nicknameQuery, idQuery); + + return query.getSingleResult(); + } + + private String buildSearchPredicate(String nicknameQuery, Long idQuery) { + boolean hasNickname = nicknameQuery != null; + boolean hasId = idQuery != null; + String nicknamePresent = "(u.nickname IS NOT NULL AND TRIM(u.nickname) <> '')"; + if (hasNickname && hasId) { + return " AND ((" + nicknamePresent + " AND u.nickname LIKE :nicknameQuery) OR u.id = :idQuery)"; + } + if (hasNickname) { + return " AND " + nicknamePresent + " AND u.nickname LIKE :nicknameQuery"; + } + if (hasId) { + return " AND u.id = :idQuery"; + } + return " AND " + nicknamePresent; + } + + private void bindSearchParameters(Query query, String nicknameQuery, Long idQuery) { if (nicknameQuery != null) { query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); } - - return query.getSingleResult(); + if (idQuery != null) { + query.setParameter("idQuery", idQuery); + } } public List findByIds(List ids) { diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 41023359..516e772b 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -26,6 +26,3 @@ notification-messages: SYSTEM_ADMIN_MESSAGE: push-title: "{title}" push-body: "{body}" - SYSTEM_CONTENT_DELETED: - push-title: "콘텐츠 삭제 안내" - push-body: "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: {reason}" \ No newline at end of file diff --git a/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql b/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql new file mode 100644 index 00000000..8550832e --- /dev/null +++ b/src/main/resources/db/migration/V90__merge_content_deleted_notification_type.sql @@ -0,0 +1,3 @@ +UPDATE notification +SET type = 'SYSTEM_ADMIN_MESSAGE' +WHERE type = 'SYSTEM_CONTENT_DELETED'; diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java index 96db8ed9..41cde3ad 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java @@ -71,12 +71,15 @@ void handle_contentDeletedByReport_callsNotifyUser() { verify(notifySystemService).notifyUser( eq(42L), - eq(NotificationType.SYSTEM_CONTENT_DELETED), + eq(NotificationType.SYSTEM_ADMIN_MESSAGE), isNull(), extrasCaptor.capture() ); - assertThat(extrasCaptor.getValue()).containsEntry("reason", "커뮤니티 가이드라인 위반"); + assertThat(extrasCaptor.getValue()) + .containsEntry("title", "콘텐츠 삭제 안내") + .containsEntry("body", "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: 커뮤니티 가이드라인 위반") + .containsEntry("reason", "커뮤니티 가이드라인 위반"); } @Test 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 1f53c113..6301d63c 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,8 +138,8 @@ void findActiveUserIds_empty() { assertThat(activeIds).isEmpty(); } - @Test @DisplayName("findActiveNicknameUsers - 활성 닉네임 사용자만 닉네임순으로 조회") - void findActiveNicknameUsers() { + @Test @DisplayName("searchActiveUsers - 활성 닉네임 사용자만 닉네임순으로 조회") + void searchActiveUsers() { User charlie = user("charlie@example.com", "charlie"); charlie.setSignupStatus(SignupStatusType.COMPLETED); User bravo = user("bravo@example.com", "bravo"); @@ -156,16 +156,16 @@ void findActiveNicknameUsers() { deleted.softDelete(); em.flush(); em.clear(); - List users = repo.findActiveNicknameUsers(null, 0, 20); + List users = repo.searchActiveUsers(null, null, 0, 20); assertThat(users) .extracting(User::getNickname) .containsExactly("bravo", "charlie"); - assertThat(repo.countActiveNicknameUsers(null)).isEqualTo(2); + assertThat(repo.countSearchActiveUsers(null, null)).isEqualTo(2); } - @Test @DisplayName("findActiveNicknameUsers - 닉네임 검색과 페이징") - void findActiveNicknameUsers_queryAndPagination() { + @Test @DisplayName("searchActiveUsers - 닉네임 검색과 페이징") + void searchActiveUsers_queryAndPagination() { User alpha = user("alpha@example.com", "alpha"); alpha.setSignupStatus(SignupStatusType.COMPLETED); User alpine = user("alpine@example.com", "alpine"); @@ -174,8 +174,8 @@ void findActiveNicknameUsers_queryAndPagination() { bravo.setSignupStatus(SignupStatusType.COMPLETED); em.flush(); em.clear(); - List firstPage = repo.findActiveNicknameUsers("alp", 0, 1); - List secondPage = repo.findActiveNicknameUsers("alp", 1, 1); + List firstPage = repo.searchActiveUsers("alp", null, 0, 1); + List secondPage = repo.searchActiveUsers("alp", null, 1, 1); assertThat(firstPage) .extracting(User::getNickname) @@ -183,11 +183,11 @@ void findActiveNicknameUsers_queryAndPagination() { assertThat(secondPage) .extracting(User::getNickname) .containsExactly("alpine"); - assertThat(repo.countActiveNicknameUsers("alp")).isEqualTo(2); + assertThat(repo.countSearchActiveUsers("alp", null)).isEqualTo(2); } - @Test @DisplayName("findActiveNicknameUsers - 닉네임 중간 문자열 검색") - void findActiveNicknameUsers_containsQuery() { + @Test @DisplayName("searchActiveUsers - 닉네임 중간 문자열 검색") + void searchActiveUsers_containsQuery() { User duli = user("duli@example.com", "둘리"); duli.setSignupStatus(SignupStatusType.COMPLETED); User pigeon = user("pigeon@example.com", "비둘기"); @@ -196,12 +196,100 @@ void findActiveNicknameUsers_containsQuery() { magpie.setSignupStatus(SignupStatusType.COMPLETED); em.flush(); em.clear(); - List users = repo.findActiveNicknameUsers("둘", 0, 20); + List users = repo.searchActiveUsers("둘", null, 0, 20); assertThat(users) .extracting(User::getNickname) .containsExactly("둘리", "비둘기"); - assertThat(repo.countActiveNicknameUsers("둘")).isEqualTo(2); + assertThat(repo.countSearchActiveUsers("둘", null)).isEqualTo(2); + } + + @Test @DisplayName("searchActiveUsers - ID 단독 검색") + void searchActiveUsers_byIdOnly() { + User alpha = user("id-alpha@example.com", "id-alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("id-bravo@example.com", "id-bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, alpha.getId(), 0, 20); + + assertThat(users) + .extracting(User::getId) + .containsExactly(alpha.getId()); + assertThat(repo.countSearchActiveUsers(null, alpha.getId())).isEqualTo(1); + } + + @Test @DisplayName("searchActiveUsers - ID와 닉네임 OR 검색") + void searchActiveUsers_byIdOrNickname() { + User alpha = user("or-alpha@example.com", "or-alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("or-bravo@example.com", "or-bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + User charlie = user("or-charlie@example.com", "charlie-other"); + charlie.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers("or-", charlie.getId(), 0, 20); + + assertThat(users) + .extracting(User::getId) + .containsExactlyInAnyOrder(alpha.getId(), bravo.getId(), charlie.getId()); + assertThat(repo.countSearchActiveUsers("or-", charlie.getId())).isEqualTo(3); + } + + @Test @DisplayName("searchActiveUsers - 닉네임 null인 활성 사용자도 ID로 검색됨") + void searchActiveUsers_byIdAllowsNullNickname() { + User profileRequired = user("profile-required@example.com", null); + profileRequired.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, profileRequired.getId(), 0, 20); + + assertThat(users).extracting(User::getId).containsExactly(profileRequired.getId()); + assertThat(repo.countSearchActiveUsers(null, profileRequired.getId())).isEqualTo(1); + } + + @Test @DisplayName("searchActiveUsers - 닉네임 검색에는 null 닉네임 사용자 제외") + void searchActiveUsers_nicknameQueryExcludesNullNickname() { + User withNickname = user("with-nick@example.com", "with-nick-target"); + withNickname.setSignupStatus(SignupStatusType.COMPLETED); + User noNickname = user("no-nick@example.com", null); + noNickname.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers("nick-target", null, 0, 20); + + assertThat(users).extracting(User::getNickname).containsExactly("with-nick-target"); + } + + @Test @DisplayName("searchActiveUsers - 쿼리 없을 때 닉네임 null 사용자 제외") + void searchActiveUsers_noQueryExcludesNullNickname() { + User withNickname = user("default-nick@example.com", "default-nick"); + withNickname.setSignupStatus(SignupStatusType.COMPLETED); + User noNickname = user("default-no-nick@example.com", null); + noNickname.setSignupStatus(SignupStatusType.PROFILE_REQUIRED); + em.flush(); em.clear(); + + List users = repo.searchActiveUsers(null, null, 0, 20); + + assertThat(users).extracting(User::getNickname).containsExactly("default-nick"); + } + + @Test @DisplayName("searchActiveUsers - 탈퇴/삭제 사용자는 ID 검색에서도 제외") + void searchActiveUsers_byIdExcludesInactive() { + User withdrawn = user("inactive-withdrawn@example.com", "withdrawn-user"); + withdrawn.setSignupStatus(SignupStatusType.WITHDRAWN); + User deleted = user("inactive-deleted@example.com", "deleted-user"); + deleted.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); + deleted.softDelete(); + em.flush(); em.clear(); + + assertThat(repo.searchActiveUsers(null, withdrawn.getId(), 0, 20)).isEmpty(); + assertThat(repo.countSearchActiveUsers(null, withdrawn.getId())).isZero(); + assertThat(repo.searchActiveUsers(null, deleted.getId(), 0, 20)).isEmpty(); + assertThat(repo.countSearchActiveUsers(null, deleted.getId())).isZero(); } @Test @DisplayName("save - 중복 닉네임은 제약조건 위반")