From 69331fa04d76b477c964ea0048b48a3055f8159e Mon Sep 17 00:00:00 2001 From: WOOJIN LEE <39264396+riadan710@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:03:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=ED=81=AC=EB=A1=A4=EB=A7=81=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=ED=95=B4=EA=B2=B0=20(#1?= =?UTF-8?q?428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 크롤링 시스템 계정 추가 * test: 테스트 코드 수정 * refactor: flyway 스크립트 개선 이메일로 ID 동적 조회 --- .../service/CrawledToPostTransferService.java | 8 +-- ...0260727181056__CreateCrawlerSystemUser.sql | 55 +++++++++++++++++++ .../CrawledToPostTransferServiceTest.java | 8 +-- .../causw/global/constant/StaticValue.java | 2 +- 4 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 app-main/src/main/resources/db/migration/V20260727181056__CreateCrawlerSystemUser.sql diff --git a/app-main/src/main/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferService.java b/app-main/src/main/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferService.java index 0f38e44a5..e9fc57ed0 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferService.java +++ b/app-main/src/main/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferService.java @@ -46,12 +46,12 @@ public class CrawledToPostTransferService { @Transactional public void transferToPosts() { Board board = getBoard(); - User adminUser = getAdminUser(); + User systemUser = getSystemUser(); List updatedNotices = getUpdatedNotices(); int savedCount = 0; for (CrawledNotice notice : updatedNotices) { - if (processUpdatedNotice(notice, board, adminUser)) { + if (processUpdatedNotice(notice, board, systemUser)) { notice.setIsUpdated(false); crawledNoticeRepository.save(notice); savedCount++; @@ -67,8 +67,8 @@ private Board getBoard() { } //관리자 조회 - private User getAdminUser() { - return userRepository.findByStudentId(StaticValue.ADMIN_STUDENT_ID) + private User getSystemUser() { + return userRepository.findByEmail(StaticValue.SYSTEM_CRAWLER_ACCOUNT) .orElseThrow(() -> new BadRequestException( ErrorCode.ROW_DOES_NOT_EXIST, MessageUtil.USER_NOT_FOUND)); } diff --git a/app-main/src/main/resources/db/migration/V20260727181056__CreateCrawlerSystemUser.sql b/app-main/src/main/resources/db/migration/V20260727181056__CreateCrawlerSystemUser.sql new file mode 100644 index 000000000..04e8ed5e6 --- /dev/null +++ b/app-main/src/main/resources/db/migration/V20260727181056__CreateCrawlerSystemUser.sql @@ -0,0 +1,55 @@ +-- Migration: CreateCrawlerSystemUser + +-- 1. 크롤링 시스템 계정 생성 +INSERT INTO tb_user ( + id, + email, + name, + nickname, + password, + state, + academic_status, + profile_image_type, + is_v2, + is_email_verified, + report_count, + created_at, + updated_at +) +SELECT + 'system-crawler-id', + 'SYSTEM_CRAWLER_ACCOUNT', + '크롤링 시스템 계정', + '동네 크롤러', + 'LOGIN_DISABLED', + 'ACTIVE', + 'ENROLLED', + 'UNSET', + true, + false, + 0, + NOW(), + NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM tb_user WHERE email = 'SYSTEM_CRAWLER_ACCOUNT' +); + + +-- 2. 크롤링 시스템 계정 권한 부여 +INSERT INTO user_roles (user_id, role) +SELECT u.id, 'ADMIN' +FROM tb_user u +WHERE u.email = 'SYSTEM_CRAWLER_ACCOUNT' + AND NOT EXISTS ( + SELECT 1 + FROM user_roles ur + WHERE ur.user_id = u.id AND ur.role = 'ADMIN' + ); + + +-- 3. 기존 크롤링 게시물의 작성자 변경 +UPDATE tb_post +SET user_id = ( + SELECT id FROM tb_user WHERE email = 'SYSTEM_CRAWLER_ACCOUNT' + ) +WHERE is_crawled = true; \ No newline at end of file diff --git a/app-main/src/test/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferServiceTest.java b/app-main/src/test/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferServiceTest.java index 29a298fe0..67ea15b52 100644 --- a/app-main/src/test/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferServiceTest.java +++ b/app-main/src/test/java/net/causw/app/main/domain/integration/crawled/service/CrawledToPostTransferServiceTest.java @@ -69,7 +69,7 @@ void transferToPosts_shouldCreateNewPost_whenNewNotice() { given(boardRepository.findByName(StaticValue.CrawlingBoard)) .willReturn(Optional.of(mockBoard)); - given(userRepository.findByStudentId(StaticValue.ADMIN_STUDENT_ID)) + given(userRepository.findByEmail(StaticValue.SYSTEM_CRAWLER_ACCOUNT)) .willReturn(Optional.of(mockUser)); given(crawledNoticeRepository.findTop30ByIsUpdatedTrueOrderByLastModifiedDesc()) .willReturn(List.of(newNotice)); @@ -97,7 +97,7 @@ void transferToPosts_shouldUpdateExistingPost_whenNoticeUpdated() { given(boardRepository.findByName(StaticValue.CrawlingBoard)) .willReturn(Optional.of(mockBoard)); - given(userRepository.findByStudentId(StaticValue.ADMIN_STUDENT_ID)) + given(userRepository.findByEmail(StaticValue.SYSTEM_CRAWLER_ACCOUNT)) .willReturn(Optional.of(mockUser)); given(crawledNoticeRepository.findTop30ByIsUpdatedTrueOrderByLastModifiedDesc()) .willReturn(List.of(updatedNotice)); @@ -121,7 +121,7 @@ void transferToPosts_shouldDoNothing_whenNoUpdatedNotices() { given(boardRepository.findByName(StaticValue.CrawlingBoard)) .willReturn(Optional.of(mockBoard)); - given(userRepository.findByStudentId(StaticValue.ADMIN_STUDENT_ID)) + given(userRepository.findByEmail(StaticValue.SYSTEM_CRAWLER_ACCOUNT)) .willReturn(Optional.of(mockUser)); given(crawledNoticeRepository.findTop30ByIsUpdatedTrueOrderByLastModifiedDesc()) .willReturn(Collections.emptyList()); @@ -141,7 +141,7 @@ private Board createMockBoard() { private User createMockUser() { User user = mock(User.class); - when(user.getStudentId()).thenReturn(StaticValue.ADMIN_STUDENT_ID); + when(user.getEmail()).thenReturn(StaticValue.SYSTEM_CRAWLER_ACCOUNT); return user; } diff --git a/global/src/main/java/net/causw/global/constant/StaticValue.java b/global/src/main/java/net/causw/global/constant/StaticValue.java index a6d35d456..8116e2801 100644 --- a/global/src/main/java/net/causw/global/constant/StaticValue.java +++ b/global/src/main/java/net/causw/global/constant/StaticValue.java @@ -93,7 +93,7 @@ public class StaticValue { public static final String ORIGINAL_NOTICE_SITE_NAME = "중앙대학교 소프트웨어학부 공지사항"; public static final String CAU_CSE_BASE_URL = "https://cse.cau.ac.kr/sub05/sub0501.php?offset="; public static final String CAU_CSE_DOWNLOAD_URL_FORMAT = "https://cse.cau.ac.kr/_module/bbs/download.php?uid=%s&code=%s"; - public static final String ADMIN_STUDENT_ID = "20220881"; + public static final String SYSTEM_CRAWLER_ACCOUNT = "SYSTEM_CRAWLER_ACCOUNT"; public static final int CRAWLING_MAX_NOTICES = 30; public static final int CRAWLING_MAX_RETRIES = 3; public static final int CRAWLING_REQUEST_DELAY_MS = 2000; From ac4818549b3b0df4ef55ac1771b6b2669b2ed69f Mon Sep 17 00:00:00 2001 From: jiyo Date: Sun, 2 Aug 2026 18:31:57 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=ED=83=88=ED=87=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=A6=AC=20=EB=B0=B0=EC=B9=98=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20(#1431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: User 엔티티로 익명화 여부 판단 로직 이동 (#1430) * fix: 익명화된 사용자를 배치 정리 대상에서 제외 (#1430) * refactor: 탈퇴 사용자 정리 배치 조회 로직 개선 (#1430) * refactor: 미사용 메서드 제거 (#1430) * refactor: 사용자 정리 대상 조회 쿼리 명확화 (#1430) * test: 배치 사용자 정리 테스트를 조회 방식 변경에 맞게 수정 (#1430) * style: Spotless 포맷팅 적용 (#1430) * fix: 누락된 배치 테스트 수정 반영 (#1430) * fix: 탈퇴 사용자 조회 조건의 LIKE 패턴 이스케이프 적용 (#1430) * refactor: Reader 계층을 통해 배치 조회하도록 구조 개선 (#1430) --- .../app/main/core/batch/BatchScheduler.java | 11 +++----- .../repository/user/UserRepository.java | 12 ++++++++- .../service/implementation/UserReader.java | 21 ++++++++-------- .../service/implementation/UserWriter.java | 10 -------- .../security/batch/BatchSchedulerTest.java | 25 ++++++++----------- 5 files changed, 36 insertions(+), 43 deletions(-) diff --git a/app-main/src/main/java/net/causw/app/main/core/batch/BatchScheduler.java b/app-main/src/main/java/net/causw/app/main/core/batch/BatchScheduler.java index 75e26cef1..13424b332 100644 --- a/app-main/src/main/java/net/causw/app/main/core/batch/BatchScheduler.java +++ b/app-main/src/main/java/net/causw/app/main/core/batch/BatchScheduler.java @@ -4,7 +4,6 @@ import java.util.List; import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Slice; import org.springframework.scheduling.annotation.Scheduled; @@ -47,14 +46,11 @@ public void scheduleCleanupDeactivatedUsers() { LocalDateTime dueDate = LocalDateTime.now().minusDays(30); - boolean hasNext; - do { - Page userPage = userReader.findUsersDeletedBefore( + while (true) { + List withdrawnUsers = userReader.findCleanupTargets( dueDate, pageableFactory.create(0, StaticValue.BATCH_USER_LIST_SIZE)); - List withdrawnUsers = userPage.getContent(); - if (withdrawnUsers.isEmpty()) { break; } @@ -66,10 +62,9 @@ public void scheduleCleanupDeactivatedUsers() { admissionWriter.deleteAdmissionByUsers(withdrawnUsers); userWriter.cleanupWithdrawnUsers(withdrawnUsers); - hasNext = userPage.hasNext(); log.info("[유저 정리 배치] {}명 처리 완료", withdrawnUsers.size()); + } - } while (hasNext); log.info("[유저 정리 배치] 탈퇴 유저 후처리 완료"); } catch (Exception e) { log.error("유저 정리 배치 실패: {}", e.getMessage(), e); diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java index dc1ca2716..efad4bc45 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/user/UserRepository.java @@ -102,7 +102,17 @@ List findByRoleAndAcademicStatusAndState(@Param("role") Role role, Long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end); - Page findAllByDeletedAtIsNotNullAndDeletedAtBefore(LocalDateTime deletedAt, Pageable pageable); + @Query(""" + SELECT u + FROM User u + WHERE u.deletedAt IS NOT NULL + AND u.deletedAt < :deletedAt + AND u.email NOT LIKE 'deleted\\_%' ESCAPE '\\' + ORDER BY u.deletedAt ASC + """) + List findCleanupTargets( + @Param("deletedAt") LocalDateTime deletedAt, + Pageable pageable); Slice findAllByStateAndUpdatedAtBeforeAndDeletedAtIsNull(UserState state, LocalDateTime updatedAt, Pageable pageable); diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserReader.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserReader.java index 695e08182..871051134 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserReader.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserReader.java @@ -187,6 +187,17 @@ public Long countByCreatedAtBetween(LocalDateTime start, LocalDateTime end) { return userRepository.countByCreatedAtBetween(start, end); } + /** + * 탈퇴 후 유예기간이 지난 개인정보 정리 대상 유저를 조회합니다. + * + * @param deletedAt 조회 기준 시각 (이 시각 이전에 탈퇴한 유저) + * @param pageable 배치 처리용 조회 범위 + * @return 개인정보 정리 대상 유저 목록 + */ + public List findCleanupTargets(LocalDateTime deletedAt, Pageable pageable) { + return userRepository.findCleanupTargets(deletedAt, pageable); + } + /** * 특정 상태이면서 updatedAt이 기준 시각 이전인 유저 목록을 조회합니다. (배치용) * @param state 조회할 유저 상태 @@ -198,16 +209,6 @@ public Slice findUsersByStateAndUpdatedAtBefore(UserState state, LocalDate return userRepository.findAllByStateAndUpdatedAtBeforeAndDeletedAtIsNull(state, dueDate, pageable); } - /** - * deletedAt이 기준 시각 이전인 소프트 삭제된 유저 목록을 조회합니다. (배치용) - * @param dueDate deletedAt 기준 시각 - * @param pageable 페이지네이션 정보 - * @return 조건에 해당하는 유저 페이지 - */ - public Page findUsersDeletedBefore(LocalDateTime dueDate, Pageable pageable) { - return userRepository.findAllByDeletedAtIsNotNullAndDeletedAtBefore(dueDate, pageable); - } - public Long getTotalUserCount() { return userQueryRepository.countTotalUsers(); } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserWriter.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserWriter.java index 5869242ab..3ee4cc116 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserWriter.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserWriter.java @@ -113,10 +113,6 @@ public void cleanupWithdrawnUsers(List users) { .collect(Collectors.groupingBy(sa -> sa.getUser().getId())); for (User user : users) { - if (isAlreadyAnonymized(user)) { - continue; - } - List socialAccounts = socialAccountMap.getOrDefault(user.getId(), List.of()); for (SocialAccount socialAccount : socialAccounts) { @@ -134,12 +130,6 @@ public void cleanupWithdrawnUsers(List users) { userRepository.saveAll(users); } - private boolean isAlreadyAnonymized(User user) { - return user.isInactive() && - user.getEmail() != null && - user.getEmail().startsWith("deleted_"); - } - /** * 유저를 DB에서 영구 삭제합니다. (소프트 삭제가 아니며 복구할 수 없습니다) *

diff --git a/app-main/src/test/java/net/causw/app/main/core/security/batch/BatchSchedulerTest.java b/app-main/src/test/java/net/causw/app/main/core/security/batch/BatchSchedulerTest.java index 35632d330..15a2cd69c 100644 --- a/app-main/src/test/java/net/causw/app/main/core/security/batch/BatchSchedulerTest.java +++ b/app-main/src/test/java/net/causw/app/main/core/security/batch/BatchSchedulerTest.java @@ -11,10 +11,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.batch.core.job.Job; -import org.springframework.batch.core.launch.JobLauncher; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.SliceImpl; @@ -37,8 +33,6 @@ public class BatchSchedulerTest { @InjectMocks private BatchScheduler batchScheduler; - @Mock - private JobLauncher jobLauncher; @Mock private UserReader userReader; @Mock @@ -54,8 +48,6 @@ public class BatchSchedulerTest { @Mock private UserWriter userWriter; @Mock - private Job cleanUpUnusedFilesJob; - @Mock private UserProfileImageService userProfileImageService; @Test @@ -69,14 +61,16 @@ void scheduleCleanupDeactivatedUsers_Success() { when(pageableFactory.create(anyInt(), anyInt())).thenReturn(PageRequest.of(0, 10)); when( - userReader.findUsersDeletedBefore(any(LocalDateTime.class), any(Pageable.class))) - .thenReturn(new PageImpl<>(withdrawnUsers), Page.empty()); + userReader.findCleanupTargets( + any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(withdrawnUsers, List.of()); // when batchScheduler.scheduleCleanupDeactivatedUsers(); // then - verify(userReader).findUsersDeletedBefore( + verify(userReader, times(2)).findCleanupTargets( any(LocalDateTime.class), any(Pageable.class)); verify(userProfileImageService, times(1)).cleanupProfileImagesForBatch(anyList()); @@ -94,14 +88,17 @@ void scheduleCleanupDeactivatedUsers_NoTarget() { when(pageableFactory.create(anyInt(), anyInt())).thenReturn(PageRequest.of(0, 10)); when( - userReader.findUsersDeletedBefore(any(LocalDateTime.class), any(Pageable.class))) - .thenReturn(Page.empty()); + userReader.findCleanupTargets( + any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(List.of()); // when batchScheduler.scheduleCleanupDeactivatedUsers(); // then - verify(userReader).findUsersDeletedBefore(any(LocalDateTime.class), + verify(userReader).findCleanupTargets( + any(LocalDateTime.class), any(Pageable.class)); verifyNoInteractions(userInfoWriter, ceremonyWriter, socialAccountWriter, userAdmissionWriter, userWriter); } From 7ea3c40c3132eb341c67358a1c8bbfa9191befb9 Mon Sep 17 00:00:00 2001 From: CHAEYEON LEE <102214015+chaeyeonlee898@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:17:49 +0900 Subject: [PATCH 3/4] Feat/#1435 fix wrong user login (#1441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 리프레시 토큰 jti 추가 * fix: OIDC 로그인 유저 조회를 socialId 우선으로 변경 * feat: 충돌 리프레시 토큰 1회성 정리 러너 추가 --- .../main/core/security/JwtTokenProvider.java | 3 + .../auth/handler/OAuth2SuccessHandler.java | 12 +- .../CollidedRefreshTokenCleanupRunner.java | 28 +++ .../main/shared/infra/redis/RedisUtils.java | 69 ++++++++ app-main/src/main/resources/application.yml | 2 + .../core/security/JwtTokenProviderTest.java | 71 ++++++++ .../handler/OAuth2SuccessHandlerTest.java | 162 ++++++++++++++++++ .../shared/infra/redis/RedisUtilsTest.java | 87 ++++++++++ 8 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 app-main/src/main/java/net/causw/app/main/shared/infra/redis/CollidedRefreshTokenCleanupRunner.java create mode 100644 app-main/src/test/java/net/causw/app/main/core/security/JwtTokenProviderTest.java create mode 100644 app-main/src/test/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandlerTest.java create mode 100644 app-main/src/test/java/net/causw/app/main/shared/infra/redis/RedisUtilsTest.java diff --git a/app-main/src/main/java/net/causw/app/main/core/security/JwtTokenProvider.java b/app-main/src/main/java/net/causw/app/main/core/security/JwtTokenProvider.java index 6908115db..c0670fb0a 100644 --- a/app-main/src/main/java/net/causw/app/main/core/security/JwtTokenProvider.java +++ b/app-main/src/main/java/net/causw/app/main/core/security/JwtTokenProvider.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Set; +import java.util.UUID; import java.util.stream.Collectors; import javax.crypto.SecretKey; @@ -65,6 +66,8 @@ public String createRefreshToken() { Date now = new Date(); return Jwts.builder() + .id(UUID.randomUUID().toString()) + .issuedAt(now) .expiration(new Date(now.getTime() + StaticValue.JWT_REFRESH_TOKEN_VALID_TIME)) .signWith(secretKey, Jwts.SIG.HS256) .compact(); diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandler.java b/app-main/src/main/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandler.java index c165c80e7..97cb09c2b 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandler.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandler.java @@ -149,7 +149,8 @@ private void saveProviderOAuthRefreshTokenIfPresent(HttpServletRequest request, /** * 인증 principal 타입에 맞춰 도메인 사용자 엔티티를 조회합니다. *

- * Apple OIDC의 경우 email 우선 조회 후, email 누락 시 socialId(sub)로 fallback 조회합니다. + * OIDC의 경우 {@link net.causw.app.main.domain.user.auth.service.CustomOAuth2UserService}의 + * 사용자 확정 정책과 동일하게 socialId(sub) 우선 조회 후, 미존재 시 email로 fallback 조회합니다. * * @param authentication 인증 컨텍스트 * @return 로그인 대상 사용자 엔티티 @@ -162,10 +163,11 @@ private User resolveAuthenticatedUser(Authentication authentication) { } if (principal instanceof OidcUser oidcUser) { - Optional userByEmail = findByEmail(oidcUser.getEmail()); - return userByEmail.orElseGet( - () -> userReader.findBySocialTypeAndSocialId(SocialType.APPLE, oidcUser.getSubject()) - .orElseThrow(AuthErrorCode.INVALID_TOKEN::toBaseException)); + SocialType socialType = SocialType.from( + ((OAuth2AuthenticationToken)authentication).getAuthorizedClientRegistrationId()); + return userReader.findBySocialTypeAndSocialId(socialType, oidcUser.getSubject()) + .or(() -> findByEmail(oidcUser.getEmail())) + .orElseThrow(AuthErrorCode.INVALID_TOKEN::toBaseException); } if (principal instanceof OAuth2User oAuth2User) { diff --git a/app-main/src/main/java/net/causw/app/main/shared/infra/redis/CollidedRefreshTokenCleanupRunner.java b/app-main/src/main/java/net/causw/app/main/shared/infra/redis/CollidedRefreshTokenCleanupRunner.java new file mode 100644 index 000000000..c6988887b --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/shared/infra/redis/CollidedRefreshTokenCleanupRunner.java @@ -0,0 +1,28 @@ +package net.causw.app.main.shared.infra.redis; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "redis.migration.collided-refresh-token-cleanup", name = "enabled", havingValue = "true") +public class CollidedRefreshTokenCleanupRunner { + + private final RedisUtils redisUtils; + + @EventListener(ApplicationReadyEvent.class) + public void purgeCollidedRefreshTokens() { + try { + int purgedCount = redisUtils.purgeCollidedRefreshTokens(); + log.info("[Redis Migration] 중복 발급된 refresh token 정리 완료. purgedCount={}", purgedCount); + } catch (Exception e) { + log.error("[Redis Migration] 중복 발급된 refresh token 정리 실패. 재시도가 필요합니다.", e); + } + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/infra/redis/RedisUtils.java b/app-main/src/main/java/net/causw/app/main/shared/infra/redis/RedisUtils.java index d0aba7adf..f392cb950 100644 --- a/app-main/src/main/java/net/causw/app/main/shared/infra/redis/RedisUtils.java +++ b/app-main/src/main/java/net/causw/app/main/shared/infra/redis/RedisUtils.java @@ -1,5 +1,8 @@ package net.causw.app.main.shared.infra.redis; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -21,6 +24,7 @@ public class RedisUtils { // 사용자별 refresh token 목록 key prefix private static final String USER_REFRESH_TOKENS_PREFIX = "UserRefreshTokens:"; private static final String REFRESH_TOKEN_USER_INDEX_MIGRATION_KEY = "Migration:RefreshTokenUserIndex:v1"; + private static final String COLLIDED_REFRESH_TOKEN_CLEANUP_KEY = "Migration:CollidedRefreshTokenCleanup:v1"; private static final String BLACKLIST_PREFIX = "Blacklist"; private final RedisTemplate redisTemplate; @@ -140,6 +144,71 @@ private int scanRefreshTokenKeysAndBuildUserIndex() { return migratedCount; } + /** + * 서로 다른 사용자에게 중복 발급된 refresh token을 찾아 폐기합니다. + *

+ * jti 도입 이전에는 같은 초에 발급된 token 문자열이 동일해 {@code RefreshToken:{refreshToken} -> userId} + * 매핑이 덮어써졌습니다. {@code UserRefreshTokens:{userId}}는 Set이라 충돌한 token이 양쪽 인덱스에 + * 남아 있으므로, 2명 이상에게 매핑된 token만 선별 폐기합니다. + * + * @return 폐기된 refresh token 개수 + */ + public int purgeCollidedRefreshTokens() { + if (redisTemplate.hasKey(COLLIDED_REFRESH_TOKEN_CLEANUP_KEY)) { + return 0; + } + + int purgedCount = scanUserIndexAndPurgeCollidedTokens(); + redisTemplate.opsForValue().set(COLLIDED_REFRESH_TOKEN_CLEANUP_KEY, "DONE"); + return purgedCount; + } + + private int scanUserIndexAndPurgeCollidedTokens() { + int purgedCount = 0; + + for (Map.Entry> entry : collectUserIdsByRefreshToken().entrySet()) { + Set userIds = entry.getValue(); + if (userIds.size() < 2) { + continue; + } + + String refreshToken = entry.getKey(); + redisTemplate.delete(REFRESH_TOKEN_PREFIX + refreshToken); + userIds.forEach( + userId -> redisTemplate.opsForSet().remove(USER_REFRESH_TOKENS_PREFIX + userId, refreshToken)); + purgedCount++; + } + + return purgedCount; + } + + private Map> collectUserIdsByRefreshToken() { + ScanOptions scanOptions = ScanOptions.scanOptions() + .match(USER_REFRESH_TOKENS_PREFIX + "*") + .count(1000) + .build(); + + Map> userIdsByRefreshToken = new HashMap<>(); + try (Cursor cursor = redisTemplate.scan(scanOptions)) { + while (cursor.hasNext()) { + String userRefreshTokensKey = cursor.next(); + String userId = userRefreshTokensKey.substring(USER_REFRESH_TOKENS_PREFIX.length()); + + Set refreshTokens = redisTemplate.opsForSet().members(userRefreshTokensKey); + if (refreshTokens == null) { + continue; + } + + for (Object refreshToken : refreshTokens) { + if (refreshToken instanceof String refreshTokenValue) { + userIdsByRefreshToken.computeIfAbsent(refreshTokenValue, key -> new HashSet<>()).add(userId); + } + } + } + } + return userIdsByRefreshToken; + } + private void syncUserRefreshTokenIndexTtl(String refreshTokenKey, String userRefreshTokensKey) { Long refreshTokenTtl = redisTemplate.getExpire(refreshTokenKey, TimeUnit.MILLISECONDS); if (refreshTokenTtl == null || refreshTokenTtl <= 0) { diff --git a/app-main/src/main/resources/application.yml b/app-main/src/main/resources/application.yml index a4d47e58a..601617ee2 100644 --- a/app-main/src/main/resources/application.yml +++ b/app-main/src/main/resources/application.yml @@ -6,3 +6,5 @@ redis: migration: refresh-token-user-index: enabled: false + collided-refresh-token-cleanup: + enabled: false diff --git a/app-main/src/test/java/net/causw/app/main/core/security/JwtTokenProviderTest.java b/app-main/src/test/java/net/causw/app/main/core/security/JwtTokenProviderTest.java new file mode 100644 index 000000000..ec9f272c3 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/core/security/JwtTokenProviderTest.java @@ -0,0 +1,71 @@ +package net.causw.app.main.core.security; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Set; + +import javax.crypto.spec.SecretKeySpec; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import net.causw.app.main.domain.user.auth.userdetails.CustomUserDetailsService; +import net.causw.app.main.shared.infra.redis.RedisUtils; + +import io.jsonwebtoken.Jwts; + +@ExtendWith(MockitoExtension.class) +class JwtTokenProviderTest { + + private static final String SECRET = "test-secret-key-for-jwt-token-provider-unit-test"; + + @Mock + private CustomUserDetailsService userDetailsService; + + @Mock + private RedisUtils redisUtils; + + private JwtTokenProvider jwtTokenProvider; + + @BeforeEach + void setUp() { + jwtTokenProvider = new JwtTokenProvider(userDetailsService, redisUtils); + ReflectionTestUtils.setField(jwtTokenProvider, "secret", SECRET); + jwtTokenProvider.init(); + } + + @Test + @DisplayName("같은 초에 연속 발급해도 리프레시 토큰 문자열은 서로 겹치지 않는다") + void createRefreshToken_neverCollides() { + int issueCount = 1000; + + Set refreshTokens = new HashSet<>(); + for (int i = 0; i < issueCount; i++) { + refreshTokens.add(jwtTokenProvider.createRefreshToken()); + } + + assertEquals(issueCount, refreshTokens.size()); + } + + @Test + @DisplayName("리프레시 토큰은 jti를 가지며 사용자 식별 정보를 담지 않는다") + void createRefreshToken_hasJtiWithoutSubject() { + String refreshToken = jwtTokenProvider.createRefreshToken(); + + var claims = Jwts.parser() + .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(refreshToken) + .getPayload(); + + assertNotNull(claims.getId()); + assertNull(claims.getSubject()); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandlerTest.java b/app-main/src/test/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandlerTest.java new file mode 100644 index 000000000..1a966a795 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/domain/user/auth/handler/OAuth2SuccessHandlerTest.java @@ -0,0 +1,162 @@ +package net.causw.app.main.domain.user.auth.handler; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseCookie; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.enums.user.SocialType; +import net.causw.app.main.domain.user.account.service.implementation.UserReader; +import net.causw.app.main.domain.user.auth.service.dto.CustomOAuth2User; +import net.causw.app.main.domain.user.auth.service.implementation.AuthTokenManager; +import net.causw.app.main.domain.user.auth.service.implementation.SocialAccountOauthRefreshStore; +import net.causw.app.main.domain.user.auth.util.OAuthRedirectResolver; + +@ExtendWith(MockitoExtension.class) +class OAuth2SuccessHandlerTest { + + private static final String REDIRECT_BASE = "https://front.test/callback"; + + @Mock + private AuthTokenManager authTokenManager; + + @Mock + private UserReader userReader; + + @Mock + private OAuthRedirectResolver oAuthRedirectResolver; + + @Mock + private SocialAccountOauthRefreshStore socialAccountOauthRefreshStore; + + @InjectMocks + private OAuth2SuccessHandler oAuth2SuccessHandler; + + @Test + @DisplayName("OIDC 로그인은 email보다 socialId(sub)로 먼저 유저를 조회한다") + void handleLoginSuccess_resolvesOidcUserBySocialIdFirst() throws Exception { + //given: email claim은 다른 유저를 가리키지만, socialId로 확정된 유저는 userB + User resolvedUser = mock(User.class); + given(resolvedUser.getId()).willReturn("user-b"); + + OidcIdToken idToken = new OidcIdToken( + "apple-id-token", + Instant.now(), + Instant.MAX, + Map.of( + "sub", "apple-social-id", + "email", "shared@test.com")); + OidcUser principal = new DefaultOidcUser(Collections.singleton(() -> "ROLE_USER"), idToken, "sub"); + + Authentication authentication = new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), "apple"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + given(userReader.findBySocialTypeAndSocialId(SocialType.APPLE, "apple-social-id")) + .willReturn(Optional.of(resolvedUser)); + given(oAuthRedirectResolver.resolveRedirectBase(request)).willReturn(REDIRECT_BASE); + given(oAuthRedirectResolver.clearEnvCookie(request)) + .willReturn(ResponseCookie.from("oauth_env", "").maxAge(0).build()); + given(authTokenManager.createRefreshToken("user-b")).willReturn("issued-refresh-token"); + + //when + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + //then + verify(authTokenManager).createRefreshToken("user-b"); + verify(userReader, never()).findByEmail(any()); + verify(userReader, never()).findByEmailOrElseThrow(any()); + assertTrue(response.getRedirectedUrl().contains("refreshToken=issued-refresh-token")); + } + + @Test + @DisplayName("OIDC 로그인에서 socialId로 못 찾으면 email로 fallback 조회한다") + void handleLoginSuccess_fallsBackToEmailForOidcUser() throws Exception { + User userByEmail = mock(User.class); + given(userByEmail.getId()).willReturn("user-a"); + + OidcIdToken idToken = new OidcIdToken( + "google-id-token", + Instant.now(), + Instant.MAX, + Map.of( + "sub", "google-social-id", + "email", "plain@test.com")); + OidcUser principal = new DefaultOidcUser(Collections.singleton(() -> "ROLE_USER"), idToken, "sub"); + + Authentication authentication = new OAuth2AuthenticationToken( + principal, principal.getAuthorities(), "google"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + given(userReader.findBySocialTypeAndSocialId(SocialType.GOOGLE, "google-social-id")) + .willReturn(Optional.empty()); + given(userReader.findByEmail("plain@test.com")).willReturn(Optional.of(userByEmail)); + given(oAuthRedirectResolver.resolveRedirectBase(request)).willReturn(REDIRECT_BASE); + given(oAuthRedirectResolver.clearEnvCookie(request)) + .willReturn(ResponseCookie.from("oauth_env", "").maxAge(0).build()); + given(authTokenManager.createRefreshToken("user-a")).willReturn("issued-refresh-token"); + + //when + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + //then + verify(userReader).findBySocialTypeAndSocialId(SocialType.GOOGLE, "google-social-id"); + verify(userReader).findByEmail("plain@test.com"); + verify(authTokenManager).createRefreshToken("user-a"); + } + + @Test + @DisplayName("카카오(CustomOAuth2User) 로그인 경로는 기존 동작을 그대로 유지한다") + void handleLoginSuccess_keepsExistingPathForCustomOAuth2User() throws Exception { + User kakaoUser = mock(User.class); + given(kakaoUser.getEmail()).willReturn("kakao@test.com"); + given(kakaoUser.getId()).willReturn("user-kakao"); + + CustomOAuth2User principal = new CustomOAuth2User( + kakaoUser, + Map.of("id", "kakao-social-id"), + "id"); + + Authentication authentication = new OAuth2AuthenticationToken( + principal, Collections.singleton(() -> "ROLE_USER"), "kakao"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + given(userReader.findByEmailOrElseThrow("kakao@test.com")).willReturn(kakaoUser); + given(oAuthRedirectResolver.resolveRedirectBase(request)).willReturn(REDIRECT_BASE); + given(oAuthRedirectResolver.clearEnvCookie(request)) + .willReturn(ResponseCookie.from("oauth_env", "").maxAge(0).build()); + given(authTokenManager.createRefreshToken("user-kakao")).willReturn("issued-refresh-token"); + + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + verify(userReader).findByEmailOrElseThrow("kakao@test.com"); + verify(authTokenManager).createRefreshToken("user-kakao"); + } +} diff --git a/app-main/src/test/java/net/causw/app/main/shared/infra/redis/RedisUtilsTest.java b/app-main/src/test/java/net/causw/app/main/shared/infra/redis/RedisUtilsTest.java new file mode 100644 index 000000000..55bded600 --- /dev/null +++ b/app-main/src/test/java/net/causw/app/main/shared/infra/redis/RedisUtilsTest.java @@ -0,0 +1,87 @@ +package net.causw.app.main.shared.infra.redis; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class RedisUtilsTest { + + private static final String CLEANUP_KEY = "Migration:CollidedRefreshTokenCleanup:v1"; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private SetOperations setOperations; + + @Mock + private ValueOperations valueOperations; + + @InjectMocks + private RedisUtils redisUtils; + + @Test + @DisplayName("두 사용자에게 중복 발급된 refresh token만 폐기하고 정상 토큰은 유지한다") + void purgeCollidedRefreshTokens_purgesOnlyCollidedTokens() { + //given + Cursor cursor = cursorOf("UserRefreshTokens:userA", "UserRefreshTokens:userB"); + + given(redisTemplate.hasKey(CLEANUP_KEY)).willReturn(false); + given(redisTemplate.scan(any(ScanOptions.class))).willReturn(cursor); + given(redisTemplate.opsForSet()).willReturn(setOperations); + given(setOperations.members("UserRefreshTokens:userA")) + .willReturn(Set.of("collided-token", "userA-only-token")); + given(setOperations.members("UserRefreshTokens:userB")) + .willReturn(Set.of("collided-token")); + given(redisTemplate.opsForValue()).willReturn(valueOperations); + + //when + int purgedCount = redisUtils.purgeCollidedRefreshTokens(); + + //then + assertEquals(1, purgedCount); + verify(redisTemplate).delete("RefreshToken:collided-token"); + verify(redisTemplate, never()).delete("RefreshToken:userA-only-token"); + verify(setOperations).remove("UserRefreshTokens:userA", "collided-token"); + verify(setOperations).remove("UserRefreshTokens:userB", "collided-token"); + verify(valueOperations).set(CLEANUP_KEY, "DONE"); + } + + @Test + @DisplayName("이미 정리가 수행된 경우 다시 스캔하지 않는다") + void purgeCollidedRefreshTokens_skipsWhenAlreadyDone() { + given(redisTemplate.hasKey(CLEANUP_KEY)).willReturn(true); + + int purgedCount = redisUtils.purgeCollidedRefreshTokens(); + + assertEquals(0, purgedCount); + verify(redisTemplate, never()).scan(any(ScanOptions.class)); + } + + @SuppressWarnings("unchecked") + private Cursor cursorOf(String... keys) { + Cursor cursor = mock(Cursor.class); + Iterator iterator = List.of(keys).iterator(); + given(cursor.hasNext()).willAnswer(invocation -> iterator.hasNext()); + given(cursor.next()).willAnswer(invocation -> iterator.next()); + return cursor; + } +} From 983422f5936cd095bd61eafe497b87b4287f867b Mon Sep 17 00:00:00 2001 From: bingle <63186553+bingle625@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:11:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[FEATURE]=20=EB=8F=99=EB=AC=B8=EC=88=98?= =?UTF-8?q?=EC=B2=A9=20=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20(#1444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove: 더 이상 사용하지 않는 v1 repository 삭제 * feat: 커피챗 허용 속성 추가 및 사용하지 않는 job 속성 삭제 * fix: typo * fix: typo * feat: 동문수첩 상세 조회 시 커피챗 허용여부 포함하도록 변경 * feat: 동문수첩 상세 조회 시 학과 정보 포함 * feat: 동문 정보 리스트 조회 cursor 추가 * feat: 커서 기반 동문리스트 조회 api 추가 * refactor: 커서 관련 책임 분리 * feat: summary 에 커피챗 가능 여부, 학과 정보 추가 * feat: 커서 일관성 유지를 위한 필터해시 추가 * docs: javadoc 주석 추가 * test: #1442 동문수첩 서비스 테스트 수정 --- .../api/v2/controller/UserInfoController.java | 32 ++- .../v2/dto/request/UserInfoListRequest.java | 4 +- .../v2/dto/response/DepartmentResponse.java | 8 + .../dto/response/UserInfoDetailResponse.java | 8 +- .../response/UserInfoDirectoryResponse.java | 9 + .../dto/response/UserInfoSectionResponse.java | 11 + .../dto/response/UserInfoSummaryResponse.java | 6 +- .../api/v2/mapper/UserInfoDtoMapper.java | 36 ++- .../account/entity/userInfo/UserInfo.java | 25 +-- .../enums/userinfo/UserInfoSectionType.java | 18 ++ .../userInfo/UserInfoQueryRepository.java | 185 ++++++++++++++++ .../userInfo/UserInfoQueryV1Repository.java | 12 - .../UserInfoQueryV1RepositoryImpl.java | 105 --------- .../user/account/service/UserInfoService.java | 121 ++++++++++- .../account/service/dto/UserInfoCursor.java | 30 +++ .../dto/result/UserInfoDetailResult.java | 7 +- .../dto/result/UserInfoDirectoryResult.java | 9 + .../dto/result/UserInfoSectionResult.java | 11 + .../dto/result/UserInfoSummaryResult.java | 5 +- .../implementation/UserInfoReader.java | 24 ++ .../service/mapper/UserInfoMapper.java | 11 +- .../service/util/UserInfoCursorManager.java | 205 ++++++++++++++++++ .../errorcode/UserInfoErrorCode.java | 1 + ...8__AddCoffeeChatAvailabilityToUserInfo.sql | 14 ++ .../account/service/UserInfoServiceTest.java | 35 ++- 25 files changed, 768 insertions(+), 164 deletions(-) create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/DepartmentResponse.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDirectoryResponse.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSectionResponse.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/enums/userinfo/UserInfoSectionType.java delete mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1Repository.java delete mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1RepositoryImpl.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/UserInfoCursor.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDirectoryResult.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSectionResult.java create mode 100644 app-main/src/main/java/net/causw/app/main/domain/user/account/service/util/UserInfoCursorManager.java create mode 100644 app-main/src/main/resources/db/migration/V20260804194808__AddCoffeeChatAvailabilityToUserInfo.sql diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/controller/UserInfoController.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/controller/UserInfoController.java index 0c224a425..e4c5029ad 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/controller/UserInfoController.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/controller/UserInfoController.java @@ -18,10 +18,13 @@ import net.causw.app.main.domain.user.account.api.v2.dto.request.UserInfoListRequest; import net.causw.app.main.domain.user.account.api.v2.dto.request.UserInfoUpdateRequest; import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoDetailResponse; +import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoDirectoryResponse; import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoSummaryResponse; import net.causw.app.main.domain.user.account.api.v2.mapper.UserInfoDtoMapper; import net.causw.app.main.domain.user.account.service.UserInfoService; +import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDetailResult; +import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDirectoryResult; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSummaryResult; import net.causw.app.main.domain.user.auth.userdetails.CustomUserDetails; import net.causw.app.main.shared.dto.ApiResponse; @@ -100,9 +103,34 @@ public ApiResponse> getUserInfoPage( @ModelAttribute @Valid UserInfoListRequest request, @RequestParam(name = "pageNum", required = false, defaultValue = "0") Integer pageNum, @AuthenticationPrincipal CustomUserDetails userDetails) { - Page result = userInfoService.getUserInfoPage(userInfoDtoMapper.toListCondition(request), - pageNum, userDetails.getUserId()); + + UserInfoListCondition listCondition = userInfoDtoMapper.toListCondition(request); + Page result = userInfoService.getUserInfoPage(listCondition, pageNum, + userDetails.getUserId()); Page response = result.map(userInfoDtoMapper::toSummaryResponse); + return ApiResponse.success(PageResponse.from(response)); } + + /** + * 동문 수첩 프로필 리스트 조회 및 검색 + * @param request 동문 수첩 프로필 리스트 조회 요청 DTO + * @param pageNum 페이징 + * @return 조회된 동문 수첩 프로필 리스트 + */ + @GetMapping(value = "/list") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "동문 수첩 프로필 리스트 조회 및 검색 (커서기반)", description = "검색어 또는 필터를 포함해 동문 수첩 프로필 리스트를 조회합니다.(본인 프로필 포함)") + public ApiResponse getUserInfoList( + @ModelAttribute @Valid UserInfoListRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + UserInfoListCondition listCondition = userInfoDtoMapper.toListCondition(request); + UserInfoDirectoryResult result = userInfoService.getUserInfoByCursor( + listCondition, + userDetails.getUserId(), + request.cursor()); + + return ApiResponse.success(userInfoDtoMapper.toDirectoryResponse(result)); + } } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/request/UserInfoListRequest.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/request/UserInfoListRequest.java index fc843269b..d51595ee8 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/request/UserInfoListRequest.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/request/UserInfoListRequest.java @@ -15,5 +15,7 @@ public record UserInfoListRequest( @Schema(description = "학적 상태", example = "[\"ENROLLED\", \"GRADUATED\"]") List academicStatus, - @Schema(description = "정렬 기준", example = "UPDATED_AT_DESC") String sortType) { + @Schema(description = "정렬 기준", example = "UPDATED_AT_DESC") String sortType, + + @Schema(description = "요청 커서(없으면 null)", example = "{랜덤 해시값}") String cursor) { } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/DepartmentResponse.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/DepartmentResponse.java new file mode 100644 index 000000000..7033c423f --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/DepartmentResponse.java @@ -0,0 +1,8 @@ +package net.causw.app.main.domain.user.account.api.v2.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record DepartmentResponse( + @Schema(description = "학과 코드", example = "SCHOOL_OF_SW") String code, + @Schema(description = "학과 이름", example = "소프트웨어학부") String name) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDetailResponse.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDetailResponse.java index c798ef23a..ee04dae48 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDetailResponse.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDetailResponse.java @@ -15,6 +15,8 @@ public record UserInfoDetailResponse( @Schema(description = "사용자 학번", example = "18학번") String admissionYear, + @Schema(description = "사용자 학과", example = "소프트웨어학부/미기입") String departmentDescription, + @Schema(description = "사용자 학적 상태", example = "졸업생") String academicStatus, @Schema(description = "동문 수첩 프로필 소개글", example = "동문 수첩 프로필 소개글입니다.") String description, @@ -35,5 +37,9 @@ public record UserInfoDetailResponse( @Schema(description = "사용자 관심 기술", example = "[\"Tech0\", \"Tech1\"]") List userInterestTech, - @Schema(description = "사용자 관심 도메인", example = "[\"Domain0\", \"Domain1\"]") List userInterestDomain) { + @Schema(description = "사용자 관심 도메인", example = "[\"Domain0\", \"Domain1\"]") List userInterestDomain, + + @Schema(description = "커피챗 가능 여부", example = "false") boolean isCoffeeChatAvailable + +) { } \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDirectoryResponse.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDirectoryResponse.java new file mode 100644 index 000000000..75cf28c34 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoDirectoryResponse.java @@ -0,0 +1,9 @@ +package net.causw.app.main.domain.user.account.api.v2.dto.response; + +import java.util.List; + +public record UserInfoDirectoryResponse( + UserInfoSummaryResponse myProfile, + List sections, + String nextCursor) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSectionResponse.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSectionResponse.java new file mode 100644 index 000000000..a525c29c3 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSectionResponse.java @@ -0,0 +1,11 @@ +package net.causw.app.main.domain.user.account.api.v2.dto.response; + +import java.util.List; + +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; + +public record UserInfoSectionResponse( + UserInfoSectionType type, + List items, + boolean hasNext) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSummaryResponse.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSummaryResponse.java index a200c0077..52c84593d 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSummaryResponse.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/dto/response/UserInfoSummaryResponse.java @@ -15,5 +15,9 @@ public record UserInfoSummaryResponse( @Schema(description = "사용자 학적 상태", example = "졸업생") String academicStatus, - @Schema(description = "동문 수첩 프로필 설명", example = "동문 수첩 프로필 설명입니다.") String description) { + @Schema(description = "사용자 학과") DepartmentResponse department, + + @Schema(description = "동문 수첩 프로필 설명", example = "동문 수첩 프로필 설명입니다.") String description, + + @Schema(description = "커피챗 가능 여부", example = "false") boolean isCoffeeChatAvailable) { } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/mapper/UserInfoDtoMapper.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/mapper/UserInfoDtoMapper.java index 442a88db8..28e0ccc2e 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/mapper/UserInfoDtoMapper.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/api/v2/mapper/UserInfoDtoMapper.java @@ -1,24 +1,58 @@ package net.causw.app.main.domain.user.account.api.v2.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import net.causw.app.main.domain.user.account.api.v2.dto.request.UserInfoListRequest; import net.causw.app.main.domain.user.account.api.v2.dto.request.UserInfoUpdateRequest; +import net.causw.app.main.domain.user.account.api.v2.dto.response.DepartmentResponse; import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoDetailResponse; +import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoDirectoryResponse; +import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoSectionResponse; import net.causw.app.main.domain.user.account.api.v2.dto.response.UserInfoSummaryResponse; +import net.causw.app.main.domain.user.account.enums.user.Department; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoUpdateCommand; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDetailResult; +import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDirectoryResult; +import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSectionResult; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSummaryResult; @Mapper(componentModel = "spring") public interface UserInfoDtoMapper { + @Mapping(target = "departmentDescription", source = "result.department.name", defaultValue = "학과 미기입") UserInfoDetailResponse toDetailResponse(UserInfoDetailResult result); UserInfoSummaryResponse toSummaryResponse(UserInfoSummaryResult result); + /** + * 학과 enum을 API 응답 객체로 변환한다. + * @param department 학과 enum + * @return 학과 코드와 이름을 포함한 응답, 학과가 없으면 {@code null} + */ + default DepartmentResponse toDepartmentResponse(Department department) { + if (department == null) { + return null; + } + return new DepartmentResponse(department.name(), department.getName()); + } + + /** + * 동문 섹션 조회 결과를 API 응답으로 변환한다. + * @param result 동문 섹션 조회 결과 + * @return 동문 섹션 응답 + */ + UserInfoSectionResponse toSectionResponse(UserInfoSectionResult result); + + /** + * 동문 목록 조회 결과를 API 응답으로 변환한다. + * @param result 동문 목록 조회 결과 + * @return 동문 목록 응답 + */ + UserInfoDirectoryResponse toDirectoryResponse(UserInfoDirectoryResult result); + UserInfoUpdateCommand toUpdateCommand(UserInfoUpdateRequest request); UserInfoListCondition toListCondition(UserInfoListRequest request); -} \ No newline at end of file +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/entity/userInfo/UserInfo.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/entity/userInfo/UserInfo.java index c5894692b..5256e010a 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/entity/userInfo/UserInfo.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/entity/userInfo/UserInfo.java @@ -46,15 +46,6 @@ public class UserInfo extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - /** - * @deprecated v2 API에서는 직업(job) 정보가 소개글(description)과 역할이 겹쳐 더 이상 사용하지 않습니다. - * v1 호환을 위해 컬럼은 유지합니다. - * TODO: v2 배포 완료 후 제거 - */ - @Deprecated(since = "v2") - @Column(name = "job", nullable = true) - private String job; - @Column(name = "description", nullable = true) private String description; @@ -93,24 +84,16 @@ public class UserInfo extends BaseEntity { @Builder.Default private Set userInterestDomain = new HashSet<>(); + @Column(name = "is_coffee_chat_available", nullable = false) + @Builder.Default + private boolean isCoffeeChatAvailable = false; + public static UserInfo of(User user) { return UserInfo.builder() .user(user) .build(); } - /** - * v1 API용 업데이트. description, job, socialLinks, isPhoneNumberVisible을 한 번에 갱신한다. - * v1은 API 레이어에서 검증하므로 엔티티 레벨 검증 없이 직접 반영한다. - */ - @Deprecated(since = "v2 API에서는 description, socialLinks, isPhoneNumberVisible을 각각 업데이트하는 별도의 메서드를 사용합니다.") - public void updateV1(String description, String job, List socialLinks, boolean isPhoneNumberVisible) { - this.description = description; - this.job = job; - this.socialLinks = socialLinks == null ? new ArrayList<>() : new ArrayList<>(socialLinks); - this.isPhoneNumberVisible = isPhoneNumberVisible; - } - public void update( String description, boolean isPhoneNumberVisible) { diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/enums/userinfo/UserInfoSectionType.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/enums/userinfo/UserInfoSectionType.java new file mode 100644 index 000000000..31db829ba --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/enums/userinfo/UserInfoSectionType.java @@ -0,0 +1,18 @@ +package net.causw.app.main.domain.user.account.enums.userinfo; + +public enum UserInfoSectionType { + COFFEE_CHAT_AVAILABLE, + ALL_MEMBERS; + + /** + * 현재 섹션 다음에 조회할 섹션을 반환한다. + * @return 다음 섹션, 마지막 섹션인 경우 {@code null} + */ + public UserInfoSectionType next() { + return switch (this) { + case COFFEE_CHAT_AVAILABLE -> ALL_MEMBERS; + case ALL_MEMBERS -> null; + }; + + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryRepository.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryRepository.java index 054ec9885..d7f9357ed 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryRepository.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryRepository.java @@ -1,9 +1,13 @@ package net.causw.app.main.domain.user.account.repository.userInfo; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; @@ -13,6 +17,7 @@ import net.causw.app.main.domain.user.account.entity.userInfo.QUserInfo; import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; import net.causw.app.main.domain.user.account.enums.userinfo.SortType; +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.shared.exception.errorcode.UserInfoErrorCode; @@ -31,6 +36,186 @@ public class UserInfoQueryRepository { private final JPAQueryFactory jpaQueryFactory; + /** + * 검색 조건과 커서 위치를 기준으로 특정 동문 섹션을 조회한다. + * @param listCondition 검색 및 필터 조건 + * @param positionId 마지막으로 조회한 동문 프로필 ID + * @param section 조회할 동문 섹션 + * @param cursorSortType 커서에 저장된 정렬 기준 + * @param updatedAt 마지막으로 조회한 프로필의 수정 일시 + * @param admissionYear 마지막으로 조회한 사용자의 입학 연도 + * @param name 마지막으로 조회한 사용자의 이름 + * @param excludeUserId 조회 결과에서 제외할 사용자 ID + * @param size 조회할 최대 항목 수 + * @return 다음 페이지 존재 여부를 포함한 동문 프로필 slice + */ + public Slice findAllWithFilter( + UserInfoListCondition listCondition, + String positionId, + UserInfoSectionType section, + SortType cursorSortType, + LocalDateTime updatedAt, + Integer admissionYear, + String name, + String excludeUserId, + int size) { + QUserInfo userInfo = QUserInfo.userInfo; + QUser user = QUser.user; + + SortType sortType = resolveSortType(listCondition); + if (cursorSortType != null && cursorSortType != sortType) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + + BooleanExpression condition = baseCondition(listCondition, userInfo, excludeUserId); + BooleanExpression sectionCondition = userInfo.isCoffeeChatAvailable.eq( + section == UserInfoSectionType.COFFEE_CHAT_AVAILABLE); + BooleanExpression cursorCondition = cursorCondition( + userInfo, + sortType, + updatedAt, + admissionYear, + name, + positionId); + + List content = jpaQueryFactory + .selectFrom(userInfo) + .join(userInfo.user, user).fetchJoin() + .where(condition, sectionCondition, cursorCondition) + .orderBy(getCursorSortType(sortType, userInfo)) + .limit(size + 1L) + .fetch(); + + boolean hasNext = content.size() > size; + if (hasNext) { + content.remove(size); + } + + return new SliceImpl<>(content, PageRequest.of(0, size), hasNext); + } + + private BooleanExpression cursorCondition( + QUserInfo userInfo, + SortType sortType, + LocalDateTime updatedAt, + Integer admissionYear, + String name, + String positionId) { + if (updatedAt == null && admissionYear == null && name == null && positionId == null) { + return null; + } + if (updatedAt == null || admissionYear == null || name == null || positionId == null) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + + return switch (sortType) { + case UPDATED_AT_DESC -> updatedAtDescCursorCondition( + userInfo, updatedAt, admissionYear, positionId); + case UPDATED_AT_ASC -> updatedAtAscCursorCondition( + userInfo, updatedAt, admissionYear, positionId); + case ADMISSION_YEAR_DESC -> admissionYearDescCursorCondition( + userInfo, updatedAt, admissionYear, name, positionId); + case ADMISSION_YEAR_ASC -> admissionYearAscCursorCondition( + userInfo, updatedAt, admissionYear, name, positionId); + }; + } + + private BooleanExpression updatedAtDescCursorCondition( + QUserInfo userInfo, + LocalDateTime updatedAt, + Integer admissionYear, + String positionId) { + return userInfo.updatedAt.lt(updatedAt) + .or(userInfo.updatedAt.eq(updatedAt) + .and(userInfo.user.admissionYear.lt(admissionYear))) + .or(userInfo.updatedAt.eq(updatedAt) + .and(userInfo.user.admissionYear.eq(admissionYear)) + .and(userInfo.id.lt(positionId))); + } + + private BooleanExpression updatedAtAscCursorCondition( + QUserInfo userInfo, + LocalDateTime updatedAt, + Integer admissionYear, + String positionId) { + return userInfo.updatedAt.gt(updatedAt) + .or(userInfo.updatedAt.eq(updatedAt) + .and(userInfo.user.admissionYear.lt(admissionYear))) + .or(userInfo.updatedAt.eq(updatedAt) + .and(userInfo.user.admissionYear.eq(admissionYear)) + .and(userInfo.id.gt(positionId))); + } + + private BooleanExpression admissionYearDescCursorCondition( + QUserInfo userInfo, + LocalDateTime updatedAt, + Integer admissionYear, + String name, + String positionId) { + return userInfo.user.admissionYear.lt(admissionYear) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.gt(name))) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.eq(name)) + .and(userInfo.updatedAt.lt(updatedAt))) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.eq(name)) + .and(userInfo.updatedAt.eq(updatedAt)) + .and(userInfo.id.lt(positionId))); + } + + private BooleanExpression admissionYearAscCursorCondition( + QUserInfo userInfo, + LocalDateTime updatedAt, + Integer admissionYear, + String name, + String positionId) { + return userInfo.user.admissionYear.gt(admissionYear) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.gt(name))) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.eq(name)) + .and(userInfo.updatedAt.lt(updatedAt))) + .or(userInfo.user.admissionYear.eq(admissionYear) + .and(userInfo.user.name.eq(name)) + .and(userInfo.updatedAt.eq(updatedAt)) + .and(userInfo.id.gt(positionId))); + } + + private OrderSpecifier[] getCursorSortType(SortType sortType, QUserInfo userInfo) { + return switch (sortType) { + case UPDATED_AT_DESC -> new OrderSpecifier[] { + userInfo.updatedAt.desc(), + userInfo.user.admissionYear.desc(), + userInfo.id.desc() + }; + case UPDATED_AT_ASC -> new OrderSpecifier[] { + userInfo.updatedAt.asc(), + userInfo.user.admissionYear.desc(), + userInfo.id.asc() + }; + case ADMISSION_YEAR_DESC -> new OrderSpecifier[] { + userInfo.user.admissionYear.desc(), + userInfo.user.name.asc(), + userInfo.updatedAt.desc(), + userInfo.id.desc() + }; + case ADMISSION_YEAR_ASC -> new OrderSpecifier[] { + userInfo.user.admissionYear.asc(), + userInfo.user.name.asc(), + userInfo.updatedAt.desc(), + userInfo.id.asc() + }; + }; + } + + private SortType resolveSortType(UserInfoListCondition filter) { + if (filter.sortType() == null || filter.sortType().isBlank()) { + return SortType.UPDATED_AT_DESC; + } + return SortType.fromString(filter.sortType()); + } + public Page findAllWithFilter(UserInfoListCondition filter, Pageable pageable, String excludeUserId) { QUserInfo userInfo = QUserInfo.userInfo; QUser user = QUser.user; diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1Repository.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1Repository.java deleted file mode 100644 index 5eb6854d6..000000000 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1Repository.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.causw.app.main.domain.user.account.repository.userInfo; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; -import net.causw.app.main.domain.user.account.service.dto.request.UserInfoSearchCondition; - -public interface UserInfoQueryV1Repository { - - Page searchUserInfo(UserInfoSearchCondition userInfoSearchCondition, Pageable pageable); -} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1RepositoryImpl.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1RepositoryImpl.java deleted file mode 100644 index 657851ef8..000000000 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/repository/userInfo/UserInfoQueryV1RepositoryImpl.java +++ /dev/null @@ -1,105 +0,0 @@ -package net.causw.app.main.domain.user.account.repository.userInfo; - -import java.util.List; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Repository; - -import net.causw.app.main.domain.user.academic.enums.userAcademicRecord.AcademicStatus; -import net.causw.app.main.domain.user.account.entity.user.QUser; -import net.causw.app.main.domain.user.account.entity.userInfo.QUserCareer; -import net.causw.app.main.domain.user.account.entity.userInfo.QUserInfo; -import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; -import net.causw.app.main.domain.user.account.enums.user.UserState; -import net.causw.app.main.domain.user.account.service.dto.request.UserInfoSearchCondition; - -import com.querydsl.core.BooleanBuilder; -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.impl.JPAQueryFactory; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class UserInfoQueryV1RepositoryImpl implements UserInfoQueryV1Repository { - private final JPAQueryFactory jpaQueryFactory; - - @Override - public Page searchUserInfo(UserInfoSearchCondition userInfoSearchCondition, Pageable pageable) { - // keyword는 이름, 직업, 경력, like 검색 - // user, job, user.career - QUserInfo userInfo = QUserInfo.userInfo; - BooleanBuilder predicate = buildSearchPredicate(userInfoSearchCondition, userInfo); - - QUser user = QUser.user; - - List content = jpaQueryFactory - .selectFrom(userInfo) - .join(userInfo.user, user).fetchJoin() - .where(user.state.eq(UserState.ACTIVE)) - .where(user.academicStatus.ne(AcademicStatus.UNDETERMINED)) - .where(predicate) - .orderBy(userInfo.updatedAt.desc()) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .distinct() - .fetch(); - - Long total = jpaQueryFactory - .select(userInfo.count()) - .from(userInfo) - .where(predicate) - .fetchOne(); - - return new PageImpl<>(content, pageable, total != null ? total : 0); - } - - /** - * 검색 조건 생성 - * (학번, 학적상태 (in), 이름 or 직업 or 커리어,텍스트 검색) - * @param userInfoSearchCondition 검색 조건 dto - * @param userInfo 검색 QueryDSL 엔티티 - * @return 검색 조건 booleanBuilder - */ - private BooleanBuilder buildSearchPredicate( - UserInfoSearchCondition userInfoSearchCondition, - QUserInfo userInfo) { - BooleanBuilder predicate = new BooleanBuilder(); - String keyword = userInfoSearchCondition.keyword(); - - QUserCareer userCareer = QUserCareer.userCareer; - if (keyword != null && !keyword.trim().isEmpty()) { - BooleanBuilder keywordPredicate = new BooleanBuilder(); - keywordPredicate.or(userInfo.user.name.containsIgnoreCase(keyword)); - keywordPredicate.or(userInfo.job.containsIgnoreCase(keyword)); - keywordPredicate.or(JPAExpressions.selectFrom(userCareer) - .where(userCareer.userInfo.eq(userInfo) - .and(userCareer.description.containsIgnoreCase(keyword))) - .exists()); - - predicate.and(keywordPredicate); - } - - // 입학 년도 검색 - Integer admissionYearStart = userInfoSearchCondition.admissionYearStart(); - Integer admissionYearEnd = userInfoSearchCondition.admissionYearEnd(); - if (admissionYearStart != null && admissionYearEnd != null) { - BooleanBuilder admissionYearPredicate = new BooleanBuilder(); - admissionYearPredicate.and(userInfo.user.admissionYear.between(admissionYearStart, admissionYearEnd)); - - predicate.and(admissionYearPredicate); - } - - // 학적 상태 - List academicStatuses = userInfoSearchCondition.academicStatus(); - if (academicStatuses != null && !academicStatuses.isEmpty()) { - BooleanBuilder academicStatusPredicate = new BooleanBuilder(); - academicStatusPredicate.and(userInfo.user.academicStatus.in(academicStatuses)); - - predicate.and(academicStatusPredicate); - } - return predicate; - } -} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/UserInfoService.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/UserInfoService.java index 8b6f3bb1b..0832934d1 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/UserInfoService.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/UserInfoService.java @@ -1,11 +1,13 @@ package net.causw.app.main.domain.user.account.service; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,14 +15,20 @@ import net.causw.app.main.domain.asset.file.service.implementation.UserProfileImageReader; import net.causw.app.main.domain.user.account.entity.user.User; import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; +import net.causw.app.main.domain.user.account.enums.userinfo.SortType; +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; +import net.causw.app.main.domain.user.account.service.dto.UserInfoCursor; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoUpdateCommand; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDetailResult; +import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDirectoryResult; +import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSectionResult; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSummaryResult; import net.causw.app.main.domain.user.account.service.implementation.UserInfoCreator; import net.causw.app.main.domain.user.account.service.implementation.UserInfoReader; import net.causw.app.main.domain.user.account.service.implementation.UserInfoWriter; import net.causw.app.main.domain.user.account.service.mapper.UserInfoMapper; +import net.causw.app.main.domain.user.account.service.util.UserInfoCursorManager; import net.causw.app.main.shared.exception.errorcode.UserInfoErrorCode; import net.causw.app.main.shared.pageable.PageableFactory; import net.causw.global.constant.StaticValue; @@ -37,6 +45,7 @@ public class UserInfoService { private final PageableFactory pageableFactory; private final UserInfoWriter userInfoWriter; private final UserProfileImageReader userProfileImageReader; + private final UserInfoCursorManager userInfoCursorManager; /** * 내 동문 수첩 프로필 수정 @@ -63,7 +72,7 @@ public UserInfoDetailResult updateUserInfo(UserInfoUpdateCommand request, User u UserInfo updated = userInfoWriter.save(userInfo); UserProfileImage profileImage = userProfileImageReader.findByUserIdOrNull(user.getId()); - return userInfoMapper.toDetailResult(updated, profileImage); + return userInfoMapper.toDetailResult(updated, profileImage, user.getDepartment()); } /** @@ -75,10 +84,11 @@ public UserInfoDetailResult updateUserInfo(UserInfoUpdateCommand request, User u public UserInfoDetailResult getDetailUserInfo(String userInfoId) { UserInfo userInfo = userInfoReader.findById(userInfoId) .orElseThrow(UserInfoErrorCode.USERINFO_NOT_FOUND::toBaseException); + User user = userInfo.getUser(); UserProfileImage profileImage = userProfileImageReader.findByUserIdOrNull( userInfo.getUser().getId()); - return userInfoMapper.toDetailResult(userInfo, profileImage); + return userInfoMapper.toDetailResult(userInfo, profileImage, user.getDepartment()); } /** @@ -93,7 +103,7 @@ public UserInfoDetailResult getMyDetailUserInfo(User user) { .orElseGet(() -> userInfoCreator.createAndSave(user)); UserProfileImage profileImage = userProfileImageReader.findByUserIdOrNull(user.getId()); - return userInfoMapper.toMyDetailResult(userInfo, profileImage); + return userInfoMapper.toMyDetailResult(userInfo, profileImage, user.getDepartment()); } /** @@ -107,6 +117,7 @@ public Page getUserInfoPage( UserInfoListCondition condition, Integer pageNum, String excludeUserId) { + Pageable pageable = pageableFactory.create(pageNum, StaticValue.USER_LIST_PAGE_SIZE); Page userInfos = userInfoReader.findUserInfoWithFilter(condition, pageable, excludeUserId); @@ -119,4 +130,108 @@ public Page getUserInfoPage( profileImageMap.get(ui.getUser().getId()))); } + /** + * 동문 수첩 프로필 리스트 조회 및 검색 + * @param condition 조회 조건 + * @param userId 조회 user id + * @param cursor 커서 + * @return 동문수첩 리스트 + */ + @Transactional(readOnly = true) + public UserInfoDirectoryResult getUserInfoByCursor( + UserInfoListCondition condition, + String userId, + String cursor) { + + boolean isInitialCursor = cursor == null || cursor.isBlank(); + UserInfoSummaryResult myProfile = isInitialCursor ? getUserInfoSummaryResultByUserId(userId) : null; + + int pageSize = StaticValue.USER_LIST_PAGE_SIZE; + SortType sortType = resolveSortType(condition); + String filterHash = userInfoCursorManager.createFilterHash(condition, sortType, userId); + + UserInfoCursor nextCursor = isInitialCursor + ? UserInfoCursor.sectionStartCursor( + UserInfoSectionType.COFFEE_CHAT_AVAILABLE, + sortType, + filterHash) + : userInfoCursorManager.decode(cursor); + userInfoCursorManager.validateFilterHash(nextCursor, condition, sortType, userId); + + // 조회를 통해 얻은 section의 개수가 목표개수보다 작다면, 다음 커서를 불러와서 조회 (반복) + Map sections = new EnumMap<>(UserInfoSectionType.class); + for (UserInfoSectionType sectionType : UserInfoSectionType.values()) { + sections.put(sectionType, new UserInfoSectionResult(sectionType, List.of(), false)); + } + UserInfoSectionType currentSectionType = nextCursor.section(); + int remainingSize = pageSize; + + while (remainingSize > 0 && currentSectionType != null) { + Slice currentSlice = userInfoReader.readCursor( + condition, + nextCursor, + userId, + remainingSize); + List summaryResults = toSummaryResults(currentSlice.getContent()); + sections.put( + currentSectionType, + new UserInfoSectionResult(currentSectionType, summaryResults, currentSlice.hasNext())); + remainingSize -= currentSlice.getContent().size(); + + // 해당 slice next가 있다면 조회 완료된것이므로 pass + if (currentSlice.hasNext()) { + nextCursor = userInfoCursorManager.nextCursor( + currentSlice, + currentSectionType, + sortType, + filterHash); + break; + } + + // 현재 sectionType 조회완료했는데도 목표 개수 못채웠다면 nextCursor + currentSectionType = currentSectionType.next(); + if (currentSectionType != null) { + nextCursor = UserInfoCursor.sectionStartCursor(currentSectionType, sortType, filterHash); + } else { + nextCursor = null; + } + + } + + List result = List.of( + sections.get(UserInfoSectionType.COFFEE_CHAT_AVAILABLE), + sections.get(UserInfoSectionType.ALL_MEMBERS)); + return new UserInfoDirectoryResult( + myProfile, + result, + nextCursor != null ? userInfoCursorManager.encode(nextCursor) : null); + } + + private UserInfoSummaryResult getUserInfoSummaryResultByUserId(String userId) { + UserInfo userInfo = userInfoReader.findByUserId(userId) + .orElseThrow(UserInfoErrorCode.USERINFO_NOT_FOUND::toBaseException); + UserProfileImage profileImage = userProfileImageReader.findByUserIdOrNull(userId); + + return userInfoMapper.toSummaryResult(userInfo, profileImage); + } + + private List toSummaryResults(List userInfos) { + List userIds = userInfos.stream() + .map(userInfo -> userInfo.getUser().getId()) + .toList(); + Map profileImageMap = userProfileImageReader.findMapByUserIds(userIds); + + return userInfos.stream() + .map(userInfo -> userInfoMapper.toSummaryResult( + userInfo, + profileImageMap.get(userInfo.getUser().getId()))) + .toList(); + } + + private SortType resolveSortType(UserInfoListCondition condition) { + if (condition.sortType() == null || condition.sortType().isBlank()) { + return SortType.UPDATED_AT_DESC; + } + return SortType.fromString(condition.sortType()); + } } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/UserInfoCursor.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/UserInfoCursor.java new file mode 100644 index 000000000..06215fc8c --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/UserInfoCursor.java @@ -0,0 +1,30 @@ +package net.causw.app.main.domain.user.account.service.dto; + +import java.time.LocalDateTime; + +import net.causw.app.main.domain.user.account.enums.userinfo.SortType; +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; + +public record UserInfoCursor( + UserInfoSectionType section, + SortType sortType, + LocalDateTime updatedAt, + Integer admissionYear, + String name, + String userInfoId, + String filterHash) { + + /** + * 지정한 섹션의 첫 항목부터 조회하기 위한 커서를 생성한다. + * @param section 조회를 시작할 섹션 + * @param sortType 정렬 기준 + * @param filterHash 조회 조건 식별 hash + * @return 섹션 시작 커서 + */ + public static UserInfoCursor sectionStartCursor( + UserInfoSectionType section, + SortType sortType, + String filterHash) { + return new UserInfoCursor(section, sortType, null, null, null, null, filterHash); + } +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDetailResult.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDetailResult.java index a8cb99c4f..8caf72dcf 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDetailResult.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDetailResult.java @@ -2,6 +2,7 @@ import java.util.List; +import net.causw.app.main.domain.user.account.enums.user.Department; import net.causw.app.main.shared.dto.ProfileImageDto; public record UserInfoDetailResult( @@ -12,12 +13,14 @@ public record UserInfoDetailResult( String academicStatus, String description, String phoneNumber, - Boolean isPhoneNumberVisible, + boolean isPhoneNumberVisible, String email, List socialLinks, List userTechStack, List userCareer, List userProject, List userInterestTech, - List userInterestDomain) { + List userInterestDomain, + boolean isCoffeeChatAvailable, + Department department) { } \ No newline at end of file diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDirectoryResult.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDirectoryResult.java new file mode 100644 index 000000000..fafc8ff35 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoDirectoryResult.java @@ -0,0 +1,9 @@ +package net.causw.app.main.domain.user.account.service.dto.result; + +import java.util.List; + +public record UserInfoDirectoryResult( + UserInfoSummaryResult myProfile, + List sections, + String nextCursor) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSectionResult.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSectionResult.java new file mode 100644 index 000000000..4cce4a30d --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSectionResult.java @@ -0,0 +1,11 @@ +package net.causw.app.main.domain.user.account.service.dto.result; + +import java.util.List; + +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; + +public record UserInfoSectionResult( + UserInfoSectionType type, + List items, + boolean hasNext) { +} diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSummaryResult.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSummaryResult.java index b4a7aad0b..ff6ba4d6e 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSummaryResult.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/dto/result/UserInfoSummaryResult.java @@ -1,5 +1,6 @@ package net.causw.app.main.domain.user.account.service.dto.result; +import net.causw.app.main.domain.user.account.enums.user.Department; import net.causw.app.main.shared.dto.ProfileImageDto; public record UserInfoSummaryResult( @@ -8,5 +9,7 @@ public record UserInfoSummaryResult( String name, String admissionYear, String academicStatus, - String description) { + Department department, + String description, + boolean isCoffeeChatAvailable) { } diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserInfoReader.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserInfoReader.java index 98d1c2ed5..8f063b969 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserInfoReader.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/implementation/UserInfoReader.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,7 @@ import net.causw.app.main.domain.user.account.repository.userInfo.UserInfoQueryRepository; import net.causw.app.main.domain.user.account.repository.userInfo.UserInfoRepository; import net.causw.app.main.domain.user.account.repository.userInfo.UserProjectRepository; +import net.causw.app.main.domain.user.account.service.dto.UserInfoCursor; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.shared.entity.BaseEntity; import net.causw.app.main.shared.exception.errorcode.UserInfoErrorCode; @@ -45,6 +47,28 @@ public Page findUserInfoWithFilter(UserInfoListCondition condition, Pa return userInfoQueryRepository.findAllWithFilter(condition, pageable, excludeUserId); } + /** + * 조건 및 커서로 동문수첩 리스트 조회 + * @param listCondition 검색 조건 + * @return + */ + public Slice readCursor( + UserInfoListCondition listCondition, + UserInfoCursor cursor, + String excludeUserId, + int size) { + return userInfoQueryRepository.findAllWithFilter( + listCondition, + cursor.userInfoId(), + cursor.section(), + cursor.sortType(), + cursor.updatedAt(), + cursor.admissionYear(), + cursor.name(), + excludeUserId, + size); + } + /** * ID로 동문 수첩 프로필 조회 * @param userInfoId 동문 수첩 프로필 ID diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/mapper/UserInfoMapper.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/mapper/UserInfoMapper.java index b45cb36a8..3b996fb30 100644 --- a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/mapper/UserInfoMapper.java +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/mapper/UserInfoMapper.java @@ -14,6 +14,7 @@ import net.causw.app.main.domain.user.account.entity.userInfo.UserCareer; import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; import net.causw.app.main.domain.user.account.entity.userInfo.UserProject; +import net.causw.app.main.domain.user.account.enums.user.Department; import net.causw.app.main.domain.user.account.service.dto.result.UserCareerResult; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDetailResult; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoSummaryResult; @@ -39,7 +40,9 @@ public interface UserInfoMapper extends UuidFileToUrlDtoMapper { @Mapping(target = "userProject", source = "userInfo.userProject", qualifiedByName = "sortUserProject") @Mapping(target = "userInterestTech", source = "userInfo.userInterestTech", qualifiedByName = "sortStringsAsc") @Mapping(target = "userInterestDomain", source = "userInfo.userInterestDomain", qualifiedByName = "sortStringsAsc") - UserInfoDetailResult toDetailResult(UserInfo userInfo, UserProfileImage userProfileImage); + @Mapping(target = "isCoffeeChatAvailable", source = "userInfo.coffeeChatAvailable") + @Mapping(target = "department", source = "department") + UserInfoDetailResult toDetailResult(UserInfo userInfo, UserProfileImage userProfileImage, Department department); // 내 동문 수첩 프로필 상세 조회 (전화번호 직접 노출) @Mapping(target = "id", source = "userInfo.id") @@ -57,7 +60,9 @@ public interface UserInfoMapper extends UuidFileToUrlDtoMapper { @Mapping(target = "userProject", source = "userInfo.userProject", qualifiedByName = "sortUserProject") @Mapping(target = "userInterestTech", source = "userInfo.userInterestTech", qualifiedByName = "sortStringsAsc") @Mapping(target = "userInterestDomain", source = "userInfo.userInterestDomain", qualifiedByName = "sortStringsAsc") - UserInfoDetailResult toMyDetailResult(UserInfo userInfo, UserProfileImage userProfileImage); + @Mapping(target = "isCoffeeChatAvailable", source = "userInfo.coffeeChatAvailable") + @Mapping(target = "department", source = "department") + UserInfoDetailResult toMyDetailResult(UserInfo userInfo, UserProfileImage userProfileImage, Department department); // 동문 수첩 프로필 리스트 조회 @Mapping(target = "id", source = "userInfo.id") @@ -65,7 +70,9 @@ public interface UserInfoMapper extends UuidFileToUrlDtoMapper { @Mapping(target = "name", source = "userInfo.user.name") @Mapping(target = "admissionYear", source = "userInfo", qualifiedByName = "mapAdmissionYear") @Mapping(target = "academicStatus", source = "userInfo", qualifiedByName = "mapAcademicStatus") + @Mapping(target = "department", source = "userInfo.user.department") @Mapping(target = "description", source = "userInfo.description") + @Mapping(target = "isCoffeeChatAvailable", source = "userInfo.coffeeChatAvailable") UserInfoSummaryResult toSummaryResult(UserInfo userInfo, UserProfileImage userProfileImage); @Named("mapAdmissionYear") diff --git a/app-main/src/main/java/net/causw/app/main/domain/user/account/service/util/UserInfoCursorManager.java b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/util/UserInfoCursorManager.java new file mode 100644 index 000000000..db8c5d365 --- /dev/null +++ b/app-main/src/main/java/net/causw/app/main/domain/user/account/service/util/UserInfoCursorManager.java @@ -0,0 +1,205 @@ +package net.causw.app.main.domain.user.account.service.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; + +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Component; + +import net.causw.app.main.domain.user.academic.enums.userAcademicRecord.AcademicStatus; +import net.causw.app.main.domain.user.account.entity.user.User; +import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; +import net.causw.app.main.domain.user.account.enums.userinfo.SortType; +import net.causw.app.main.domain.user.account.enums.userinfo.UserInfoSectionType; +import net.causw.app.main.domain.user.account.service.dto.UserInfoCursor; +import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; +import net.causw.app.main.shared.exception.errorcode.UserInfoErrorCode; + +import lombok.RequiredArgsConstructor; +import tools.jackson.databind.json.JsonMapper; + +@Component +@RequiredArgsConstructor +public class UserInfoCursorManager { + + private final JsonMapper jsonMapper; + + /** + * 조회 결과의 마지막 항목을 기준으로 다음 페이지 커서를 생성한다. + * @param slice 현재 조회 결과 + * @param section 현재 조회한 섹션 + * @param sortType 정렬 기준 + * @param filterHash 조회 조건 식별 hash + * @return 다음 페이지 커서, 다음 페이지가 없으면 {@code null} + */ + public UserInfoCursor nextCursor( + Slice slice, + UserInfoSectionType section, + SortType sortType, + String filterHash) { + if (!slice.hasNext() || slice.getContent().isEmpty()) { + return null; + } + + UserInfo lastUserInfo = slice.getContent().getLast(); + User lastUser = lastUserInfo.getUser(); + return new UserInfoCursor( + section, + sortType, + lastUserInfo.getUpdatedAt(), + lastUser.getAdmissionYear(), + lastUser.getName(), + lastUserInfo.getId(), + filterHash); + } + + /** + * 검색 조건과 사용자별 제외 조건을 정규화하여 SHA-256 hash를 생성한다. + * @param condition 검색 및 필터 조건 + * @param sortType 적용할 정렬 기준 + * @param excludeUserId 조회 결과에서 제외할 사용자 ID + * @return 정규화된 조회 조건의 SHA-256 hash + */ + public String createFilterHash( + UserInfoListCondition condition, + SortType sortType, + String excludeUserId) { + NormalizedFilter normalizedFilter = new NormalizedFilter( + normalizeKeyword(condition.keyword()), + condition.admissionYearStart(), + condition.admissionYearEnd(), + normalizeAcademicStatuses(condition.academicStatus()), + sortType, + excludeUserId); + + try { + byte[] serializedFilter = jsonMapper.writeValueAsBytes(normalizedFilter); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(serializedFilter); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available.", e); + } + } + + /** + * 커서 발급 당시의 조회 조건과 현재 요청의 조회 조건이 같은지 검증한다. + * @param cursor 검증할 커서 + * @param condition 현재 요청의 검색 및 필터 조건 + * @param sortType 현재 요청에 적용할 정렬 기준 + * @param excludeUserId 조회 결과에서 제외할 사용자 ID + * @throws net.causw.app.main.shared.exception.BaseRunTimeV2Exception 조회 조건이 다르면 발생 + */ + public void validateFilterHash( + UserInfoCursor cursor, + UserInfoListCondition condition, + SortType sortType, + String excludeUserId) { + String currentFilterHash = createFilterHash(condition, sortType, excludeUserId); + if (!MessageDigest.isEqual( + cursor.filterHash().getBytes(StandardCharsets.UTF_8), + currentFilterHash.getBytes(StandardCharsets.UTF_8))) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + } + + /** + * Base64 URL-safe 문자열을 동문 목록 커서로 변환하고 필수 값을 검증한다. + * @param cursor 인코딩된 커서 문자열 + * @return 디코딩된 동문 목록 커서 + * @throws net.causw.app.main.shared.exception.BaseRunTimeV2Exception 커서 형식이 올바르지 않으면 발생 + */ + public UserInfoCursor decode(String cursor) { + try { + byte[] json = Base64.getUrlDecoder() + .decode(cursor); + + UserInfoCursor decodedCursor = jsonMapper.readValue(json, UserInfoCursor.class); + + validate(decodedCursor); + return decodedCursor; + } catch (Exception e) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + } + + /** + * 동문 목록 커서를 Base64 URL-safe 문자열로 변환한다. + * @param cursor 인코딩할 동문 목록 커서 + * @return 인코딩된 커서 문자열 + */ + public String encode(UserInfoCursor cursor) { + try { + validate(cursor); + byte[] json = jsonMapper.writeValueAsBytes(cursor); + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private void validate(UserInfoCursor cursor) { + if (cursor == null + || cursor.section() == null + || cursor.sortType() == null + || cursor.filterHash() == null + || cursor.filterHash().isBlank()) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + + boolean sectionStart = cursor.updatedAt() == null + && cursor.admissionYear() == null + && cursor.name() == null + && cursor.userInfoId() == null; + boolean itemPosition = cursor.updatedAt() != null + && cursor.admissionYear() != null + && cursor.name() != null + && cursor.userInfoId() != null; + + if (!sectionStart && !itemPosition) { + throw UserInfoErrorCode.INVALID_CURSOR.toBaseException(); + } + } + + private String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return ""; + } + return keyword.toLowerCase(Locale.ROOT); + } + + private List normalizeAcademicStatuses(List academicStatuses) { + List normalizedStatuses; + if (academicStatuses == null || academicStatuses.isEmpty()) { + normalizedStatuses = List.of(AcademicStatus.ENROLLED, AcademicStatus.GRADUATED); + } else { + normalizedStatuses = academicStatuses.stream() + .map(AcademicStatus::fromString) + .distinct() + .toList(); + } + + List statusNames = new ArrayList<>(normalizedStatuses.stream() + .map(AcademicStatus::name) + .toList()); + statusNames.sort(String::compareTo); + return List.copyOf(statusNames); + } + + private record NormalizedFilter( + String keyword, + Integer admissionYearStart, + Integer admissionYearEnd, + List academicStatuses, + SortType sortType, + String excludeUserId) { + } +} diff --git a/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/UserInfoErrorCode.java b/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/UserInfoErrorCode.java index cf8de6643..5e8ebb447 100644 --- a/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/UserInfoErrorCode.java +++ b/app-main/src/main/java/net/causw/app/main/shared/exception/errorcode/UserInfoErrorCode.java @@ -22,6 +22,7 @@ public enum UserInfoErrorCode implements BaseResponseCode { INVALID_PROJECT_START_DATE(HttpStatus.BAD_REQUEST, "USERINFO_400_006", "대표 프로젝트 시작 날짜가 올바르지 않습니다."), INVALID_PROJECT_END_DATE(HttpStatus.BAD_REQUEST, "USERINFO_400_007", "대표 프로젝트 종료 날짜가 올바르지 않습니다."), PROJECT_START_BEFORE_END(HttpStatus.BAD_REQUEST, "USERINFO_400_008", "대표 프로젝트 시작 날짜는 종료 날짜 이전이어야 합니다."), + INVALID_CURSOR(HttpStatus.BAD_REQUEST, "USERINFO_400_009", "동문수첩 리스트 조회 커서가 올바르지 않습니다."), INVALID_SORT_TYPE(HttpStatus.BAD_REQUEST, "USERINFO_400_009", "올바르지 않은 정렬 기준입니다."), USERINFO_NOT_FOUND(HttpStatus.NOT_FOUND, "USERINFO_404_001", "존재하지 않는 동문 수첩 프로필입니다."), USER_CAREER_NOT_FOUND(HttpStatus.NOT_FOUND, "USERINFO_404_002", "존재하지 않는 경력 사항입니다."), diff --git a/app-main/src/main/resources/db/migration/V20260804194808__AddCoffeeChatAvailabilityToUserInfo.sql b/app-main/src/main/resources/db/migration/V20260804194808__AddCoffeeChatAvailabilityToUserInfo.sql new file mode 100644 index 000000000..2d901db1c --- /dev/null +++ b/app-main/src/main/resources/db/migration/V20260804194808__AddCoffeeChatAvailabilityToUserInfo.sql @@ -0,0 +1,14 @@ +-- Migration: AddCoffeeChatAvailabilityToUserInfo + +-- 커피챗 허용 boolean 추가 +ALTER TABLE tb_user_info + ADD COLUMN is_coffee_chat_available boolean not null default false; + +-- 기존 전화번호 공개 null 값 default false로 변경 +UPDATE tb_user_info +SET is_phone_number_visible = b'0' +WHERE is_phone_number_visible IS NULL; + +ALTER TABLE tb_user_info + MODIFY COLUMN is_phone_number_visible BIT(1) NOT NULL DEFAULT b'0'; + diff --git a/app-main/src/test/java/net/causw/app/main/domain/user/account/service/UserInfoServiceTest.java b/app-main/src/test/java/net/causw/app/main/domain/user/account/service/UserInfoServiceTest.java index d287f25fb..1ebb7e4b4 100644 --- a/app-main/src/test/java/net/causw/app/main/domain/user/account/service/UserInfoServiceTest.java +++ b/app-main/src/test/java/net/causw/app/main/domain/user/account/service/UserInfoServiceTest.java @@ -27,6 +27,7 @@ import net.causw.app.main.domain.asset.file.service.implementation.UserProfileImageReader; import net.causw.app.main.domain.user.account.entity.user.User; import net.causw.app.main.domain.user.account.entity.userInfo.UserInfo; +import net.causw.app.main.domain.user.account.enums.user.Department; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoListCondition; import net.causw.app.main.domain.user.account.service.dto.request.UserInfoUpdateCommand; import net.causw.app.main.domain.user.account.service.dto.result.UserInfoDetailResult; @@ -75,6 +76,7 @@ void givenValidUserInfoId_whenGetDetailUserInfo_thenReturnsDetailDto() { // given String userInfoId = "ui-1"; User user = ObjectFixtures.getMockUser(); + Department department = Department.SCHOOL_OF_SW; UserInfo userInfo = ObjectFixtures.getMockUserInfo(); UserInfoDetailResult resultDto = ObjectFixtures.getMockUserInfoDetailResult(); UserProfileImage profileImage = null; @@ -82,8 +84,9 @@ void givenValidUserInfoId_whenGetDetailUserInfo_thenReturnsDetailDto() { when(userInfoReader.findById(userInfoId)).thenReturn(Optional.of(userInfo)); when(userInfo.getUser()).thenReturn(user); when(user.getId()).thenReturn("user-1"); + when(user.getDepartment()).thenReturn(department); when(userProfileImageReader.findByUserIdOrNull("user-1")).thenReturn(profileImage); - when(userInfoMapper.toDetailResult(userInfo, profileImage)).thenReturn(resultDto); + when(userInfoMapper.toDetailResult(userInfo, profileImage, department)).thenReturn(resultDto); // when UserInfoDetailResult result = userInfoService.getDetailUserInfo(userInfoId); @@ -93,7 +96,7 @@ void givenValidUserInfoId_whenGetDetailUserInfo_thenReturnsDetailDto() { verify(userInfoReader).findById(userInfoId); verify(userProfileImageReader).findByUserIdOrNull("user-1"); - verify(userInfoMapper).toDetailResult(userInfo, profileImage); + verify(userInfoMapper).toDetailResult(userInfo, profileImage, department); } @Test @@ -110,7 +113,7 @@ void givenInvalidUserInfoId_whenGetDetailUserInfo_thenThrowsNotFound() { .isEqualTo(UserInfoErrorCode.USERINFO_NOT_FOUND); verify(userInfoReader).findById(userInfoId); - verify(userInfoMapper, never()).toDetailResult(any(), any()); + verify(userInfoMapper, never()).toDetailResult(any(), any(), any()); } } @@ -124,7 +127,9 @@ void givenExistingUserInfo_whenGetMyDetailUserInfo_thenReturnsMyDetailDto() { // given String userId = "user-1"; User user = ObjectFixtures.getMockUser(); + Department department = Department.SCHOOL_OF_SW; when(user.getId()).thenReturn(userId); + when(user.getDepartment()).thenReturn(department); UserInfo userInfo = ObjectFixtures.getMockUserInfo(); UserInfoDetailResult resultDto = ObjectFixtures.getMockUserInfoDetailResult(); @@ -132,7 +137,7 @@ void givenExistingUserInfo_whenGetMyDetailUserInfo_thenReturnsMyDetailDto() { when(userInfoReader.findByUserId(userId)).thenReturn(Optional.of(userInfo)); when(userProfileImageReader.findByUserIdOrNull(userId)).thenReturn(profileImage); - when(userInfoMapper.toMyDetailResult(userInfo, profileImage)).thenReturn(resultDto); + when(userInfoMapper.toMyDetailResult(userInfo, profileImage, department)).thenReturn(resultDto); // when UserInfoDetailResult result = userInfoService.getMyDetailUserInfo(user); @@ -143,7 +148,7 @@ void givenExistingUserInfo_whenGetMyDetailUserInfo_thenReturnsMyDetailDto() { verify(userInfoReader).findByUserId(userId); verify(userInfoCreator, never()).createAndSave(any(User.class)); verify(userProfileImageReader).findByUserIdOrNull(userId); - verify(userInfoMapper).toMyDetailResult(userInfo, profileImage); + verify(userInfoMapper).toMyDetailResult(userInfo, profileImage, department); } @Test @@ -152,7 +157,9 @@ void givenNoUserInfo_whenGetMyDetailUserInfo_thenCreatesAndReturnsMyDetailDto() // given String userId = "user-1"; User user = ObjectFixtures.getMockUser(); + Department department = Department.SCHOOL_OF_SW; when(user.getId()).thenReturn(userId); + when(user.getDepartment()).thenReturn(department); UserInfo created = ObjectFixtures.getMockUserInfo(); UserInfoDetailResult resultDto = ObjectFixtures.getMockUserInfoDetailResult(); @@ -161,7 +168,7 @@ void givenNoUserInfo_whenGetMyDetailUserInfo_thenCreatesAndReturnsMyDetailDto() when(userInfoReader.findByUserId(userId)).thenReturn(Optional.empty()); when(userInfoCreator.createAndSave(user)).thenReturn(created); when(userProfileImageReader.findByUserIdOrNull(userId)).thenReturn(profileImage); - when(userInfoMapper.toMyDetailResult(created, profileImage)).thenReturn(resultDto); + when(userInfoMapper.toMyDetailResult(created, profileImage, department)).thenReturn(resultDto); // when UserInfoDetailResult result = userInfoService.getMyDetailUserInfo(user); @@ -172,7 +179,7 @@ void givenNoUserInfo_whenGetMyDetailUserInfo_thenCreatesAndReturnsMyDetailDto() verify(userInfoReader).findByUserId(userId); verify(userInfoCreator).createAndSave(user); verify(userProfileImageReader).findByUserIdOrNull(userId); - verify(userInfoMapper).toMyDetailResult(created, profileImage); + verify(userInfoMapper).toMyDetailResult(created, profileImage, department); } } @@ -186,7 +193,9 @@ void givenExistingUserInfo_whenUpdateUserInfo_thenReturnsDetailDto() { // given String userId = "user-1"; User user = ObjectFixtures.getMockUser(); + Department department = Department.SCHOOL_OF_SW; when(user.getId()).thenReturn(userId); + when(user.getDepartment()).thenReturn(department); UserInfoUpdateCommand request = ObjectFixtures.getUserInfoUpdateCommand(); @@ -198,7 +207,7 @@ void givenExistingUserInfo_whenUpdateUserInfo_thenReturnsDetailDto() { when(userInfoReader.findByUserId(userId)).thenReturn(Optional.of(existing)); when(userInfoWriter.save(existing)).thenReturn(updated); when(userProfileImageReader.findByUserIdOrNull(userId)).thenReturn(profileImage); - when(userInfoMapper.toDetailResult(updated, profileImage)).thenReturn(resultDto); + when(userInfoMapper.toDetailResult(updated, profileImage, department)).thenReturn(resultDto); doNothing().when(existing).update(any(), anyBoolean()); doNothing().when(existing).updateSocialLinks(any()); @@ -221,7 +230,7 @@ void givenExistingUserInfo_whenUpdateUserInfo_thenReturnsDetailDto() { verify(existing).updateInterestDomain(null); verify(userInfoWriter).save(existing); verify(userProfileImageReader).findByUserIdOrNull(userId); - verify(userInfoMapper).toDetailResult(updated, profileImage); + verify(userInfoMapper).toDetailResult(updated, profileImage, department); } @Test @@ -230,7 +239,9 @@ void givenNoUserInfo_whenUpdateUserInfo_thenCreatesAndReturnsDetailDto() { // given String userId = "user-1"; User user = ObjectFixtures.getMockUser(); + Department department = Department.SCHOOL_OF_SW; when(user.getId()).thenReturn(userId); + when(user.getDepartment()).thenReturn(department); UserInfoUpdateCommand request = ObjectFixtures.getUserInfoUpdateCommand(); @@ -243,7 +254,7 @@ void givenNoUserInfo_whenUpdateUserInfo_thenCreatesAndReturnsDetailDto() { when(userInfoCreator.createAndSave(user)).thenReturn(created); when(userInfoWriter.save(created)).thenReturn(updated); when(userProfileImageReader.findByUserIdOrNull(userId)).thenReturn(profileImage); - when(userInfoMapper.toDetailResult(updated, profileImage)).thenReturn(resultDto); + when(userInfoMapper.toDetailResult(updated, profileImage, department)).thenReturn(resultDto); doNothing().when(created).update(any(), anyBoolean()); doNothing().when(created).updateSocialLinks(any()); @@ -266,7 +277,7 @@ void givenNoUserInfo_whenUpdateUserInfo_thenCreatesAndReturnsDetailDto() { verify(created).updateInterestDomain(null); verify(userInfoWriter).save(created); verify(userProfileImageReader).findByUserIdOrNull(userId); - verify(userInfoMapper).toDetailResult(updated, profileImage); + verify(userInfoMapper).toDetailResult(updated, profileImage, department); } } @@ -317,4 +328,4 @@ void givenConditionAndPageNum_whenGetUserInfoPage_thenReturnsSummaryPage() { verify(userInfoMapper).toSummaryResult(u2, null); } } -} \ No newline at end of file +}