diff --git a/.gitignore b/.gitignore index 92a546e0..88bd04c8 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ src/main/resources/.env ### Firebase ### firebase-service-account.json **/firebase-service-account*.json + +### Static Test Files ### +src/main/resources/static/ diff --git a/build.gradle b/build.gradle index 76022eb2..7b1a69d1 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,9 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // JOL (Java Object Layout) - 메모리 크기 측정용 + testImplementation 'org.openjdk.jol:jol-core:0.17' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' diff --git a/src/main/java/com/example/cherrydan/activity/controller/ActivityController.java b/src/main/java/com/example/cherrydan/activity/controller/ActivityController.java index 726bbf8d..d313f795 100644 --- a/src/main/java/com/example/cherrydan/activity/controller/ActivityController.java +++ b/src/main/java/com/example/cherrydan/activity/controller/ActivityController.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -36,25 +37,37 @@ public class ActivityController { description = """ 사용자의 북마크 기반 활동 알림 목록을 조회합니다. 북마크한 캠페인의 신청 마감이 3일 남았을 때 생성되는 알림들입니다. - + **쿼리 파라미터 예시:** - ?page=0&size=20&sort=alertDate,desc - ?page=1&size=10&sort=alertDate,asc - + **정렬 가능한 필드:** - alertDate: 알림 생성 날짜 (기본값, DESC) - + **주의:** 이는 Request Body가 아닌 **Query Parameter**입니다. """ ) @GetMapping("/bookmark-alerts") - public ApiResponse> getBookmarkActivityAlerts( + public ResponseEntity>> getBookmarkActivityAlerts( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @PageableDefault(size = 20, sort = "alertDate", direction = Sort.Direction.DESC) Pageable pageable ) { Page alerts = activityAlertService.getUserActivityAlerts(currentUser.getId(), pageable); PageListResponseDTO response = PageListResponseDTO.from(alerts); - return ApiResponse.success("북마크 활동 알림 목록 조회 성공", response); + return ResponseEntity.ok(ApiResponse.success("북마크 활동 알림 목록 조회 성공", response)); + } + + @Operation( + summary = "북마크 기반 활동 알림 개수 조회", + description = "사용자의 북마크 기반 활동 알림 개수를 조회합니다." + ) + @GetMapping("/bookmark-alerts/count") + public ResponseEntity> getBookmarkActivityAlertsCount( + @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser + ) { + Long alertsCount = activityAlertService.getUserActivityAlertsCount(currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("북마크 활동 알림 개수 조회 성공", alertsCount)); } @Operation( @@ -62,12 +75,12 @@ public ApiResponse> getBookmarkAct description = "선택한 북마크 기반 활동 알림들을 삭제합니다. 본인의 알림이 아닌 경우 403 에러를 반환합니다." ) @DeleteMapping("/bookmark-alerts") - public ApiResponse deleteBookmarkActivityAlerts( + public ResponseEntity> deleteBookmarkActivityAlerts( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestBody AlertIdsRequestDTO request ) { activityAlertService.deleteActivityAlert(currentUser.getId(), request.getAlertIds()); - return ApiResponse.success("북마크 활동 알림 삭제 성공", null); + return ResponseEntity.ok(ApiResponse.success("북마크 활동 알림 삭제 성공", null)); } @Operation( @@ -75,11 +88,11 @@ public ApiResponse deleteBookmarkActivityAlerts( description = "선택한 북마크 기반 활동 알림들을 읽음 상태로 변경합니다. 본인의 알림이 아닌 경우 403 에러를 반환합니다." ) @PatchMapping("/bookmark-alerts/read") - public ApiResponse markBookmarkActivityAlertsAsRead( + public ResponseEntity> markBookmarkActivityAlertsAsRead( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestBody AlertIdsRequestDTO request ) { activityAlertService.markActivityAlertsAsRead(currentUser.getId(), request.getAlertIds()); - return ApiResponse.success("북마크 활동 알림 읽음 처리 성공", null); + return ResponseEntity.ok(ApiResponse.success("북마크 활동 알림 읽음 처리 성공", null)); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/controller/AlertController.java b/src/main/java/com/example/cherrydan/activity/controller/AlertController.java new file mode 100644 index 00000000..23241111 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/controller/AlertController.java @@ -0,0 +1,36 @@ +package com.example.cherrydan.activity.controller; + +import com.example.cherrydan.activity.dto.UnreadAlertCountResponseDTO; +import com.example.cherrydan.activity.service.AlertService; +import com.example.cherrydan.common.response.ApiResponse; +import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Alert", description = "알림 관련 API") +@RestController +@RequestMapping("/api/alerts") +@RequiredArgsConstructor +public class AlertController { + + private final AlertService alertService; + + @Operation( + summary = "미읽은 알림 개수 조회", + description = "사용자의 미읽은 알림 개수를 조회합니다. 활동 알림과 키워드 알림의 개수를 각각 제공하며, 전체 개수도 함께 반환합니다." + ) + @GetMapping("/unread-count") + public ResponseEntity> getUnreadAlertCount( + @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl user + ) { + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(user.getId()); + return ResponseEntity.ok(ApiResponse.success(result)); + } +} diff --git a/src/main/java/com/example/cherrydan/activity/domain/ActivityAlert.java b/src/main/java/com/example/cherrydan/activity/domain/ActivityAlert.java index 3c86b6a8..c3178102 100644 --- a/src/main/java/com/example/cherrydan/activity/domain/ActivityAlert.java +++ b/src/main/java/com/example/cherrydan/activity/domain/ActivityAlert.java @@ -13,6 +13,9 @@ indexes = { @Index(name = "idx_user_visible_alert_date", columnList = "user_id, is_visible_to_user, alert_date"), @Index(name = "idx_alert_stage_visible", columnList = "alert_stage, is_visible_to_user") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_activity_alert", columnNames = {"user_id", "campaign_id", "alert_type", "alert_date"}) }) @Getter @NoArgsConstructor @@ -34,6 +37,10 @@ public class ActivityAlert extends BaseTimeEntity { @Column(name = "alert_date", nullable = false) private LocalDate alertDate; + + @Enumerated(EnumType.STRING) + @Column(name = "alert_type", nullable = false, length = 50) + private ActivityAlertType alertType; @Column(name = "alert_stage", nullable = false) @Builder.Default @@ -51,11 +58,19 @@ public void markAsNotified() { this.alertStage = 1; } - public boolean isNotified() { - return alertStage > 0; - } - public void markAsRead() { this.isRead = true; } + + public void hide() { + this.isVisibleToUser = false; + } + + public String getNotificationTitle() { + return alertType.getTitle(); + } + + public String getNotificationBody() { + return alertType.getBodyTemplate(campaign.getTitle()); + } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/domain/ActivityAlertType.java b/src/main/java/com/example/cherrydan/activity/domain/ActivityAlertType.java new file mode 100644 index 00000000..054b79db --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/domain/ActivityAlertType.java @@ -0,0 +1,39 @@ +package com.example.cherrydan.activity.domain; + +import lombok.Getter; + +@Getter +public enum ActivityAlertType { + // 북마크 기반 알림 + BOOKMARK_DEADLINE_D1("마감알림", "D-1", "모집이 내일 완료됩니다. 얼른 신청해보세요."), + BOOKMARK_DEADLINE_DDAY("마감알림", "D-Day", "모집이 오늘 종료됩니다."), + + // CampaignStatus 기반 알림 + APPLY_RESULT_DDAY("공고 결과 알림", "D-Day", "선정 결과를 확인해보세요!"), + + // 선정자 방문 알림 (REGION 타입만) + SELECTED_VISIT_D3("방문알림", "D-3", "방문 마감일이 3일 남았습니다."), + SELECTED_VISIT_DDAY("방문알림", "D-Day", "오늘이 마지막 방문 기회예요!"), + + // 리뷰 작성 알림 + REVIEWING_DEADLINE_D3("리뷰 작성 알림", "D-3", "리뷰 작성이 3일 남았습니다."), + REVIEWING_DEADLINE_DDAY("리뷰 작성 알림", "D-Day", "리뷰 작성이 오늘 마감됩니다. 놓치지 말고 작성해주세요."); + + private final String category; + private final String dDayLabel; + private final String messageTemplate; + + ActivityAlertType(String category, String dDayLabel, String messageTemplate) { + this.category = category; + this.dDayLabel = dDayLabel; + this.messageTemplate = messageTemplate; + } + + public String getTitle() { + return category; + } + + public String getBodyTemplate(String campaignTitle) { + return String.format("%s %s %s", dDayLabel, campaignTitle, messageTemplate); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/domain/vo/ActivityAlertMessage.java b/src/main/java/com/example/cherrydan/activity/domain/vo/ActivityAlertMessage.java new file mode 100644 index 00000000..c5ac6fdc --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/domain/vo/ActivityAlertMessage.java @@ -0,0 +1,32 @@ +package com.example.cherrydan.activity.domain.vo; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.notification.domain.AlertMessage; + +import java.util.Map; + +public record ActivityAlertMessage(String title, + String body, + String imageUrl, + Map data) + implements AlertMessage { + private static final String TYPE = "activity_alert"; + private static final String ACTION = "open_activity_page"; + + + public static ActivityAlertMessage create(ActivityAlert activityAlert){ + Map data = Map.of( + "type", TYPE, + "alert_type", activityAlert.getAlertType().name(), + "campaign_id", String.valueOf(activityAlert.getCampaign().getId()), + "campaign_title", activityAlert.getCampaign().getTitle(), + "action", ACTION + ); + + return new ActivityAlertMessage( + activityAlert.getNotificationTitle(), + activityAlert.getNotificationBody(), + activityAlert.getCampaign().getImageUrl(), + data); + } +} diff --git a/src/main/java/com/example/cherrydan/activity/dto/ActivityAlertResponseDTO.java b/src/main/java/com/example/cherrydan/activity/dto/ActivityAlertResponseDTO.java index 1f990447..febf19c3 100644 --- a/src/main/java/com/example/cherrydan/activity/dto/ActivityAlertResponseDTO.java +++ b/src/main/java/com/example/cherrydan/activity/dto/ActivityAlertResponseDTO.java @@ -15,40 +15,41 @@ @Builder @Schema(description = "활동 알림 응답 DTO") public class ActivityAlertResponseDTO { - - @Schema(description = "알림 ID", example = "1") + + @Schema(description = "알림 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long id; - - @Schema(description = "캠페인 ID", example = "123") + + @Schema(description = "캠페인 ID", example = "123", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long campaignId; - - @Schema(description = "캠페인 제목", example = "[양주] 리치마트 양주점_피드&릴스") + + @Schema(description = "알림 타이틀", example = "방문 알림", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String alertTitle; + + @Schema(description = "dDay 알림 타이틀", example = "D-3", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String dayTitle; + + @Schema(description = "캠페인 타이틀", example = "[양주] 리치마트 양주점_피드&릴스", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignTitle; - - @Schema(description = "신청 마감일", example = "2024-07-24") - private LocalDate applyEndDate; - - @Schema(description = "알림 날짜", example = "2024-07-21") + + @Schema(description = "며칠 남았는 지", example = "피드&릴스 방문일이 3일 남았습니다.", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String alertBody; + + @Schema(description = "알림 날짜", example = "2024-07-21", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private LocalDate alertDate; - - @Schema(description = "읽음 여부", example = "false") + + @Schema(description = "읽음 여부", example = "false", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Boolean isRead; - - @Schema(description = "D-day (마감까지 남은 일수)", example = "3") - private Integer dDay; public static ActivityAlertResponseDTO fromEntity(ActivityAlert activityAlert) { - final int FIXED_D_DAY = 3; - LocalDate applyEndDate = activityAlert.getCampaign().getApplyEnd(); - return ActivityAlertResponseDTO.builder() .id(activityAlert.getId()) .campaignId(activityAlert.getCampaign().getId()) + .alertTitle(activityAlert.getAlertType().getTitle()) + .dayTitle(activityAlert.getAlertType().getDDayLabel()) .campaignTitle(activityAlert.getCampaign().getTitle()) - .applyEndDate(applyEndDate) + .alertBody(activityAlert.getAlertType().getMessageTemplate()) .alertDate(activityAlert.getAlertDate()) .isRead(activityAlert.getIsRead()) - .dDay(FIXED_D_DAY) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/dto/UnreadAlertCountResponseDTO.java b/src/main/java/com/example/cherrydan/activity/dto/UnreadAlertCountResponseDTO.java new file mode 100644 index 00000000..9d38c959 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/dto/UnreadAlertCountResponseDTO.java @@ -0,0 +1,21 @@ +package com.example.cherrydan.activity.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class UnreadAlertCountResponseDTO { + + @Schema(description = "총 미읽은 알림 개수", example = "15", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private Long totalCount; + + @Schema(description = "활동 알림 미읽은 개수", example = "8", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private Long activityAlertCount; + + @Schema(description = "키워드 알림 미읽은 개수", example = "7", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private Long keywordAlertCount; +} diff --git a/src/main/java/com/example/cherrydan/activity/repository/ActivityAlertRepository.java b/src/main/java/com/example/cherrydan/activity/repository/ActivityAlertRepository.java index bc545799..6502b162 100644 --- a/src/main/java/com/example/cherrydan/activity/repository/ActivityAlertRepository.java +++ b/src/main/java/com/example/cherrydan/activity/repository/ActivityAlertRepository.java @@ -1,15 +1,18 @@ package com.example.cherrydan.activity.repository; import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; +import java.util.Set; @Repository public interface ActivityAlertRepository extends JpaRepository { @@ -20,19 +23,34 @@ public interface ActivityAlertRepository extends JpaRepository findByUserIdAndIsVisibleToUserTrue(@Param("userId") Long userId, Pageable pageable); + /** + * 사용자의 활동 알림 개수 조회 + */ + @Query("SELECT COUNT(aa) FROM ActivityAlert aa WHERE aa.user.id = :userId AND aa.isVisibleToUser = true") + Long countByUserIdAndIsVisibleToUserTrue(@Param("userId") Long userId); /** - * 당일 생성된 알림 미발송 활동 알림들 조회 (Campaign과 User를 Fetch Join으로 함께 조회) + * 당일 생성된 알림 미발송 활동 알림들 페이징 조회 (Campaign과 User를 Fetch Join으로 함께 조회) */ @Query("SELECT aa FROM ActivityAlert aa " + "JOIN FETCH aa.campaign c " + "JOIN FETCH aa.user u " + "WHERE aa.alertStage = 0 AND aa.isVisibleToUser = true AND aa.alertDate = :alertDate") - List findTodayUnnotifiedAlerts(@Param("alertDate") LocalDate alertDate); + Page findTodayUnnotifiedAlertsWithPaging(@Param("alertDate") LocalDate alertDate, Pageable pageable); /** * 사용자와 캠페인으로 알림 존재 여부 확인 */ @Query("SELECT COUNT(aa) > 0 FROM ActivityAlert aa WHERE aa.user.id = :userId AND aa.campaign.id = :campaignId AND aa.isVisibleToUser = true") boolean existsByUserIdAndCampaignId(@Param("userId") Long userId, @Param("campaignId") Long campaignId); + + /** + * 사용자의 미읽은 활동 알림 개수 조회 + */ + @Query("SELECT COUNT(aa) FROM ActivityAlert aa WHERE aa.user.id = :userId AND aa.isRead = false AND aa.isVisibleToUser = true") + Long countUnreadByUserId(@Param("userId") Long userId); + + @Modifying + @Query("DELETE FROM ActivityAlert aa WHERE aa.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/service/ActivityAlertService.java b/src/main/java/com/example/cherrydan/activity/service/ActivityAlertService.java index ad3c1b00..209ec47b 100644 --- a/src/main/java/com/example/cherrydan/activity/service/ActivityAlertService.java +++ b/src/main/java/com/example/cherrydan/activity/service/ActivityAlertService.java @@ -1,18 +1,24 @@ package com.example.cherrydan.activity.service; import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.vo.ActivityAlertMessage; import com.example.cherrydan.activity.dto.ActivityAlertResponseDTO; import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.activity.strategy.AlertStrategy; import com.example.cherrydan.campaign.domain.Bookmark; -import com.example.cherrydan.campaign.domain.Campaign; import com.example.cherrydan.campaign.repository.BookmarkRepository; import com.example.cherrydan.common.exception.ErrorMessage; import com.example.cherrydan.common.exception.UserException; +import com.example.cherrydan.fcm.dto.NotificationRequest; +import com.example.cherrydan.fcm.dto.NotificationResultDto; +import com.example.cherrydan.fcm.service.NotificationService; +import com.example.cherrydan.user.domain.User; import com.example.cherrydan.user.dto.AlertIdsRequestDTO; import com.example.cherrydan.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,67 +28,34 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; +import java.util.Set; @Slf4j @Service @RequiredArgsConstructor public class ActivityAlertService { - + private final ActivityAlertRepository activityAlertRepository; - private final BookmarkRepository bookmarkRepository; private final UserRepository userRepository; private final ActivityProcessingService activityProcessingService; + private final List alertStrategies; + private final NotificationService notificationService; + + private static final int BATCH_SIZE = 500; /** - * 활동 알림 대상 업데이트 (북마크된 캠페인 중 apply_end가 3일 남은 것들) + * 활동 알림 대상 업데이트 (모든 Strategy 실행) */ @Transactional public void updateActivityAlerts() { - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); - LocalDate threeDaysLater = today.plusDays(3); - - // 3일 후 마감되는 활성 캠페인의 북마크들을 조회 (페치 조인으로 N+1 문제 해결) - List activeBookmarks = bookmarkRepository - .findActiveBookmarksWithCampaignAndUserByApplyEndDate(threeDaysLater); - - if (activeBookmarks.isEmpty()) { - log.info("3일 후 마감되는 북마크된 캠페인이 없습니다."); - return; - } - - - // 캠페인별로 그룹핑해서 효율적으로 처리 - Map> campaignGroups = activeBookmarks.stream() - .collect(Collectors.groupingBy(Bookmark::getCampaign)); - - // 모든 캠페인에 대해 비동기 처리 시작 (예외 처리 포함) - List>> safeFutures = campaignGroups.entrySet().stream() - .map(entry -> activityProcessingService.processCampaignAsync(entry.getKey(), entry.getValue(), today) - .exceptionally(throwable -> { - log.error("캠페인 '{}' 처리 실패: {}", entry.getKey().getTitle(), throwable.getMessage()); - return new ArrayList<>(); - })) - .toList(); - - // 모든 비동기 작업 완료 대기 - CompletableFuture.allOf(safeFutures.toArray(new CompletableFuture[0])).join(); - - // 결과 수집 - List alertsToSave = safeFutures.stream() - .map(CompletableFuture::join) - .flatMap(List::stream) - .collect(Collectors.toList()); - - // 벌크 저장으로 성능 최적화 - if (!alertsToSave.isEmpty()) { - activityAlertRepository.saveAll(alertsToSave); - log.info("벌크 저장 완료: {}개 활동 알림", alertsToSave.size()); - } + + alertStrategies.forEach(strategy -> + activityProcessingService.processBatchAlertsAsync(strategy, today) + ); - log.info("=== 활동 알림 업데이트 작업 완료: 총 {}개 알림 생성 ===", alertsToSave.size()); + log.info("활동 알림 생성 작업 시작 - {} 개 전략 실행", alertStrategies.size()); + // 메서드 즉시 반환 } /** @@ -90,53 +63,82 @@ public void updateActivityAlerts() { */ @Transactional public void sendActivityNotifications() { - + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); - List unnotifiedAlerts = activityAlertRepository.findTodayUnnotifiedAlerts(today); - - if (unnotifiedAlerts.isEmpty()) { - log.info("발송할 활동 알림이 없습니다."); - return; - } - - // 캠페인별로 그룹핑 (같은 캠페인 = 같은 메시지) - Map> groupedByCampaign = unnotifiedAlerts.stream() - .collect(Collectors.groupingBy(ActivityAlert::getCampaign)); - - // 캠페인별 병렬 알림 발송 (예외 처리 포함) - List>> futures = groupedByCampaign.entrySet().stream() - .map(entry -> activityProcessingService.sendActivityNotificationAsync(entry.getKey(), entry.getValue()) - .exceptionally(throwable -> { - log.error("캠페인 '{}' 알림 발송 실패: {}", entry.getKey().getTitle(), throwable.getMessage()); - return new ArrayList<>(); - })) - .toList(); - - // 모든 비동기 작업 완료 대기 - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - - // 결과 수집 - List allAlertsToUpdate = futures.stream() - .map(CompletableFuture::join) - .flatMap(List::stream) - .collect(Collectors.toList()); - - int totalSentCount = allAlertsToUpdate.size(); - - // 성공한 알림들 상태 업데이트 - if (totalSentCount > 0) { + + // 페이징 설정 + Pageable pageable = PageRequest.of(0, BATCH_SIZE); + Page page; + int totalSentCount = 0; + int totalProcessed = 0; + + log.info("=== 활동 알림 발송 시작 ==="); + + // 페이지 단위로 처리 + do { + page = activityAlertRepository.findTodayUnnotifiedAlertsWithPaging(today, pageable); + + if (page.isEmpty()) { + log.info("발송할 활동 알림이 없습니다."); + break; + } + + // 배치 처리 및 즉시 상태 업데이트 + int batchSentCount = processBatchNotifications(page.getContent()); + totalSentCount += batchSentCount; + totalProcessed += page.getNumberOfElements(); + + log.info("배치 처리 완료: {} / {} 건 발송 성공", batchSentCount, page.getNumberOfElements()); + + // 다음 페이지로 이동 + pageable = page.nextPageable(); + + } while (page.hasNext()); + + log.info("=== 활동 알림 발송 완료: 총 {} / {} 건 발송 ===", totalSentCount, totalProcessed); + } + + /** + * 배치 단위 알림 발송 및 상태 업데이트 + * @return 성공적으로 발송된 알림 개수 + */ + private int processBatchNotifications(List batch) { + ArrayList successfulAlerts = new ArrayList<>(); + + for (ActivityAlert alert : batch) { try { - allAlertsToUpdate.forEach(ActivityAlert::markAsNotified); - activityAlertRepository.saveAll(allAlertsToUpdate); - - log.info("알림 상태 벌크 업데이트 완료: 성공한 알림 {}개", allAlertsToUpdate.size()); - + ActivityAlertMessage activityAlertMessage = ActivityAlertMessage.create(alert); + + // 개별 알림 발송 (ActivityAlert가 이미 모든 정보를 가지고 있음) + NotificationRequest request = NotificationRequest.create(activityAlertMessage); + + // 사용자에게 발송 + NotificationResultDto result = notificationService.sendNotificationToUsers( + List.of(alert.getUser().getId()), request); + + if (result.getSuccessCount() > 0) { + // 성공 시 즉시 상태 업데이트 + alert.markAsNotified(); + successfulAlerts.add(alert); + + log.debug("알림 발송 성공: userId={}, alertType={}, campaignId={}", + alert.getUser().getId(), alert.getAlertType(), alert.getCampaign().getId()); + } else { + log.debug("알림 발송 실패: userId={}, alertType={}, campaignId={}", + alert.getUser().getId(), alert.getAlertType(), alert.getCampaign().getId()); + } + } catch (Exception e) { - log.error("알림 상태 업데이트 실패: {}", e.getMessage()); + log.error("알림 발송 중 오류: userId={}, alertId={}, error={}", + alert.getUser().getId(), alert.getId(), e.getMessage()); } } - - log.info("=== 활동 알림 발송 완료: 총 {}건 발송 ===", totalSentCount); + + if (!successfulAlerts.isEmpty()){ + activityAlertRepository.saveAll(successfulAlerts); + } + + return successfulAlerts.size(); } /** @@ -149,21 +151,32 @@ public Page getUserActivityAlerts(Long userId, Pageabl } /** - * 활동 알림 삭제 (배열) + * 사용자의 활동 알림 개수 조회 (페이지네이션) + */ + @Transactional(readOnly = true) + public Long getUserActivityAlertsCount(Long userId) { + return activityAlertRepository.countByUserIdAndIsVisibleToUserTrue(userId); + } + + + /** + * 활동 알림 삭제 (소프트 삭제) */ @Transactional public void deleteActivityAlert(Long userId, List alertIds) { List alerts = activityAlertRepository.findAllById(alertIds); - + // 모든 알림이 해당 사용자의 것인지 확인 for (ActivityAlert alert : alerts) { - if (!alert.getUser().getId().equals(userId)) { + User user = alert.getUser(); + if (user == null || !user.getId().equals(userId)) { throw new UserException(ErrorMessage.ACTIVITY_ALERT_ACCESS_DENIED); } + alert.hide(); } - - activityAlertRepository.deleteAll(alerts); - log.info("활동 알림 삭제 완료: userId={}, count={}", userId, alertIds.size()); + + activityAlertRepository.saveAll(alerts); + log.info("활동 알림 숨김 처리 완료: userId={}, count={}", userId, alertIds.size()); } /** diff --git a/src/main/java/com/example/cherrydan/activity/service/ActivityProcessingService.java b/src/main/java/com/example/cherrydan/activity/service/ActivityProcessingService.java index ee89e630..93e7958c 100644 --- a/src/main/java/com/example/cherrydan/activity/service/ActivityProcessingService.java +++ b/src/main/java/com/example/cherrydan/activity/service/ActivityProcessingService.java @@ -1,6 +1,7 @@ package com.example.cherrydan.activity.service; import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.strategy.AlertStrategy; import com.example.cherrydan.campaign.domain.Bookmark; import com.example.cherrydan.campaign.domain.Campaign; import com.example.cherrydan.activity.repository.ActivityAlertRepository; @@ -9,6 +10,7 @@ import com.example.cherrydan.fcm.dto.NotificationResultDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,7 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -27,124 +30,87 @@ public class ActivityProcessingService { private final ActivityAlertRepository activityAlertRepository; - private final NotificationService notificationService; + + private static final int BATCH_SIZE = 500; /** - * 캠페인별 활동 알림 생성 및 처리 (비동기) + * 배치 처리 방식으로 알림 생성 */ - @Async("keywordTaskExecutor") + @Async("alertTaskExecutor") @Transactional - public CompletableFuture> processCampaignAsync( - Campaign campaign, List bookmarks, LocalDate today) { - - List results = new ArrayList<>(); - int successCount = 0; - int failureCount = 0; - + public CompletableFuture processBatchAlertsAsync( + AlertStrategy strategy, LocalDate today) { + + String strategyName = strategy.getClass().getSimpleName(); + List batch = new ArrayList<>(BATCH_SIZE); + int totalProcessed = 0; + int totalSkipped = 0; + long startTime = System.currentTimeMillis(); + try { - // 이미 알림이 생성된 사용자들은 제외 - for (Bookmark bookmark : bookmarks) { - try { - // 이미 해당 사용자-캠페인에 대한 알림이 있는지 확인 - if (activityAlertRepository.existsByUserIdAndCampaignId( - bookmark.getUser().getId(), campaign.getId())) { - continue; // 이미 알림이 있으면 스킵 - } - - // TODO: 추후 필요시 푸시 알림 정책 체크 추가 - // 현재는 북마크한 모든 사용자에게 알림 생성 (발송 시에 푸시 설정 확인) + log.info("[{}] 배치 처리 시작", strategyName); + + // Iterator 패턴으로 스트리밍 처리 + Iterator iterator = strategy.generateAlertsIterator(today); + while (iterator.hasNext()) { + batch.add(iterator.next()); - ActivityAlert alert = ActivityAlert.builder() - .user(bookmark.getUser()) - .campaign(campaign) - .alertDate(today) - .build(); - - results.add(alert); - successCount++; - - } catch (Exception e) { - failureCount++; - log.error("사용자 {} 캠페인 {} 활동 알림 생성 실패: {}", - bookmark.getUser().getId(), campaign.getId(), e.getMessage()); + if (batch.size() >= BATCH_SIZE) { + BatchResult result = saveBatchWithDuplicateHandling(batch); + totalProcessed += result.processed(); + totalSkipped += result.skipped(); + batch.clear(); + + log.debug("[{}] 배치 저장: {} 건 처리, {} 건 스킵", + strategyName, result.processed(), result.skipped()); } } - - log.info("캠페인 '{}' 활동 알림 처리 완료: 성공 {}건, 실패 {}건", - campaign.getTitle(), successCount, failureCount); - - return CompletableFuture.completedFuture(results); - + + // 남은 배치 처리 + if (!batch.isEmpty()) { + BatchResult result = saveBatchWithDuplicateHandling(batch); + totalProcessed += result.processed(); + totalSkipped += result.skipped(); + } + + long elapsed = System.currentTimeMillis() - startTime; + + log.info("[{}] 완료: {} 건 처리, {} 건 중복 스킵 (소요시간: {}ms)", + strategyName, totalProcessed, totalSkipped, elapsed); + } catch (Exception e) { - log.error("캠페인 '{}' 활동 알림 처리 중 예외 발생: {}", campaign.getTitle(), e.getMessage(), e); - throw e; + log.error("[{}] 실패: {}", strategyName, e.getMessage(), e); } - } - /** - * 캠페인별 활동 알림 발송 (비동기) - */ - @Async("keywordTaskExecutor") - @Transactional - public CompletableFuture> sendActivityNotificationAsync( - Campaign campaign, List alerts) { - - List successAlerts = new ArrayList<>(); - int successCount = 0; - int failureCount = 0; + return CompletableFuture.completedFuture(null); + } + + private record BatchResult(int processed, int skipped) {} + + private BatchResult saveBatchWithDuplicateHandling(List batch) { + int processed = 0; + int skipped = 0; try { - // D-day 계산 - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); - long dDay = ChronoUnit.DAYS.between(today, campaign.getApplyEnd()); - - // 알림 메시지 구성 - String title = "신청 마감 알림"; - String body = String.format("D-%d %s 신청이 %d일 남았습니다", - dDay, campaign.getTitle(), dDay); - - NotificationRequest request = NotificationRequest.builder() - .title(title) - .body(body) - .data(java.util.Map.of( - "type", "activity_reminder", - "campaign_id", String.valueOf(campaign.getId()), - "campaign_title", campaign.getTitle(), - "days_remaining", String.valueOf(dDay), - "action", "open_activity_page" - )) - .priority("high") - .build(); - - // 같은 캠페인을 북마크한 사용자들에게 단체 발송 - List userIds = alerts.stream() - .map(alert -> alert.getUser().getId()) - .collect(Collectors.toList()); + activityAlertRepository.saveAllAndFlush(batch); + processed = batch.size(); - NotificationResultDto result = notificationService.sendNotificationToUsers(userIds, request); + } catch (DataIntegrityViolationException e) { + // 중복 발생 시 개별 저장으로 폴백 + log.debug("중복 감지, 개별 저장 모드로 전환"); - // 성공한 사용자들의 알림만 반환 - if (result.getSuccessfulUserIds() != null && !result.getSuccessfulUserIds().isEmpty()) { - successAlerts = alerts.stream() - .filter(alert -> result.getSuccessfulUserIds().contains(alert.getUser().getId())) - .collect(Collectors.toList()); - successCount = successAlerts.size(); - - log.info("활동 알림 발송 성공: 캠페인={}, 성공 사용자 수={}", - campaign.getTitle(), successCount); + for (ActivityAlert alert : batch) { + try { + activityAlertRepository.save(alert); + processed++; + } catch (DataIntegrityViolationException ignored) { + // DB unique constraint가 중복 방지 + skipped++; + } } - - failureCount = alerts.size() - successCount; - - log.info("캠페인 '{}' 활동 알림 발송 완료: 성공 {}건, 실패 {}건", - campaign.getTitle(), successCount, failureCount); - - return CompletableFuture.completedFuture(successAlerts); - - } catch (Exception e) { - log.error("캠페인 '{}' 활동 알림 발송 중 예외 발생: {}", campaign.getTitle(), e.getMessage(), e); - throw e; } + + return new BatchResult(processed, skipped); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/service/AlertService.java b/src/main/java/com/example/cherrydan/activity/service/AlertService.java new file mode 100644 index 00000000..cf06bcbc --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/service/AlertService.java @@ -0,0 +1,33 @@ +package com.example.cherrydan.activity.service; + +import com.example.cherrydan.activity.dto.UnreadAlertCountResponseDTO; +import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AlertService { + + private final ActivityAlertRepository activityAlertRepository; + private final KeywordCampaignAlertRepository keywordCampaignAlertRepository; + + /** + * 사용자의 미읽은 알림 개수 조회 + */ + @Transactional(readOnly = true) + public UnreadAlertCountResponseDTO getUnreadAlertCount(Long userId) { + Long activityCount = activityAlertRepository.countUnreadByUserId(userId); + Long keywordCount = keywordCampaignAlertRepository.countUnreadByUserId(userId); + + return UnreadAlertCountResponseDTO.builder() + .totalCount(activityCount + keywordCount) + .activityAlertCount(activityCount) + .keywordAlertCount(keywordCount) + .build(); + } +} diff --git a/src/main/java/com/example/cherrydan/activity/strategy/AlertStrategy.java b/src/main/java/com/example/cherrydan/activity/strategy/AlertStrategy.java new file mode 100644 index 00000000..17a49cb0 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/strategy/AlertStrategy.java @@ -0,0 +1,13 @@ +package com.example.cherrydan.activity.strategy; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import java.time.LocalDate; +import java.util.Iterator; + +public interface AlertStrategy { + /** + * Iterator 방식으로 알림 생성 (메모리 효율적) + * 대량 데이터 처리 시 메모리 사용량을 최소화 + */ + Iterator generateAlertsIterator(LocalDate today); +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/strategy/ApplyResultAlertStrategy.java b/src/main/java/com/example/cherrydan/activity/strategy/ApplyResultAlertStrategy.java new file mode 100644 index 00000000..46a869e5 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/strategy/ApplyResultAlertStrategy.java @@ -0,0 +1,36 @@ +package com.example.cherrydan.activity.strategy; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.common.util.PagedAlertIterator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Iterator; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ApplyResultAlertStrategy implements AlertStrategy { + + private final CampaignStatusRepository campaignStatusRepository; + + @Override + public Iterator generateAlertsIterator(LocalDate today) { + return new PagedAlertIterator<>( + page -> campaignStatusRepository.findByStatusAndReviewerAnnouncementDate( + CampaignStatusType.APPLY, today, page), + status -> ActivityAlert.builder() + .user(status.getUser()) + .campaign(status.getCampaign()) + .alertType(ActivityAlertType.APPLY_RESULT_DDAY) + .alertDate(today) + .build() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/strategy/BookmarkAlertStrategy.java b/src/main/java/com/example/cherrydan/activity/strategy/BookmarkAlertStrategy.java new file mode 100644 index 00000000..99f4d1a1 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/strategy/BookmarkAlertStrategy.java @@ -0,0 +1,48 @@ +package com.example.cherrydan.activity.strategy; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.campaign.domain.Bookmark; +import com.example.cherrydan.campaign.repository.BookmarkRepository; +import com.example.cherrydan.common.util.CompositeAlertIterator; +import com.example.cherrydan.common.util.PagedAlertIterator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Iterator; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BookmarkAlertStrategy implements AlertStrategy { + + private final BookmarkRepository bookmarkRepository; + + @Override + public Iterator generateAlertsIterator(LocalDate today) { + return new CompositeAlertIterator( + new PagedAlertIterator<>( + page -> bookmarkRepository.findActiveBookmarksByApplyEndDate( + today.plusDays(1), page), // D-1 + bookmark -> createAlert(bookmark, ActivityAlertType.BOOKMARK_DEADLINE_D1, today) + ), + new PagedAlertIterator<>( + page -> bookmarkRepository.findActiveBookmarksByApplyEndDate( + today, page), // D-Day + bookmark -> createAlert(bookmark, ActivityAlertType.BOOKMARK_DEADLINE_DDAY, today) + ) + ); + } + + private ActivityAlert createAlert(Bookmark bookmark, ActivityAlertType type, LocalDate date) { + return ActivityAlert.builder() + .user(bookmark.getUser()) + .campaign(bookmark.getCampaign()) + .alertType(type) + .alertDate(date) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/strategy/ReviewingAlertStrategy.java b/src/main/java/com/example/cherrydan/activity/strategy/ReviewingAlertStrategy.java new file mode 100644 index 00000000..b7841b4d --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/strategy/ReviewingAlertStrategy.java @@ -0,0 +1,48 @@ +package com.example.cherrydan.activity.strategy; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.common.util.CompositeAlertIterator; +import com.example.cherrydan.common.util.PagedAlertIterator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Iterator; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReviewingAlertStrategy implements AlertStrategy { + + private final CampaignStatusRepository campaignStatusRepository; + + @Override + public Iterator generateAlertsIterator(LocalDate today) { + return new CompositeAlertIterator( + new PagedAlertIterator<>( + page -> campaignStatusRepository.findReviewingCampaignsByReviewEndDate( + today.plusDays(3), page), // D-3 + status -> createAlert(status, ActivityAlertType.REVIEWING_DEADLINE_D3, today) + ), + new PagedAlertIterator<>( + page -> campaignStatusRepository.findReviewingCampaignsByReviewEndDate( + today, page), // D-Day + status -> createAlert(status, ActivityAlertType.REVIEWING_DEADLINE_DDAY, today) + ) + ); + } + + private ActivityAlert createAlert(CampaignStatus status, ActivityAlertType type, LocalDate date) { + return ActivityAlert.builder() + .user(status.getUser()) + .campaign(status.getCampaign()) + .alertType(type) + .alertDate(date) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/activity/strategy/SelectedVisitAlertStrategy.java b/src/main/java/com/example/cherrydan/activity/strategy/SelectedVisitAlertStrategy.java new file mode 100644 index 00000000..eca14888 --- /dev/null +++ b/src/main/java/com/example/cherrydan/activity/strategy/SelectedVisitAlertStrategy.java @@ -0,0 +1,49 @@ +package com.example.cherrydan.activity.strategy; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.domain.CampaignType; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.common.util.CompositeAlertIterator; +import com.example.cherrydan.common.util.PagedAlertIterator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Iterator; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SelectedVisitAlertStrategy implements AlertStrategy { + + private final CampaignStatusRepository campaignStatusRepository; + + @Override + public Iterator generateAlertsIterator(LocalDate today) { + return new CompositeAlertIterator( + new PagedAlertIterator<>( + page -> campaignStatusRepository.findSelectedRegionCampaignsByVisitEndDate( + today.plusDays(3), page), + status -> createAlert(status, ActivityAlertType.SELECTED_VISIT_D3, today) + ), + new PagedAlertIterator<>( + page -> campaignStatusRepository.findSelectedRegionCampaignsByVisitEndDate( + today, page), + status -> createAlert(status, ActivityAlertType.SELECTED_VISIT_DDAY, today) + ) + ); + } + + private ActivityAlert createAlert(CampaignStatus status, ActivityAlertType type, LocalDate date) { + return ActivityAlert.builder() + .user(status.getUser()) + .campaign(status.getCampaign()) + .alertType(type) + .alertDate(date) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/controller/BookmarkController.java b/src/main/java/com/example/cherrydan/campaign/controller/BookmarkController.java index 507f782a..9a3adead 100644 --- a/src/main/java/com/example/cherrydan/campaign/controller/BookmarkController.java +++ b/src/main/java/com/example/cherrydan/campaign/controller/BookmarkController.java @@ -1,28 +1,27 @@ package com.example.cherrydan.campaign.controller; +import com.example.cherrydan.campaign.dto.BookmarkDeleteDTO; +import com.example.cherrydan.campaign.dto.BookmarkCancelDTO; import com.example.cherrydan.campaign.dto.BookmarkResponseDTO; +import com.example.cherrydan.campaign.domain.BookmarkCase; import com.example.cherrydan.campaign.service.BookmarkService; import com.example.cherrydan.common.response.ApiResponse; import com.example.cherrydan.common.response.PageListResponseDTO; import com.example.cherrydan.common.response.EmptyResponse; +import com.example.cherrydan.common.exception.CampaignException; +import com.example.cherrydan.common.exception.ErrorMessage; import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import com.example.cherrydan.common.response.ApiResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.PageRequest; -import com.example.cherrydan.common.response.PageListResponseDTO; -import com.example.cherrydan.campaign.dto.BookmarkSplitResponseDTO; +import jakarta.validation.Valid; @Tag(name = "Bookmark", description = "캠페인 북마크(찜) 관련 API") @RestController @@ -41,49 +40,56 @@ public ResponseEntity> addBookmark( return ResponseEntity.ok(ApiResponse.success("북마크 추가 성공")); } - @Operation(summary = "북마크 취소", description = "캠페인 북마크(찜)를 취소합니다. (is_active=0)") - @PatchMapping("/{campaignId}/bookmark") + @Operation(summary = "북마크 취소", description = "여러 캠페인 북마크(찜)를 한 번에 취소합니다. (is_active=0)") + @PatchMapping("/bookmark") public ResponseEntity> cancelBookmark( - @Parameter(description = "캠페인 ID", required = true) @PathVariable Long campaignId, + @Parameter(description = "북마크 취소 요청", required = true) @Valid @RequestBody BookmarkCancelDTO request, @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser ) { - bookmarkService.cancelBookmark(currentUser.getId(), campaignId); + bookmarkService.cancelBookmarks(currentUser.getId(), request); return ResponseEntity.ok(ApiResponse.success("북마크 취소 성공")); } @Operation( - summary = "오늘+기간 남은 북마크 목록 조회", - description = "오늘 이후 reviewerAnnouncement가 남아있는 북마크 목록을 조회합니다." + summary = "북마크 목록 조회", + description = "case 파라미터(likedOpen/likedClosed)로 북마크 목록을 조회합니다. likedOpen: 기간 남은 북마크, likedClosed: 기간 지난 북마크" ) - @GetMapping("/bookmarks/open") - public ResponseEntity>> getOpenBookmarks( - @AuthenticationPrincipal UserDetailsImpl currentUser, - Pageable pageable + @GetMapping("/bookmarks") + public ResponseEntity>> getBookmarksByCase( + @Parameter(description = "북마크 케이스 (likedOpen: 기간 남은 북마크, likedClosed: 기간 지난 북마크)") + @RequestParam(value = "case", defaultValue = "likedOpen") String caseParam, + @Parameter(description = "정렬 기준 createdAt", example = "createdAt") + @RequestParam(defaultValue = "createdAt") String sort, + @Parameter(description = "페이지 번호", example = "0") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal UserDetailsImpl currentUser ) { - PageListResponseDTO result = bookmarkService.getOpenBookmarks(currentUser.getId(), pageable); - return ResponseEntity.ok(ApiResponse.success("기간 남은 북마크 목록 조회 성공", result)); - } + BookmarkCase bookmarkCase; + try { + bookmarkCase = BookmarkCase.fromCode(caseParam.trim()); + } catch (IllegalArgumentException e) { + throw new CampaignException(ErrorMessage.CAMPAIGN_STATUS_INVALID); + } - @Operation( - summary = "기간 지난 북마크 목록 조회", - description = "오늘 이전 reviewerAnnouncement가 지난 북마크 목록을 조회합니다." - ) - @GetMapping("/bookmarks/closed") - public ResponseEntity>> getClosedBookmarks( - @AuthenticationPrincipal UserDetailsImpl currentUser, - Pageable pageable - ) { - PageListResponseDTO result = bookmarkService.getClosedBookmarks(currentUser.getId(), pageable); - return ResponseEntity.ok(ApiResponse.success("기간 지난 북마크 목록 조회 성공", result)); + Pageable pageable = createPageable(page, size, sort); + PageListResponseDTO result = bookmarkService.getBookmarksByCase(currentUser.getId(), bookmarkCase, pageable); + String message = bookmarkCase == BookmarkCase.LIKED_OPEN ? "신청 가능한 공고 목록 조회 성공" : "신청 마감된 공고 목록 조회 성공"; + return ResponseEntity.ok(ApiResponse.success(message, result)); } @Operation(summary = "북마크 완전 삭제", description = "캠페인 북마크(찜) 정보를 완전히 삭제합니다.") - @DeleteMapping("/{campaignId}/bookmark") + @DeleteMapping("/bookmark") public ResponseEntity> deleteBookmark( - @Parameter(description = "캠페인 ID", required = true) @PathVariable Long campaignId, + @Parameter(description = "북마크 삭제 요청", required = true) @Valid @RequestBody BookmarkDeleteDTO request, @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser ) { - bookmarkService.deleteBookmark(currentUser.getId(), campaignId); + bookmarkService.deleteBookmark(currentUser.getId(), request); return ResponseEntity.ok(ApiResponse.success("북마크 삭제 성공")); } + + private Pageable createPageable(int page, int size, String sort) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sort)); + } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/controller/CampaignStatusController.java b/src/main/java/com/example/cherrydan/campaign/controller/CampaignStatusController.java index 8616787a..e7b3230e 100644 --- a/src/main/java/com/example/cherrydan/campaign/controller/CampaignStatusController.java +++ b/src/main/java/com/example/cherrydan/campaign/controller/CampaignStatusController.java @@ -2,10 +2,9 @@ import com.example.cherrydan.campaign.dto.CampaignStatusRequestDTO; import com.example.cherrydan.campaign.dto.CampaignStatusResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusListResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusPopupResponseDTO; import com.example.cherrydan.campaign.dto.CampaignStatusCountResponseDTO; -import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.domain.CampaignStatusCase; +import com.example.cherrydan.common.response.EmptyResponse; import com.example.cherrydan.common.response.PageListResponseDTO; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -24,6 +23,11 @@ import jakarta.validation.constraints.NotNull; import com.example.cherrydan.common.exception.CampaignException; import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.campaign.dto.CampaignStatusBatchRequestDTO; +import com.example.cherrydan.campaign.dto.CampaignStatusDeleteRequestDTO; +import com.example.cherrydan.campaign.dto.CampaignStatusPopupByTypeResponseDTO; + +import java.util.List; @Tag(name = "CampaignStatus", description = "내 체험단 상태 관리 및 팝업 API") @RestController @@ -32,32 +36,25 @@ public class CampaignStatusController { private final CampaignStatusService campaignStatusService; - @Operation(summary = "내 체험단 상태별 목록 조회", description = "status 파라미터(APPLY/SELECTED/NOT_SELECTED/REVIEWING/ENDED) 기준 페이지네이션, APPLY 상태의 경우 subFilter(waiting/completed)로 기한 남은/지난 공고 필터링 가능") + @Operation(summary = "내 체험단 상태별 목록 조회", + description = "case 파라미터(appliedCompleted/appliedWaiting/resultSelected/resultNotSelected/reviewInProgress/reviewCompleted) 기준 페이지네이션") @GetMapping - public ResponseEntity>> getMyStatusesByType( - @RequestParam(value = "status", defaultValue = "APPLY") String status, - @RequestParam(value = "subFilter", required = false) String subFilter, + public ResponseEntity>> getMyStatusesByCase( + @RequestParam(value = "case", defaultValue = "appliedCompleted") String caseParam, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @AuthenticationPrincipal UserDetailsImpl currentUser ) { - CampaignStatusType statusType; + CampaignStatusCase statusCase; + + System.out.println("caseParam: " + caseParam); try { - statusType = CampaignStatusType.valueOf(status.trim().toUpperCase()); + statusCase = CampaignStatusCase.fromCode(caseParam.trim()); } catch (IllegalArgumentException e) { throw new CampaignException(ErrorMessage.CAMPAIGN_STATUS_INVALID); } - - // APPLY 상태에서 subFilter가 주어졌다면 waiting/completed만 허용 - if (statusType == CampaignStatusType.APPLY && subFilter != null && !subFilter.trim().isEmpty()) { - String sf = subFilter.trim().toLowerCase(); - if (!sf.equals("waiting") && !sf.equals("completed")) { - throw new CampaignException(ErrorMessage.CAMPAIGN_STATUS_SUBFILTER_INVALID); - } - } - Pageable pageable = PageRequest.of(page, size); - PageListResponseDTO result = campaignStatusService.getStatusesByType(currentUser.getId(), statusType, subFilter, pageable); + PageListResponseDTO result = campaignStatusService.getStatusesByCase(currentUser.getId(), statusCase, pageable); return ResponseEntity.ok(ApiResponse.success(result)); } @@ -76,45 +73,36 @@ public ResponseEntity> createOrRecoverSta @Valid @RequestBody CampaignStatusRequestDTO requestDTO, @AuthenticationPrincipal UserDetailsImpl currentUser ) { - requestDTO.setUserId(currentUser.getId()); - CampaignStatusResponseDTO result = campaignStatusService.createOrRecoverStatus(requestDTO); + CampaignStatusResponseDTO result = campaignStatusService.createOrRecoverStatus(requestDTO, currentUser.getId()); return ResponseEntity.ok(ApiResponse.success(result)); } - @Operation(summary = "내 체험단 상태 변경", description = "is_active or status 변경") + @Operation(summary = "내 체험단 상태 변경", description = "배치로 여러 캠페인 상태 변경") @PatchMapping - public ResponseEntity> updateStatus( - @Valid @RequestBody CampaignStatusRequestDTO requestDTO, + public ResponseEntity>> updateStatus( + @Valid @RequestBody CampaignStatusBatchRequestDTO requestDTO, @AuthenticationPrincipal UserDetailsImpl currentUser ) { - requestDTO.setUserId(currentUser.getId()); - CampaignStatusResponseDTO result = campaignStatusService.updateStatus(requestDTO); - return ResponseEntity.ok(ApiResponse.success(result)); + List results = campaignStatusService.updateStatusBatch(requestDTO, currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success(results)); } - @Operation(summary = "내 체험단 상태 삭제", description = "campaignId만 받아서 삭제") + @Operation(summary = "내 체험단 상태 삭제", description = "campaignIds 리스트로 일괄 삭제") @DeleteMapping - public ResponseEntity> deleteStatus( - @Valid @RequestBody DeleteRequest request, + public ResponseEntity> deleteStatus( + @Valid @RequestBody CampaignStatusDeleteRequestDTO request, @AuthenticationPrincipal UserDetailsImpl currentUser ) { - campaignStatusService.deleteStatus(request.getCampaignId(), currentUser.getId()); - return ResponseEntity.ok(ApiResponse.success()); + campaignStatusService.deleteStatusBatch(request, currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("체험단 상태 삭제 성공")); } - @Operation(summary = "내 체험단 노출 팝업 조회", description = "지원한 공고/선정 결과/리뷰 작성 중 상태 중 기간이 지난 데이터만 최대 4개씩, 각 상태별 총 개수와 함께 반환") + @Operation(summary = "내 체험단 노출 팝업 조회", description = "활성 관심공고 전체 반환 (마감일 순으로 정렬)") @GetMapping("/popup") - public ResponseEntity> getPopupStatus( - @AuthenticationPrincipal UserDetailsImpl currentUser + public ResponseEntity> getPopupStatus( + @AuthenticationPrincipal UserDetailsImpl currentUser ) { - CampaignStatusPopupResponseDTO result = campaignStatusService.getPopupStatusByUser(currentUser.getId()); - return ResponseEntity.ok(ApiResponse.success(result)); - } - - public static class DeleteRequest { - @NotNull(message = "캠페인 ID는 필수입니다.") - private Long campaignId; - - public Long getCampaignId() { return campaignId; } + CampaignStatusPopupByTypeResponseDTO result = campaignStatusService.getPopupStatusByBookmark(currentUser.getId()); + return ResponseEntity.ok(ApiResponse.success("팝업 상태 조회 성공", result)); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/domain/Bookmark.java b/src/main/java/com/example/cherrydan/campaign/domain/Bookmark.java index c6154ba9..6f6bb4b8 100644 --- a/src/main/java/com/example/cherrydan/campaign/domain/Bookmark.java +++ b/src/main/java/com/example/cherrydan/campaign/domain/Bookmark.java @@ -37,4 +37,8 @@ public class Bookmark extends BaseTimeEntity { public void setIsActive(Boolean isActive) { this.isActive = isActive; } -} \ No newline at end of file + + public void activate() { + this.isActive = true; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/domain/BookmarkCase.java b/src/main/java/com/example/cherrydan/campaign/domain/BookmarkCase.java new file mode 100644 index 00000000..83e1834a --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/domain/BookmarkCase.java @@ -0,0 +1,40 @@ +package com.example.cherrydan.campaign.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "북마크 케이스 타입") +@Getter +public enum BookmarkCase { + LIKED_OPEN("likedOpen", "신청 가능한 공고"), + LIKED_CLOSED("likedClosed", "신청 마감된 공고"); + + private final String code; + private final String label; + + BookmarkCase(String code, String label) { + this.code = code; + this.label = label; + } + + public String getCode() { + return code; + } + + public String getLabel() { + return label; + } + + /** + * 코드로 BookmarkCase 찾기 + */ + public static BookmarkCase fromCode(String code) { + for (BookmarkCase bookmarkCase : values()) { + if (bookmarkCase.code.equalsIgnoreCase(code)) { + return bookmarkCase; + } + } + throw new IllegalArgumentException("Invalid BookmarkCase code: " + code); + } +} + diff --git a/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatus.java b/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatus.java index 467eb64a..7fd125f2 100644 --- a/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatus.java +++ b/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatus.java @@ -9,7 +9,12 @@ import java.time.temporal.ChronoUnit; @Entity -@Table(name = "campaign_status") +@Table( + name = "campaign_status", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "campaign_id"}) + } +) @Getter @Setter @Builder @@ -33,7 +38,7 @@ public class CampaignStatus extends BaseTimeEntity { @Builder.Default @Column(nullable = false) - private Boolean isActive = true; // 이건 캠페인이 종료되면 false로 할 듯 + private Boolean isActive = true; /** * 활동 알림 발송 여부 @@ -58,55 +63,12 @@ public class CampaignStatus extends BaseTimeEntity { @Builder.Default @Column(name = "is_visible_to_user", nullable = false) private Boolean isVisibleToUser = true; - - /** - * 활동 알림 대상인지 확인 (3일 이내 마감) - */ - public boolean isActivityEligible() { - Integer daysRemaining = getDaysRemaining(); - return daysRemaining != null && daysRemaining >= 0 && daysRemaining <= 3; - } - - /** - * 활동 마감까지 남은 일수 계산 - */ - public Integer getDaysRemaining() { - LocalDate targetDate = getActivityTargetDate(); - if (targetDate == null) return null; - - long days = ChronoUnit.DAYS.between(LocalDate.now(), targetDate); - return (int) days; - } - - /** - * 활동 목표 날짜 계산 (상태에 따라) - */ - public LocalDate getActivityTargetDate() { - if (campaign == null) return null; - - switch (this.status) { - case APPLY: - return campaign.getReviewerAnnouncement(); - case SELECTED: - case REVIEWING: - return campaign.getContentSubmissionEnd(); - default: - return null; - } - } - - /** - * 활동 알림 발송 완료 표시 - */ - public void markActivityAsNotified() { - this.activityNotified = true; - this.activityNotifiedAt = LocalDateTime.now(); + + public void activate(){ + this.isActive = true; } - - /** - * 활동 알림 읽음 처리 - */ - public void markAsRead() { - this.isRead = true; + + public void updateStatus(CampaignStatusType newStatus){ + this.status = newStatus; } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatusCase.java b/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatusCase.java new file mode 100644 index 00000000..1c21b8e3 --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/domain/CampaignStatusCase.java @@ -0,0 +1,57 @@ +package com.example.cherrydan.campaign.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캠페인 상태 케이스 타입") +@Getter +public enum CampaignStatusCase { + APPLIED_COMPLETED("appliedCompleted", "결과 발표 완료"), + APPLIED_WAITING("appliedWaiting", "발표 기다리는중"), + RESULT_SELECTED("resultSelected", "선정된 공고"), + RESULT_NOT_SELECTED("resultNotSelected", "선정되지 않은 공고"), + REVIEW_IN_PROGRESS("reviewInProgress", "리뷰 작성할 공고"), + REVIEW_COMPLETED("reviewCompleted", "리뷰 작성 완료한 공고"); + + private final String code; + private final String label; + + CampaignStatusCase(String code, String label) { + this.code = code; + this.label = label; + } + + public String getCode() { + return code; + } + + public String getLabel() { + return label; + } + + /** + * 코드로 CampaignStatusCase 찾기 + */ + public static CampaignStatusCase fromCode(String code) { + for (CampaignStatusCase statusCase : values()) { + if (statusCase.code.equalsIgnoreCase(code)) { + return statusCase; + } + } + throw new IllegalArgumentException("Invalid CampaignStatusCase code: " + code); + } + + /** + * CampaignStatusCase를 CampaignStatusType으로 변환 + */ + public CampaignStatusType toStatusType() { + return switch (this) { + case APPLIED_WAITING, APPLIED_COMPLETED -> CampaignStatusType.APPLY; + case RESULT_SELECTED -> CampaignStatusType.SELECTED; + case RESULT_NOT_SELECTED -> CampaignStatusType.NOT_SELECTED; + case REVIEW_IN_PROGRESS -> CampaignStatusType.REVIEWING; + case REVIEW_COMPLETED -> CampaignStatusType.ENDED; + }; + } +} + diff --git a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkCancelDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkCancelDTO.java new file mode 100644 index 00000000..20797551 --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkCancelDTO.java @@ -0,0 +1,18 @@ +package com.example.cherrydan.campaign.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +@Getter +@NoArgsConstructor +@Schema(description = "북마크 취소 요청 DTO") +public class BookmarkCancelDTO { + + @NotEmpty(message = "캠페인 ID 목록은 필수입니다.") + @Schema(description = "취소할 캠페인 ID 목록", example = "[1, 2, 3]", required = true) + private List campaignIds; +} diff --git a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkDeleteDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkDeleteDTO.java new file mode 100644 index 00000000..9cde3539 --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkDeleteDTO.java @@ -0,0 +1,19 @@ +package com.example.cherrydan.campaign.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + + +@Getter +@NoArgsConstructor +@Schema(description = "북마크 삭제 요청 DTO") +public class BookmarkDeleteDTO { + + @NotEmpty(message = "캠페인 ID 목록은 필수입니다.") + @Schema(description = "북마크 삭제 DTO ", example = "[1, 2, 3]") + private List campaignIds; +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkRequestDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkRequestDTO.java deleted file mode 100644 index 042a94e6..00000000 --- a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkRequestDTO.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.cherrydan.campaign.dto; - -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -public class BookmarkRequestDTO { - private Long userId; -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkResponseDTO.java index 61557d42..4f7a113d 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkResponseDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkResponseDTO.java @@ -12,6 +12,7 @@ import java.util.List; import org.springframework.beans.factory.annotation.Value; import com.example.cherrydan.common.util.CloudfrontUtil; +import io.swagger.v3.oas.annotations.media.Schema; @Getter @Builder @@ -19,15 +20,17 @@ public class BookmarkResponseDTO { private Long id; private Long campaignId; private Long userId; + private String reviewerAnnouncementStatus; + @Schema(description = "상태 보조 라벨 (형식 통일을 위한 placeholder 필드, 항상 null)", nullable = true) + private String subStatusLabel; private String campaignTitle; + private String benefit; private String campaignDetailUrl; private String campaignImageUrl; private String campaignPlatformImageUrl; - private String benefit; private Integer applicantCount; private Integer recruitCount; private List snsPlatforms; - private String reviewerAnnouncementStatus; private String campaignSite; public static BookmarkResponseDTO fromEntity(Bookmark bookmark) { @@ -45,7 +48,7 @@ public static BookmarkResponseDTO fromEntity(Bookmark bookmark) { .applicantCount(campaign.getApplicantCount()) .recruitCount(campaign.getRecruitCount()) .snsPlatforms(getPlatforms(campaign)) - .reviewerAnnouncementStatus(getReviewerAnnouncementStatus(campaign.getReviewerAnnouncement())) + .reviewerAnnouncementStatus(getReviewerAnnouncementStatus(campaign.getApplyEnd())) .campaignSite(getCampaignSiteLabel(campaign.getSourceSite())) .build(); } @@ -73,7 +76,7 @@ public static String getReviewerAnnouncementStatus(LocalDate applyEnd) { } else if (days < 0) { return "모집이 종료되었어요"; } else { - return "오늘이 마감일!"; + return "D-Day"; } } diff --git a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkSplitResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/BookmarkSplitResponseDTO.java deleted file mode 100644 index df263eb9..00000000 --- a/src/main/java/com/example/cherrydan/campaign/dto/BookmarkSplitResponseDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.cherrydan.campaign.dto; - -import lombok.Builder; -import lombok.Getter; -import java.util.List; -import org.springframework.data.domain.Page; -import com.example.cherrydan.common.response.PageListResponseDTO; - -@Getter -@Builder -public class BookmarkSplitResponseDTO { - private PageListResponseDTO open; - private PageListResponseDTO closed; -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignResponseDTO.java index 6adb5b11..30c5baca 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignResponseDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignResponseDTO.java @@ -17,19 +17,35 @@ @Getter @Builder public class CampaignResponseDTO { + @Schema(description = "캠페인 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long id; + + @Schema(description = "캠페인 제목", example = "[양주] 리치마트 양주점_피드&릴스", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String title; + + @Schema(description = "캠페인 상세 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String detailUrl; + + @Schema(description = "혜택 정보", example = "5만원 상당 체험권 제공", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String benefit; + + @Schema(description = "마감 상태 메시지", example = "신청 마감 3일 전", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String reviewerAnnouncementStatus; + + @Schema(description = "신청자 수", example = "150", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Integer applicantCount; + + @Schema(description = "모집 인원", example = "10", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Integer recruitCount; @Deprecated - @Schema(description = "플랫폼 이름(deprecated(v1.0.2까지))", deprecated = true) + @Schema(description = "플랫폼 이름(deprecated)", deprecated = true, nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("campaignSite") private String sourceSite; + + @Schema(description = "캠페인 이미지 URL", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String imageUrl; + @JsonIgnore private Boolean youtube; @JsonIgnore private Boolean shorts; @JsonIgnore private Boolean insta; @@ -39,16 +55,27 @@ public class CampaignResponseDTO { @JsonIgnore private Boolean tiktok; @JsonIgnore private Boolean thread; @JsonIgnore private Boolean etc; + + @Schema(description = "북마크 여부", example = "false", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Boolean isBookmarked; @Deprecated - @Schema(description = "기존 플랫폼 이미지 URL(deprecated(v1.0.2까지))", deprecated = true) + @Schema(description = "기존 플랫폼 이미지 URL(deprecated)", deprecated = true, nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String campaignPlatformImageUrl; - + + @Schema(description = "캠페인 플랫폼 이미지 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteUrl; + + @Schema(description = "캠페인 플랫폼 한글명", example = "레뷰", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteKr; + + @Schema(description = "캠페인 플랫폼 영문 코드", example = "revu", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteEn; + + @Schema(description = "캠페인 타입", example = "DELIVERY", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private CampaignType campaignType; + + @Schema(description = "경쟁률", example = "15.0", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Float competitionRate; @JsonProperty("snsPlatforms") diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignSiteResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignSiteResponseDTO.java index d684dc62..87e20f89 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignSiteResponseDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignSiteResponseDTO.java @@ -15,18 +15,23 @@ public class CampaignSiteResponseDTO { @Deprecated - @Schema(description = "기존 플랫폼 한글명(deprecated(v1.0.2까지))", deprecated = true) + @Schema(description = "기존 플랫폼 한글명(deprecated)", deprecated = true, nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String siteNameKr; @Deprecated - @Schema(description = "기존 플랫폼 영문명(deprecated(v1.0.2까지))", deprecated = true) + @Schema(description = "기존 플랫폼 영문명(deprecated)", deprecated = true, nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String siteNameEn; @Deprecated - @Schema(description = "기존 플랫폼 CDN URL(deprecated(v1.0.2까지))", deprecated = true) + @Schema(description = "기존 플랫폼 CDN URL(deprecated)", deprecated = true, nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String cdnUrl; + @Schema(description = "캠페인 플랫폼 이미지 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteUrl; + + @Schema(description = "캠페인 플랫폼 한글명", example = "레뷰", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteKr; + + @Schema(description = "캠페인 플랫폼 영문 코드", example = "revu", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignSiteEn; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusBatchRequestDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusBatchRequestDTO.java new file mode 100644 index 00000000..6c627388 --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusBatchRequestDTO.java @@ -0,0 +1,27 @@ +package com.example.cherrydan.campaign.dto; + +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "캠페인 상태 배치 업데이트 요청") +public class CampaignStatusBatchRequestDTO { + + @NotEmpty(message = "캠페인 ID 목록은 필수입니다.") + @Schema(description = "업데이트할 캠페인 ID 목록", example = "[1, 2, 3]", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private List campaignIds; + + @Schema(description = "변경할 캠페인 상태", example = "SELECTED", allowableValues = {"APPLY", "SELECTED", "NOT_SELECTED", "REVIEWING", "ENDED"}, nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private CampaignStatusType status; + + @Schema(description = "활성 상태 여부", example = "true", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private Boolean isActive; +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusCountResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusCountResponseDTO.java index 799d6f75..b18032b3 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusCountResponseDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusCountResponseDTO.java @@ -8,16 +8,18 @@ @Builder @Schema(description = "내 체험단 상태별 카운트 응답") public class CampaignStatusCountResponseDTO { - @Schema(description = "지원한 공고 개수") - private long apply; - @Schema(description = "선정 결과 개수") - private long selected; - @Schema(description = "미선정 결과 개수") - private long notSelected; - @Schema(description = "리뷰 작성 중 개수") - private long reviewing; - @Schema(description = "작성 완료 개수") - private long ended; + @Schema(description = "결과 발표 완료 개수") + private long appliedCompleted; + @Schema(description = "발표 기다리는 중 개수") + private long appliedWaiting; + @Schema(description = "선정된 공고 개수") + private long resultSelected; + @Schema(description = "선정되지 않은 공고 개수") + private long resultNotSelected; + @Schema(description = "리뷰 작성할 공고 개수") + private long reviewInProgress; + @Schema(description = "리뷰 작성 완료한 공고 개수") + private long reviewCompleted; } diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusDeleteRequestDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusDeleteRequestDTO.java new file mode 100644 index 00000000..99e98a7c --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusDeleteRequestDTO.java @@ -0,0 +1,20 @@ +package com.example.cherrydan.campaign.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "캠페인 상태 배치 삭제 요청") +public class CampaignStatusDeleteRequestDTO { + + @NotEmpty(message = "캠페인 ID 목록은 필수입니다.") + @Schema(description = "삭제할 캠페인 ID 목록", example = "[1, 2, 3]", required = true) + private List campaignIds; +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupByTypeResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupByTypeResponseDTO.java new file mode 100644 index 00000000..01305967 --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupByTypeResponseDTO.java @@ -0,0 +1,19 @@ +package com.example.cherrydan.campaign.dto; + +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import java.util.List; + +@Getter +@Builder +@Schema(description = "특정 상태의 내 체험단 팝업용 응답 DTO") +public class CampaignStatusPopupByTypeResponseDTO { + + @Schema(description = "총 개수", example = "4", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private long totalCount; + + @Schema(description = "팝업 아이템 리스트 (최대 4개)", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private List items; +} diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupItemDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupItemDTO.java index 06bb9c76..f0e6f2c6 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupItemDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupItemDTO.java @@ -1,5 +1,8 @@ package com.example.cherrydan.campaign.dto; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.Bookmark; + import lombok.Builder; import lombok.Getter; @@ -11,9 +14,8 @@ public class CampaignStatusPopupItemDTO { private String imageUrl; private String reviewerAnnouncementStatus; private String benefit; - private String statusLabel; - public static CampaignStatusPopupItemDTO fromEntity(com.example.cherrydan.campaign.domain.CampaignStatus status) { + public static CampaignStatusPopupItemDTO fromEntity(CampaignStatus status) { String reviewerAnnouncementStatus = null; switch (status.getStatus()) { case APPLY: @@ -22,9 +24,6 @@ public static CampaignStatusPopupItemDTO fromEntity(com.example.cherrydan.campai case SELECTED: reviewerAnnouncementStatus = CampaignStatusResponseDTO.getStatusMessage(status.getCampaign().getContentSubmissionEnd(), "selected"); break; - case NOT_SELECTED: - reviewerAnnouncementStatus = CampaignStatusResponseDTO.getStatusMessage(status.getCampaign().getContentSubmissionEnd(), "not_selected"); - break; case REVIEWING: reviewerAnnouncementStatus = CampaignStatusResponseDTO.getStatusMessage(status.getCampaign().getContentSubmissionEnd(), "reviewing"); break; @@ -40,7 +39,20 @@ public static CampaignStatusPopupItemDTO fromEntity(com.example.cherrydan.campai .imageUrl(status.getCampaign().getImageUrl()) .reviewerAnnouncementStatus(reviewerAnnouncementStatus) .benefit(status.getCampaign().getBenefit()) - .statusLabel(status.getStatus().getLabel()) + .build(); + } + + public static CampaignStatusPopupItemDTO fromBookmark(Bookmark bookmark) { + String reviewerAnnouncementStatus = CampaignStatusResponseDTO.getStatusMessage( + bookmark.getCampaign().getApplyEnd(), + "bookmark" + ); + return CampaignStatusPopupItemDTO.builder() + .campaignId(bookmark.getCampaign().getId()) + .title(bookmark.getCampaign().getTitle()) + .imageUrl(bookmark.getCampaign().getImageUrl()) + .reviewerAnnouncementStatus(reviewerAnnouncementStatus) + .benefit(bookmark.getCampaign().getBenefit()) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupResponseDTO.java deleted file mode 100644 index c05502ed..00000000 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusPopupResponseDTO.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.cherrydan.campaign.dto; - -import java.util.List; - -import lombok.Builder; -import lombok.Getter; -import io.swagger.v3.oas.annotations.media.Schema; - -@Getter -@Builder -@Schema(description = "내 체험단 팝업용 상태 응답 DTO") -public class CampaignStatusPopupResponseDTO { - @Schema(description = "신청 상태 총 개수", example = "4") - private long applyTotal; - @Schema(description = "선정 상태 총 개수", example = "2") - private long selectedTotal; - @Schema(description = "리뷰 작성 중 상태 총 개수", example = "1") - private long reviewingTotal; - @Schema(description = "신청 상태 리스트") - private List apply; - @Schema(description = "선정 상태 리스트") - private List selected; - @Schema(description = "리뷰 작성 중 상태 리스트") - private List reviewing; -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusRequestDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusRequestDTO.java index b5267a34..2dd7f49a 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusRequestDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusRequestDTO.java @@ -3,7 +3,6 @@ import com.example.cherrydan.campaign.domain.CampaignStatusType; import lombok.Getter; import lombok.Setter; -import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -11,11 +10,13 @@ @Setter public class CampaignStatusRequestDTO { @NotNull(message = "캠페인 ID는 필수입니다.") + @Schema(description = "캠페인 ID", example = "123", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long campaignId; - @JsonIgnore - private Long userId; + @NotNull(message = "상태는 필수입니다.") - @Schema(description = "캠페인 상태 타입 (APPLY: 지원한 공고, SELECTED: 선정 결과, NOT_SELECTED: 미선정 결과, REVIEWING: 리뷰 작성 중, ENDED: 작성 완료)", example = "APPLY", allowableValues = {"APPLY", "SELECTED", "NOT_SELECTED", "REVIEWING", "ENDED"}) + @Schema(description = "캠페인 상태 타입 (APPLY: 지원한 공고, SELECTED: 선정 결과, NOT_SELECTED: 미선정 결과, REVIEWING: 리뷰 작성 중, ENDED: 작성 완료)", example = "APPLY", allowableValues = {"APPLY", "SELECTED", "NOT_SELECTED", "REVIEWING", "ENDED"}, nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private CampaignStatusType status; + + @Schema(description = "활성 상태 여부", example = "true", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Boolean isActive; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusResponseDTO.java b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusResponseDTO.java index 30f29493..c360de82 100644 --- a/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusResponseDTO.java +++ b/src/main/java/com/example/cherrydan/campaign/dto/CampaignStatusResponseDTO.java @@ -9,6 +9,7 @@ import com.example.cherrydan.common.util.CloudfrontUtil; import com.example.cherrydan.campaign.dto.BookmarkResponseDTO; import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; import lombok.Builder; import lombok.Getter; @@ -17,20 +18,48 @@ @Builder @Schema(description = "캠페인 상태 응답 DTO") public class CampaignStatusResponseDTO { + @Schema(description = "캠페인 상태 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long id; + + @Schema(description = "캠페인 ID", example = "123", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long campaignId; + + @Schema(description = "사용자 ID", example = "456", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long userId; + + @Schema(description = "마감 상태 메시지", example = "발표 3일 전", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String reviewerAnnouncementStatus; - private String statusLabel; - private String title; + + @Schema(description = "상태 보조 라벨 (예: APPLY의 경우 waiting/completed)", example = "waiting", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String subStatusLabel; + + @Schema(description = "캠페인 제목", example = "[양주] 리치마트 양주점_피드&릴스", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String campaignTitle; + + @Schema(description = "혜택 정보", example = "5만원 상당 체험권 제공", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String benefit; - private String detailUrl; - private String imageUrl; + + @Schema(description = "캠페인 상세 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String campaignDetailUrl; + + @Schema(description = "캠페인 이미지 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) + private String campaignImageUrl; + + @Schema(description = "캠페인 플랫폼 이미지 URL", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String campaignPlatformImageUrl; + + @Schema(description = "신청자 수", example = "150", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private int applicantCount; + + @Schema(description = "모집 인원", example = "10", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private int recruitCount; + + @Schema(description = "SNS 플랫폼 목록", example = "[\"인스타그램\", \"블로그\"]", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private List snsPlatforms; + + @Schema(description = "캠페인 사이트명", example = "레뷰", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String campaignSite; + @JsonIgnore private LocalDate reviewerAnnouncement; @JsonIgnore private LocalDate contentSubmissionEnd; @JsonIgnore private LocalDate resultAnnouncement; @@ -84,17 +113,26 @@ public static CampaignStatusResponseDTO fromEntity(CampaignStatus status) { } String campaignPlatformImageUrl = CloudfrontUtil.getCampaignPlatformImageUrl(status.getCampaign().getSourceSite()); + + // 보조 라벨: APPLY 상태일 때 발표일 기준 open/close 구분 + String subStatusLabel = null; + if (status.getStatus() == CampaignStatusType.APPLY) { + LocalDate ann = status.getCampaign().getReviewerAnnouncement(); + if (ann != null) { + subStatusLabel = ann.isAfter(LocalDate.now()) ? "waiting" : "completed"; + } + } return CampaignStatusResponseDTO.builder() .id(status.getId()) .campaignId(status.getCampaign().getId()) .userId(status.getUser().getId()) - .statusLabel(status.getStatus().getLabel()) - .title(status.getCampaign().getTitle()) - .detailUrl(status.getCampaign().getDetailUrl()) - .imageUrl(status.getCampaign().getImageUrl()) + .campaignTitle(status.getCampaign().getTitle()) + .campaignDetailUrl(status.getCampaign().getDetailUrl()) + .campaignImageUrl(status.getCampaign().getImageUrl()) .campaignPlatformImageUrl(campaignPlatformImageUrl) .reviewerAnnouncement(status.getCampaign().getReviewerAnnouncement()) .reviewerAnnouncementStatus(reviewerAnnouncementStatus) + .subStatusLabel(subStatusLabel) .applicantCount(status.getCampaign().getApplicantCount()) .recruitCount(status.getCampaign().getRecruitCount()) .snsPlatforms(BookmarkResponseDTO.getPlatforms(status.getCampaign())) diff --git a/src/main/java/com/example/cherrydan/campaign/repository/BookmarkRepository.java b/src/main/java/com/example/cherrydan/campaign/repository/BookmarkRepository.java index 36fed9a3..621e07f1 100644 --- a/src/main/java/com/example/cherrydan/campaign/repository/BookmarkRepository.java +++ b/src/main/java/com/example/cherrydan/campaign/repository/BookmarkRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDate; @@ -22,20 +23,18 @@ public interface BookmarkRepository extends JpaRepository { boolean existsByUserIdAndCampaignIdAndIsActiveTrue(Long userId, Long campaignId); List findAllByUserIdAndIsActiveTrue(Long userId); - // 원격에서 추가된 메서드들 (ReviewerAnnouncement -> ApplyEnd로 변경) Page findByUserIdAndIsActiveTrueAndCampaign_ApplyEndGreaterThanEqual(Long userId, LocalDate date, Pageable pageable); - Page findByUserIdAndIsActiveTrueAndCampaign_ApplyEndLessThan(Long userId, LocalDate date, Pageable pageable); + Page findByUserIdAndIsActiveTrueAndCampaign_ApplyEndBetween( + Long userId, + LocalDate startDate, + LocalDate endDate, + Pageable pageable + ); /** - * 특정 날짜에 마감되는 활성 캠페인의 북마크들을 조회 (페치 조인 포함) + * 특정 사용자의 여러 캠페인 ID에 대한 북마크를 조회 */ - @Query("SELECT b FROM Bookmark b " + - "JOIN FETCH b.campaign c " + - "JOIN FETCH b.user u " + - "WHERE b.isActive = true " + - "AND c.isActive = true " + - "AND c.applyEnd = :applyEndDate") - List findActiveBookmarksWithCampaignAndUserByApplyEndDate(@Param("applyEndDate") LocalDate applyEndDate); + List findByUserAndCampaignIdIn(User user, List campaignIds); /** * 특정 사용자가 북마크한 캠페인 ID들을 벌크 조회 (N+1 문제 해결) @@ -45,4 +44,44 @@ public interface BookmarkRepository extends JpaRepository { "AND b.campaign.id IN :campaignIds " + "AND b.isActive = true") Set findBookmarkedCampaignIds(@Param("userId") Long userId, @Param("campaignIds") List campaignIds); + + /** + * 특정 사용자의 여러 캠페인 북마크를 벌크 삭제 + */ + @Modifying + @Query("DELETE FROM Bookmark b WHERE b.user = :user AND b.campaign.id IN :campaignIds") + void deleteByUserAndCampaignIds(@Param("user") User user, @Param("campaignIds") List campaignIds); + + /** + * 마감 D-1, D-day 북마크 조회 (페이징, 알림 허용 사용자만) + */ + @Query("SELECT DISTINCT b FROM Bookmark b " + + "JOIN FETCH b.campaign c " + + "JOIN FETCH b.user u " + + "JOIN UserFCMToken ud ON ud.userId = u.id " + + "WHERE c.applyEnd = :applyEndDate " + + "AND b.isActive = true " + + "AND c.isActive = true " + + "AND u.isActive = true " + + "AND ud.isAllowed = true " + + "AND ud.isActive = true") + Page findActiveBookmarksByApplyEndDate(@Param("applyEndDate") LocalDate applyEndDate, Pageable pageable); + + @Modifying + @Query("DELETE FROM Bookmark b WHERE b.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); + + /** + * 팝업용: 사용자의 활성 관심공고 전체 조회 + * 마감일이 오늘 이후인 것만 조회하여 마감일 순으로 정렬 + */ + @Query("SELECT b FROM Bookmark b " + + "JOIN FETCH b.campaign c " + + "JOIN FETCH b.user u " + + "WHERE b.user.id = :userId " + + "AND b.isActive = true " + + "AND c.isActive = true " + + "AND c.applyEnd >= :today " + + "ORDER BY c.applyEnd DESC") + List findActiveBookmarksByUserForPopup(@Param("userId") Long userId, @Param("today") LocalDate today); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/repository/CampaignRepository.java b/src/main/java/com/example/cherrydan/campaign/repository/CampaignRepository.java index a3a9377b..3cc35924 100644 --- a/src/main/java/com/example/cherrydan/campaign/repository/CampaignRepository.java +++ b/src/main/java/com/example/cherrydan/campaign/repository/CampaignRepository.java @@ -41,28 +41,103 @@ public interface CampaignRepository extends JpaRepository, JpaSp @Query(value = "SELECT * FROM campaigns WHERE MATCH(title) AGAINST(:keyword IN BOOLEAN MODE) and is_active = 1 GROUP BY title ORDER BY competition_rate LIMIT 20", nativeQuery = true) List searchByTitleFullText(@Param("keyword") String keyword); - // 키워드 맞춤형 캠페인 FULLTEXT 검색 (지정 날짜 기준 전일) + /** + * 키워드 맞춤형 캠페인 FULLTEXT 검색 (지정 날짜 기준 전일) + * + * @deprecated Simple LIKE 방식(findByKeywordSimpleLike)으로 대체되었습니다. + * 성능: 희귀 키워드 280ms → 20ms, 흔한 키워드 180ms → 43ms + * STRAIGHT_JOIN 방식은 복잡하고 FULLTEXT 인덱스 활용도가 낮습니다. + */ + @Deprecated @Query(value = """ - SELECT * FROM campaigns - WHERE MATCH(title) AGAINST(:keyword IN BOOLEAN MODE) - AND is_active = 1 - AND DATE(created_at) = DATE(:date - INTERVAL 1 DAY) - ORDER BY created_at DESC + SELECT + c.* + FROM ( + SELECT + id, created_at + FROM campaigns FORCE INDEX(campaigns_created_at_is_active_IDX) + WHERE created_at >= DATE(:date - INTERVAL 1 DAY) AND created_at < DATE(:date) + AND is_active = 1 + ) AS filtered + STRAIGHT_JOIN campaigns AS c ON c.id = filtered.id + WHERE MATCH(c.title) AGAINST(:keyword IN BOOLEAN MODE) + ORDER BY filtered.created_at LIMIT :offset, :limit """, nativeQuery = true) List findByKeywordFullText( - @Param("keyword") String keyword, + @Param("keyword") String keyword, @Param("date") LocalDate date, - @Param("offset") int offset, + @Param("offset") int offset, @Param("limit") int limit ); - - // 지정 날짜 기준 전일 생성된 키워드 맞춤형 캠페인 개수 + + /** + * 지정 날짜 기준 전일 생성된 키워드 맞춤형 캠페인 개수 + * + * @deprecated Simple LIKE 방식(countByKeywordSimpleLike)으로 대체되었습니다. + */ + @Deprecated @Query(value = """ - SELECT COUNT(*) FROM campaigns - WHERE MATCH(title) AGAINST(:keyword IN BOOLEAN MODE) - AND is_active = 1 - AND DATE(created_at) = DATE(:date - INTERVAL 1 DAY) + SELECT + COUNT(*) + FROM ( + SELECT + id + FROM campaigns FORCE INDEX(campaigns_created_at_is_active_IDX) + WHERE created_at >= DATE(:date - INTERVAL 1 DAY) AND created_at < DATE(:date) + AND is_active = 1 + ) AS filtered + STRAIGHT_JOIN campaigns AS c ON c.id = filtered.id + WHERE MATCH(c.title) AGAINST(:keyword IN BOOLEAN MODE) """, nativeQuery = true) long countByKeywordAndCreatedDate(@Param("keyword") String keyword, @Param("date") LocalDate date); + + // 단순 LIKE (가장 빠른 방식) + @Query(value = """ + SELECT c.* + FROM campaigns c + WHERE c.is_active = 1 + AND c.created_at >= DATE(:date - INTERVAL 1 DAY) + AND c.created_at < DATE(:date) + AND c.title LIKE CONCAT('%', :keyword, '%') + ORDER BY c.created_at + LIMIT :offset, :limit + """, nativeQuery = true) + List findByKeywordSimpleLike( + @Param("keyword") String keyword, + @Param("date") LocalDate date, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT COUNT(*) + FROM campaigns c + WHERE c.is_active = 1 + AND c.created_at >= DATE(:date - INTERVAL 1 DAY) + AND c.created_at < DATE(:date) + AND c.title LIKE CONCAT('%', :keyword, '%') + """, nativeQuery = true) + long countByKeywordSimpleLike(@Param("keyword") String keyword, @Param("date") LocalDate date); + + @Query(value = """ + SELECT c.* + FROM campaigns_daily_search cds + INNER JOIN campaigns c ON c.id = cds.id + WHERE MATCH(cds.title) AGAINST(:keyword IN BOOLEAN MODE) + ORDER BY cds.created_at + LIMIT :offset, :limit + """, nativeQuery = true) + List searchDailyCampaignsByFulltext( + @Param("keyword") String keyword, + @Param("offset") int offset, + @Param("limit") int limit + ); + + @Query(value = """ + SELECT COUNT(*) + FROM campaigns_daily_search cds + WHERE MATCH(cds.title) AGAINST(:keyword IN BOOLEAN MODE) + """, nativeQuery = true) + long countDailyCampaignsByFulltext(@Param("keyword") String keyword); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/repository/CampaignStatusRepository.java b/src/main/java/com/example/cherrydan/campaign/repository/CampaignStatusRepository.java index 1c36deeb..1a86b18c 100644 --- a/src/main/java/com/example/cherrydan/campaign/repository/CampaignStatusRepository.java +++ b/src/main/java/com/example/cherrydan/campaign/repository/CampaignStatusRepository.java @@ -4,9 +4,12 @@ import com.example.cherrydan.campaign.domain.Campaign; import com.example.cherrydan.user.domain.User; import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.domain.CampaignStatusCase; +import com.example.cherrydan.campaign.domain.CampaignType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; @@ -20,53 +23,287 @@ public interface CampaignStatusRepository extends JpaRepository findByUserAndCampaign(User user, Campaign campaign); - Page findByUserAndStatusAndIsActiveTrue(User user, CampaignStatusType status, Pageable pageable); + /** + * 상태별 조회 - 캠페인 발표일(reviewerAnnouncement) 최신순 정렬 + */ + @Query("SELECT cs FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "ORDER BY c.reviewerAnnouncement DESC") + Page findByUserAndStatusAndIsActiveTrue(@Param("user") User user, @Param("status") CampaignStatusType status, Pageable pageable); long countByUserAndStatusAndIsActiveTrue(User user, CampaignStatusType status); + + /** + * 특정 사용자의 여러 캠페인 상태를 벌크 업데이트 (isActive와 status 둘 다) + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE CampaignStatus cs SET cs.isActive = :isActive, cs.status = :status " + + "WHERE cs.user = :user AND cs.campaign.id IN :campaignIds") + void updateStatusAndActiveBatch(@Param("user") User user, @Param("campaignIds") List campaignIds, + @Param("isActive") Boolean isActive, @Param("status") CampaignStatusType status); + + /** + * 특정 사용자의 여러 캠페인 상태를 벌크 업데이트 (status만) + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE CampaignStatus cs SET cs.status = :status " + + "WHERE cs.user = :user AND cs.campaign.id IN :campaignIds") + void updateStatusOnlyBatch(@Param("user") User user, @Param("campaignIds") List campaignIds, + @Param("status") CampaignStatusType status); + + /** + * 특정 사용자의 여러 캠페인 상태를 벌크 삭제 + */ + @Modifying + @Query("DELETE FROM CampaignStatus cs WHERE cs.user = :user AND cs.campaign.id IN :campaignIds") + void deleteByUserAndCampaignIds(@Param("user") User user, @Param("campaignIds") List campaignIds); + + /** + * 벌크 업데이트된 상태들을 조회 (N+1 문제 방지를 위한 JOIN FETCH) + */ + @Query("SELECT cs FROM CampaignStatus cs JOIN FETCH cs.campaign " + + "WHERE cs.user = :user AND cs.campaign.id IN :campaignIds") + List findByUserAndCampaignIds(@Param("user") User user, @Param("campaignIds") List campaignIds); + + @Query("SELECT cs FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND (" + + " (:status = 0 AND c.reviewerAnnouncement IS NOT NULL AND c.reviewerAnnouncement <= :today) OR " + + " (:status = 1 AND c.contentSubmissionEnd IS NOT NULL AND c.contentSubmissionEnd <= :today) OR " + + " (:status = 3 AND c.contentSubmissionEnd IS NOT NULL AND c.contentSubmissionEnd <= :today) OR " + + " (:status = 4 AND c.resultAnnouncement IS NOT NULL AND c.resultAnnouncement <= :today)" + + ") " + + "ORDER BY " + + " CASE WHEN :status = 0 THEN c.reviewerAnnouncement END DESC, " + + " CASE WHEN :status = 1 THEN c.contentSubmissionEnd END DESC, " + + " CASE WHEN :status = 3 THEN c.contentSubmissionEnd END DESC, " + + " CASE WHEN :status = 4 THEN c.resultAnnouncement END DESC") + List findTop4ByUserAndStatusAndExpired(@Param("user") User user, @Param("status") CampaignStatusType status, @Param("today") LocalDate today); + + /** + * 결과 발표일 APPLY 상태 조회 (페이징, 알림 허용 사용자만) + */ + @Query("SELECT cs FROM CampaignStatus cs " + + "JOIN FETCH cs.campaign c " + + "JOIN FETCH cs.user u " + + "WHERE cs.status = :status " + + "AND c.reviewerAnnouncement = :date " + + "AND cs.isActive = true " + + "AND u.isActive = true " + + "AND EXISTS (" + + " SELECT 1 FROM UserFCMToken ud " + + " WHERE ud.userId = u.id " + + " AND ud.isAllowed = true " + + " AND ud.isActive = true" + + ")") + Page findByStatusAndReviewerAnnouncementDate( + @Param("status") CampaignStatusType status, + @Param("date") LocalDate date, + Pageable pageable); + + /** + * SELECTED + REGION 타입 방문 마감 조회 (페이징, 알림 허용 사용자만) + */ + @Query("SELECT cs FROM CampaignStatus cs " + + "JOIN FETCH cs.campaign c " + + "JOIN FETCH cs.user u " + + "WHERE cs.status = :selectedStatus " + + "AND c.campaignType = :regionType " + + "AND c.contentSubmissionEnd = :visitEndDate " + + "AND cs.isActive = true " + + "AND u.isActive = true " + + "AND EXISTS (" + + " SELECT 1 FROM UserFCMToken ud " + + " WHERE ud.userId = u.id " + + " AND ud.isAllowed = true " + + " AND ud.isActive = true" + + ")") + Page findSelectedRegionCampaignsByVisitEndDate( + @Param("visitEndDate") LocalDate visitEndDate, + Pageable pageable, + @Param("selectedStatus") CampaignStatusType selectedStatus, + @Param("regionType") CampaignType regionType); + + default Page findSelectedRegionCampaignsByVisitEndDate( + LocalDate visitEndDate, Pageable pageable) { + return findSelectedRegionCampaignsByVisitEndDate(visitEndDate, pageable, + CampaignStatusType.SELECTED, CampaignType.REGION); + } /** - * APPLY 상태에 대한 세부 필터링 (기한 남은 공고 vs 기한 지난 공고) - * subFilter: "waiting" (기한 남은 공고), "completed" (기한 지난 공고) + * REVIEWING 상태 리뷰 마감 조회 (페이징, 알림 허용 사용자만) */ @Query("SELECT cs FROM CampaignStatus cs " + "JOIN FETCH cs.campaign c " + + "JOIN FETCH cs.user u " + + "WHERE cs.status = :reviewingStatus " + + "AND c.contentSubmissionEnd = :reviewEndDate " + + "AND cs.isActive = true " + + "AND u.isActive = true " + + "AND EXISTS (" + + " SELECT 1 FROM UserFCMToken ud " + + " WHERE ud.userId = u.id " + + " AND ud.isAllowed = true " + + " AND ud.isActive = true" + + ")") + Page findReviewingCampaignsByReviewEndDate( + @Param("reviewEndDate") LocalDate reviewEndDate, + Pageable pageable, + @Param("reviewingStatus") CampaignStatusType reviewingStatus); + + // 오버로딩 메서드 (파라미터 간소화) + default Page findReviewingCampaignsByReviewEndDate( + LocalDate reviewEndDate, Pageable pageable) { + return findReviewingCampaignsByReviewEndDate(reviewEndDate, pageable, + CampaignStatusType.REVIEWING); + } + + @Modifying + @Query("DELETE FROM CampaignStatus cs WHERE cs.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); + + /** + * 새로운 케이스별 조회 메서드들 + */ + + /** + * appliedWaiting: ID만 페이징 조회 (1단계) + */ + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + - "AND (:subFilter = 'waiting' AND c.applyEnd > :today " + - " OR :subFilter = 'completed' AND c.applyEnd <= :today)") - Page findByUserAndStatusAndIsActiveTrueWithSubFilter( - @Param("user") User user, - @Param("status") CampaignStatusType status, - @Param("subFilter") String subFilter, - @Param("today") LocalDate today, - Pageable pageable - ); + "AND c.reviewerAnnouncement >= :today " + + "ORDER BY c.reviewerAnnouncement DESC") + Page findIdsByUserAndAppliedWaiting(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today, + Pageable pageable); /** - * 사용자의 활동 알림 대상 캠페인들 조회 (3일 이내 마감) + * appliedCompleted: ID만 페이징 조회 (1단계) */ - @Query("SELECT cs FROM CampaignStatus cs WHERE cs.isActive = true AND cs.user.id = :userId") - List findActivityEligibleByUserId(@Param("userId") Long userId); + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.reviewerAnnouncement < :today " + + "ORDER BY c.reviewerAnnouncement DESC") + Page findIdsByUserAndAppliedCompleted(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today, + Pageable pageable); + + /** + * resultSelected: ID만 페이징 조회 (1단계) + */ + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd >= :today " + + "ORDER BY c.contentSubmissionEnd DESC") + Page findIdsByUserAndResultSelected(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today, + Pageable pageable); + + /** + * resultNotSelected: ID만 페이징 조회 (1단계) + */ + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd < :today " + + "ORDER BY c.contentSubmissionEnd DESC") + Page findIdsByUserAndResultNotSelected(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today, + Pageable pageable); /** - * 알림 미발송된 활동 대상 캠페인들 조회 + * reviewInProgress: ID만 페이징 조회 (1단계) */ - @Query("SELECT cs FROM CampaignStatus cs WHERE cs.isActive = true AND cs.activityNotified = false") - List findUnnotifiedActivityStatuses(); + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd >= :today " + + "ORDER BY c.contentSubmissionEnd DESC") + Page findIdsByUserAndReviewInProgress(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today, + Pageable pageable); + + /** + * reviewCompleted: ID만 페이징 조회 (1단계) + */ + @Query("SELECT cs.id FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "ORDER BY c.resultAnnouncement DESC") + Page findIdsByUserAndReviewCompleted(@Param("user") User user, + @Param("status") CampaignStatusType status, + Pageable pageable); /** - * 특정 사용자의 활동 알림 대상 캠페인들 조회 (알림 발송용) + * ID 리스트로 CampaignStatus 조회 (2단계) - N+1 문제 방지를 위해 JOIN FETCH 사용 + * ID 순서를 유지하기 위해 순서대로 조회 */ - @Query("SELECT cs FROM CampaignStatus cs WHERE cs.isActive = true AND cs.user.id = :userId AND cs.activityNotified = false") - List findUnnotifiedActivityStatusesByUserId(@Param("userId") Long userId); + @Query("SELECT cs FROM CampaignStatus cs " + + "JOIN FETCH cs.campaign c " + + "JOIN FETCH cs.user u " + + "WHERE cs.id IN :ids") + List findByIdsWithFetch(@Param("ids") List ids); /** - * 사용자별로 isVisibleToUser = true인 활동 알림만 조회 + * 새로운 케이스별 카운트 메서드들 + */ + + /** + * appliedWaiting 카운트: APPLY 상태 + reviewer_announcement >= 오늘 + */ + @Query("SELECT COUNT(cs) FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.reviewerAnnouncement >= :today") + long countByUserAndAppliedWaiting(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today); + + /** + * appliedCompleted 카운트: APPLY 상태 + reviewer_announcement < 오늘 + */ + @Query("SELECT COUNT(cs) FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.reviewerAnnouncement < :today") + long countByUserAndAppliedCompleted(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today); + + /** + * resultSelected 카운트: SELECTED 상태 + content_submission_end >= 오늘 */ - @Query("SELECT cs FROM CampaignStatus cs WHERE cs.isActive = true AND cs.user.id = :userId AND cs.isVisibleToUser = true") - List findVisibleActivityByUserId(@Param("userId") Long userId); + @Query("SELECT COUNT(cs) FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd >= :today") + long countByUserAndResultSelected(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today); + + /** + * resultNotSelected 카운트: NOT_SELECTED 상태 + content_submission_end < 오늘 + */ + @Query("SELECT COUNT(cs) FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd < :today") + long countByUserAndResultNotSelected(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today); + + /** + * reviewInProgress 카운트: REVIEWING 상태 + content_submission_end >= 오늘 + */ + @Query("SELECT COUNT(cs) FROM CampaignStatus cs JOIN cs.campaign c " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true " + + "AND c.contentSubmissionEnd >= :today") + long countByUserAndReviewInProgress(@Param("user") User user, + @Param("status") CampaignStatusType status, + @Param("today") LocalDate today); /** - * 사용자별로 isVisibleToUser = true인 활동 알림만 조회 (페이지네이션) + * reviewCompleted 카운트: ENDED 상태 */ - @Query("SELECT cs FROM CampaignStatus cs WHERE cs.isActive = true AND cs.user.id = :userId AND cs.isVisibleToUser = true") - Page findVisibleActivityByUserId(@Param("userId") Long userId, Pageable pageable); + @Query("SELECT COUNT(cs) FROM CampaignStatus cs " + + "WHERE cs.user = :user AND cs.status = :status AND cs.isActive = true") + long countByUserAndReviewCompleted(@Param("user") User user, + @Param("status") CampaignStatusType status); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncScheduler.java b/src/main/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncScheduler.java new file mode 100644 index 00000000..d159d14c --- /dev/null +++ b/src/main/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncScheduler.java @@ -0,0 +1,51 @@ +package com.example.cherrydan.campaign.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CampaignSearchSyncScheduler { + + private final JdbcTemplate jdbcTemplate; + + @Scheduled(cron = "0 30 6 * * ?", zone = "Asia/Seoul") + public void syncDailySearchTable() { + try { + LocalDate yesterday = LocalDate.now(ZoneId.of("Asia/Seoul")).minusDays(1); + + log.info("campaigns_daily_search 동기화 시작 - 날짜: {}", yesterday); + + // 1. TRUNCATE + jdbcTemplate.execute("TRUNCATE TABLE campaigns_daily_search"); + + // 2. INSERT ... SELECT (한 방 쿼리, 인덱스 활용) + String sql = """ + INSERT INTO campaigns_daily_search (id, title, created_at) + SELECT id, title, created_at + FROM campaigns + WHERE is_active = 1 + AND created_at >= ? + AND created_at < ? + """; + + LocalDateTime startOfDay = yesterday.atStartOfDay(); + LocalDateTime startOfNextDay = yesterday.plusDays(1).atStartOfDay(); + + int count = jdbcTemplate.update(sql, startOfDay, startOfNextDay); + + log.info("campaigns_daily_search 동기화 완료 - 날짜: {}, 건수: {}", yesterday, count); + + } catch (Exception e) { + log.error("campaigns_daily_search 동기화 실패: {}", e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/service/BookmarkService.java b/src/main/java/com/example/cherrydan/campaign/service/BookmarkService.java index 990ad33f..723730f2 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/BookmarkService.java +++ b/src/main/java/com/example/cherrydan/campaign/service/BookmarkService.java @@ -1,14 +1,16 @@ package com.example.cherrydan.campaign.service; +import com.example.cherrydan.campaign.dto.BookmarkDeleteDTO; +import com.example.cherrydan.campaign.dto.BookmarkCancelDTO; import com.example.cherrydan.campaign.dto.BookmarkResponseDTO; +import com.example.cherrydan.campaign.domain.BookmarkCase; import com.example.cherrydan.common.response.PageListResponseDTO; import org.springframework.data.domain.Pageable; import java.util.List; public interface BookmarkService { void addBookmark(Long userId, Long campaignId); - void cancelBookmark(Long userId, Long campaignId); - PageListResponseDTO getOpenBookmarks(Long userId, Pageable pageable); - PageListResponseDTO getClosedBookmarks(Long userId, Pageable pageable); - void deleteBookmark(Long userId, Long campaignId); + void cancelBookmarks(Long userId, BookmarkCancelDTO request); + PageListResponseDTO getBookmarksByCase(Long userId, BookmarkCase bookmarkCase, Pageable pageable); + void deleteBookmark(Long userId, BookmarkDeleteDTO request); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/service/BookmarkServiceImpl.java b/src/main/java/com/example/cherrydan/campaign/service/BookmarkServiceImpl.java index 17bdfa27..e825922a 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/BookmarkServiceImpl.java +++ b/src/main/java/com/example/cherrydan/campaign/service/BookmarkServiceImpl.java @@ -2,8 +2,9 @@ import com.example.cherrydan.campaign.domain.Bookmark; import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.domain.BookmarkCase; +import com.example.cherrydan.campaign.dto.BookmarkDeleteDTO; import com.example.cherrydan.campaign.dto.BookmarkResponseDTO; -import com.example.cherrydan.campaign.dto.BookmarkSplitResponseDTO; import com.example.cherrydan.campaign.repository.BookmarkRepository; import com.example.cherrydan.campaign.repository.CampaignRepository; import com.example.cherrydan.user.domain.User; @@ -26,6 +27,7 @@ import java.time.LocalDate; import org.springframework.data.domain.PageImpl; import com.example.cherrydan.common.response.PageListResponseDTO; +import com.example.cherrydan.campaign.dto.BookmarkCancelDTO; @Service @RequiredArgsConstructor @@ -42,50 +44,67 @@ public void addBookmark(Long userId, Long campaignId) { Campaign campaign = campaignRepository.findById(campaignId) .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); Optional optionalBookmark = bookmarkRepository.findByUserAndCampaign(user, campaign); - if (optionalBookmark.isPresent()) { - Bookmark bookmark = optionalBookmark.get(); - bookmark.setIsActive(true); - bookmarkRepository.save(bookmark); - } else { - Bookmark bookmark = Bookmark.builder() - .user(user) - .campaign(campaign) - .isActive(true) - .build(); - bookmarkRepository.save(bookmark); - } + + Bookmark bookmark = optionalBookmark + .orElseGet(() -> Bookmark.builder() + .user(user) + .campaign(campaign) + .isActive(false) + .build()); + + bookmark.activate(); + bookmarkRepository.save(bookmark); } @Override @Transactional - public void cancelBookmark(Long userId, Long campaignId) { + public void deleteBookmark(Long userId, BookmarkDeleteDTO request) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - Campaign campaign = campaignRepository.findById(campaignId) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - Bookmark bookmark = bookmarkRepository.findByUserAndCampaign(user, campaign) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - bookmark.setIsActive(false); - bookmarkRepository.save(bookmark); + + bookmarkRepository.deleteByUserAndCampaignIds(user, request.getCampaignIds()); } @Override @Transactional - public void deleteBookmark(Long userId, Long campaignId) { + public void cancelBookmarks(Long userId, BookmarkCancelDTO request) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - Campaign campaign = campaignRepository.findById(campaignId) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - bookmarkRepository.deleteByUserAndCampaign(user, campaign); - } - - public PageListResponseDTO getOpenBookmarks(Long userId, Pageable pageable) { - Page bookmarks = bookmarkRepository.findByUserIdAndIsActiveTrueAndCampaign_ApplyEndGreaterThanEqual(userId, LocalDate.now(), pageable); - return PageListResponseDTO.from(bookmarks.map(BookmarkResponseDTO::fromEntity)); + + try { + List bookmarks = bookmarkRepository.findByUserAndCampaignIdIn(user, request.getCampaignIds()); + for (Bookmark bookmark : bookmarks) { + bookmark.setIsActive(false); + } + bookmarkRepository.saveAll(bookmarks); + } catch (Exception e) { + throw new BaseException(ErrorMessage.RESOURCE_NOT_FOUND); + } } - public PageListResponseDTO getClosedBookmarks(Long userId, Pageable pageable) { - Page bookmarks = bookmarkRepository.findByUserIdAndIsActiveTrueAndCampaign_ApplyEndLessThan(userId, LocalDate.now(), pageable); + @Override + @Transactional(readOnly = true) + public PageListResponseDTO getBookmarksByCase(Long userId, BookmarkCase bookmarkCase, Pageable pageable) { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.minusMonths(1); + Page bookmarks; + + switch (bookmarkCase) { + case LIKED_OPEN: + bookmarks = bookmarkRepository.findByUserIdAndIsActiveTrueAndCampaign_ApplyEndGreaterThanEqual(userId, today, pageable); + break; + case LIKED_CLOSED: + bookmarks = bookmarkRepository.findByUserIdAndIsActiveTrueAndCampaign_ApplyEndBetween( + userId, + startDate, + today, + pageable + ); + break; + default: + throw new BaseException(ErrorMessage.RESOURCE_NOT_FOUND); + } + return PageListResponseDTO.from(bookmarks.map(BookmarkResponseDTO::fromEntity)); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/service/CampaignService.java b/src/main/java/com/example/cherrydan/campaign/service/CampaignService.java index 18b0a2d1..b3f658bc 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/CampaignService.java +++ b/src/main/java/com/example/cherrydan/campaign/service/CampaignService.java @@ -36,7 +36,7 @@ PageListResponseDTO getCampaignsByCampaignPlatform( PageListResponseDTO searchByKeyword(String keyword, Pageable pageable, Long userId); Page getPersonalizedCampaignsByKeyword(String keyword, LocalDate date, Long userId, Pageable pageable); - + long getDailyCampaignCountByKeyword(String keyword, LocalDate date); PageListResponseDTO getCampaignsByLocal( diff --git a/src/main/java/com/example/cherrydan/campaign/service/CampaignServiceImpl.java b/src/main/java/com/example/cherrydan/campaign/service/CampaignServiceImpl.java index a8cca884..0bd3c42a 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/CampaignServiceImpl.java +++ b/src/main/java/com/example/cherrydan/campaign/service/CampaignServiceImpl.java @@ -4,7 +4,6 @@ import com.example.cherrydan.campaign.domain.CampaignType; import com.example.cherrydan.campaign.domain.SnsPlatformType; import com.example.cherrydan.campaign.domain.CampaignPlatformType; -import com.example.cherrydan.common.aop.PerformanceMonitor; import com.example.cherrydan.common.response.PageListResponseDTO; import com.example.cherrydan.campaign.dto.CampaignResponseDTO; import com.example.cherrydan.campaign.repository.CampaignRepository; @@ -13,6 +12,7 @@ import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -29,6 +29,7 @@ import com.example.cherrydan.common.exception.CampaignException; import com.example.cherrydan.campaign.domain.LocalCategory; import com.example.cherrydan.campaign.domain.ProductCategory; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; @Service @RequiredArgsConstructor @@ -36,6 +37,7 @@ public class CampaignServiceImpl implements CampaignService { private final CampaignRepository campaignRepository; private final BookmarkRepository bookmarkRepository; + private final KeywordCampaignAlertRepository keywordCampaignAlertRepository; @Override public PageListResponseDTO getCampaigns(CampaignType type, String sort, Pageable pageable, Long userId) { @@ -142,48 +144,66 @@ public PageListResponseDTO searchByKeyword(String keyword, } /** - * 특정 키워드로 맞춤형 캠페인 목록 조회 (FULLTEXT 인덱스 활용) + * 특정 키워드로 맞춤형 캠페인 목록 조회 (Simple LIKE 검색) + * + * 성능 개선: FULLTEXT STRAIGHT_JOIN 방식 대비 79-157배 개선 + * - 희귀 키워드 (부산): 280ms → 20ms + * - 흔한 키워드 (서울): 1,400ms → 43ms */ @Override - @PerformanceMonitor public Page getPersonalizedCampaignsByKeyword(String keyword, LocalDate date, Long userId, Pageable pageable) { - - // Boolean 모드로 변경: +키워드* 형태로 검색 - String fullTextKeyword = "+" + keyword.trim() + "*"; - List campaigns = campaignRepository.findByKeywordFullText( - fullTextKeyword, - date, - (int) pageable.getOffset(), - pageable.getPageSize() - ); - - long totalElements = campaignRepository.countByKeywordAndCreatedDate(fullTextKeyword, date); - + + String searchKeyword = keyword.trim(); + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + + List campaigns; + long totalElements; + + if (date.equals(today)) { + // alertDate가 오늘이면 = 전날 캠페인 조회 + // FULLTEXT 검색 (campaigns_daily_search 테이블, 빠름) + campaigns = campaignRepository.searchDailyCampaignsByFulltext( + "+" + searchKeyword + "*", + (int) pageable.getOffset(), + pageable.getPageSize() + ); + totalElements = campaignRepository.countDailyCampaignsByFulltext(searchKeyword); + } else { + // alertDate가 과거이면 = 그 날짜 기준 전날 캠페인 조회 + // Simple LIKE 검색 (campaigns 테이블, 폴백) + campaigns = campaignRepository.findByKeywordSimpleLike( + searchKeyword, + date, + (int) pageable.getOffset(), + pageable.getPageSize() + ); + totalElements = campaignRepository.countByKeywordSimpleLike(searchKeyword, date); + } + // N+1 문제 해결: 벌크 조회로 북마크 여부 확인 List campaignIds = campaigns.stream() .map(Campaign::getId) .collect(Collectors.toList()); - + Set bookmarkedCampaignIds = bookmarkRepository.findBookmarkedCampaignIds(userId, campaignIds); - + + // 키워드 알림 읽음 처리 + keywordCampaignAlertRepository.markAsReadByUserAndKeyword(userId, searchKeyword, date); + List content = campaigns.stream() .map(campaign -> { boolean isBookmarked = bookmarkedCampaignIds.contains(campaign.getId()); return CampaignResponseDTO.fromEntityWithBookmark(campaign, isBookmarked); }) .collect(Collectors.toList()); - + return new PageImpl<>(content, pageable, totalElements); } - + @Override - @PerformanceMonitor public long getDailyCampaignCountByKeyword(String keyword, LocalDate date) { - if (keyword == null || keyword.trim().isEmpty()) { - return 0; - } - String fullTextKeyword = "+" + keyword.trim() + "*"; - return campaignRepository.countByKeywordAndCreatedDate(fullTextKeyword, date); + // 이 메서드는 processKeywordAsync에서만 호출되며, 항상 전날 데이터를 조회 + return campaignRepository.countDailyCampaignsByFulltext("+" + keyword.trim() + "*"); } @Override diff --git a/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusService.java b/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusService.java index 6805d7a9..162959d6 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusService.java +++ b/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusService.java @@ -1,19 +1,18 @@ package com.example.cherrydan.campaign.service; -import com.example.cherrydan.campaign.dto.CampaignStatusRequestDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusListResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusPopupResponseDTO; +import com.example.cherrydan.campaign.dto.*; + import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.domain.CampaignStatusCase; import com.example.cherrydan.common.response.PageListResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusCountResponseDTO; import org.springframework.data.domain.Pageable; +import java.util.List; public interface CampaignStatusService { - CampaignStatusResponseDTO createOrRecoverStatus(CampaignStatusRequestDTO requestDTO); - CampaignStatusResponseDTO updateStatus(CampaignStatusRequestDTO requestDTO); - void deleteStatus(Long campaignId, Long userId); - CampaignStatusPopupResponseDTO getPopupStatusByUser(Long userId); - PageListResponseDTO getStatusesByType(Long userId, CampaignStatusType statusType, String subFilter, Pageable pageable); + CampaignStatusResponseDTO createOrRecoverStatus(CampaignStatusRequestDTO requestDTO, Long userId); + List updateStatusBatch(CampaignStatusBatchRequestDTO requestDTO, Long userId); + void deleteStatusBatch(CampaignStatusDeleteRequestDTO requestDTO, Long userId); + CampaignStatusPopupByTypeResponseDTO getPopupStatusByBookmark(Long userId); + PageListResponseDTO getStatusesByCase(Long userId, CampaignStatusCase statusCase, Pageable pageable); CampaignStatusCountResponseDTO getStatusCounts(Long userId); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusServiceImpl.java b/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusServiceImpl.java index c5a61219..6b826a4f 100644 --- a/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusServiceImpl.java +++ b/src/main/java/com/example/cherrydan/campaign/service/CampaignStatusServiceImpl.java @@ -3,18 +3,17 @@ import com.example.cherrydan.campaign.domain.Campaign; import com.example.cherrydan.campaign.domain.CampaignStatus; import com.example.cherrydan.campaign.domain.CampaignStatusType; -import com.example.cherrydan.campaign.dto.BookmarkResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusRequestDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusListResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusPopupResponseDTO; -import com.example.cherrydan.campaign.dto.CampaignStatusCountResponseDTO; +import com.example.cherrydan.campaign.domain.CampaignStatusCase; +import com.example.cherrydan.campaign.domain.Bookmark; +import com.example.cherrydan.campaign.dto.*; import com.example.cherrydan.common.response.PageListResponseDTO; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import com.example.cherrydan.campaign.dto.CampaignStatusPopupItemDTO; +import java.util.ArrayList; +import java.util.List; import com.example.cherrydan.campaign.repository.CampaignRepository; import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.campaign.repository.BookmarkRepository; import com.example.cherrydan.campaign.domain.CampaignPlatformType; import com.example.cherrydan.campaign.domain.SnsPlatformType; import com.example.cherrydan.user.domain.User; @@ -36,139 +35,146 @@ public class CampaignStatusServiceImpl implements CampaignStatusService { private final CampaignStatusRepository campaignStatusRepository; private final CampaignRepository campaignRepository; private final UserRepository userRepository; + private final BookmarkRepository bookmarkRepository; @Override @Transactional - public CampaignStatusResponseDTO createOrRecoverStatus(CampaignStatusRequestDTO requestDTO) { - User user = userRepository.findActiveById(requestDTO.getUserId()) + public CampaignStatusResponseDTO createOrRecoverStatus(CampaignStatusRequestDTO requestDTO, Long userId) { + User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); Campaign campaign = campaignRepository.findById(requestDTO.getCampaignId()) .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); Optional optional = campaignStatusRepository.findByUserAndCampaign(user, campaign); - CampaignStatus status; - if (optional.isPresent()) { - status = optional.get(); - status.setIsActive(true); - status.setStatus(requestDTO.getStatus()); - } else { - status = CampaignStatus.builder() - .user(user) - .campaign(campaign) - .status(requestDTO.getStatus()) - .isActive(true) - .build(); - } + CampaignStatus status = optional.orElseGet(() -> CampaignStatus.builder() + .user(user) + .campaign(campaign) + .isActive(false) + .build()); + + status.activate(); + status.updateStatus(requestDTO.getStatus()); + CampaignStatus saved = campaignStatusRepository.save(status); return CampaignStatusResponseDTO.fromEntity(saved); } @Override @Transactional - public CampaignStatusResponseDTO updateStatus(CampaignStatusRequestDTO requestDTO) { - User user = userRepository.findActiveById(requestDTO.getUserId()) + public List updateStatusBatch(CampaignStatusBatchRequestDTO requestDTO, Long userId) { + User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - Campaign campaign = campaignRepository.findById(requestDTO.getCampaignId()) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - CampaignStatus status = campaignStatusRepository.findByUserAndCampaign(user, campaign) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - if (requestDTO.getIsActive() != null) { - status.setIsActive(requestDTO.getIsActive()); - } + if (requestDTO.getStatus() != null) { - status.setStatus(requestDTO.getStatus()); + if (requestDTO.getIsActive() != null) { + campaignStatusRepository.updateStatusAndActiveBatch(user, requestDTO.getCampaignIds(), + requestDTO.getIsActive(), requestDTO.getStatus()); + } else { + campaignStatusRepository.updateStatusOnlyBatch(user, requestDTO.getCampaignIds(), + requestDTO.getStatus()); + } } - CampaignStatus saved = campaignStatusRepository.save(status); - return CampaignStatusResponseDTO.fromEntity(saved); + + List updatedStatuses = campaignStatusRepository.findByUserAndCampaignIds(user, requestDTO.getCampaignIds()); + return updatedStatuses.stream() + .map(CampaignStatusResponseDTO::fromEntity) + .collect(Collectors.toList()); } @Override @Transactional - public void deleteStatus(Long campaignId, Long userId) { + public void deleteStatusBatch(CampaignStatusDeleteRequestDTO requestDTO, Long userId) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - Campaign campaign = campaignRepository.findById(campaignId) - .orElseThrow(() -> new BaseException(ErrorMessage.RESOURCE_NOT_FOUND)); - Optional status = campaignStatusRepository.findByUserAndCampaign(user, campaign); - if (status.isEmpty()) { - throw new BaseException(ErrorMessage.RESOURCE_NOT_FOUND); - } - campaignStatusRepository.delete(status.get()); + + campaignStatusRepository.deleteByUserAndCampaignIds(user, requestDTO.getCampaignIds()); } @Override @Transactional(readOnly = true) - public CampaignStatusPopupResponseDTO getPopupStatusByUser(Long userId) { + public CampaignStatusPopupByTypeResponseDTO getPopupStatusByBookmark(Long userId) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - List apply = new ArrayList<>(); - List selected = new ArrayList<>(); - List reviewing = new ArrayList<>(); - List all = campaignStatusRepository.findByUserAndIsActiveTrue(user); - for (CampaignStatus status : all) { + + LocalDate today = LocalDate.now(); + List bookmarks = bookmarkRepository.findActiveBookmarksByUserForPopup(user.getId(), today); + + List items = new ArrayList<>(); + + for (Bookmark bookmark : bookmarks) { try { - CampaignStatusPopupItemDTO dto = CampaignStatusPopupItemDTO.fromEntity(status); - switch (status.getStatus()) { - case APPLY: apply.add(dto); break; - case SELECTED: selected.add(dto); break; - case REVIEWING: reviewing.add(dto); break; - default: break; - } + CampaignStatusPopupItemDTO dto = CampaignStatusPopupItemDTO.fromBookmark(bookmark); + items.add(dto); } catch (BaseException e) { - // 캠페인 정보가 없는 데이터는 무시 + // 에러 발생 시 해당 항목은 건너뛰고 계속 진행 + continue; } } - LocalDate today = LocalDate.now(); - List filteredApply = apply.stream() - .filter(dto -> dto.getReviewerAnnouncementStatus() != null) - .sorted(Comparator.comparing(CampaignStatusPopupItemDTO::getReviewerAnnouncementStatus)) - .toList(); - List filteredSelected = selected.stream() - .filter(dto -> dto.getReviewerAnnouncementStatus() != null) - .sorted(Comparator.comparing(CampaignStatusPopupItemDTO::getReviewerAnnouncementStatus)) - .toList(); - List filteredReviewing = reviewing.stream() - .filter(dto -> dto.getReviewerAnnouncementStatus() != null) - .sorted(Comparator.comparing(CampaignStatusPopupItemDTO::getReviewerAnnouncementStatus)) - .toList(); - return CampaignStatusPopupResponseDTO.builder() - .applyTotal(filteredApply.size()) - .selectedTotal(filteredSelected.size()) - .reviewingTotal(filteredReviewing.size()) - .apply(filteredApply.stream().limit(4).toList()) - .selected(filteredSelected.stream().limit(4).toList()) - .reviewing(filteredReviewing.stream().limit(4).toList()) + + return CampaignStatusPopupByTypeResponseDTO.builder() + .items(items) + .totalCount(items.size()) .build(); } @Override @Transactional(readOnly = true) - public PageListResponseDTO getStatusesByType(Long userId, CampaignStatusType statusType, String subFilter, Pageable pageable) { + public PageListResponseDTO getStatusesByCase(Long userId, CampaignStatusCase statusCase, Pageable pageable) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); - Page page; + LocalDate today = LocalDate.now(); + CampaignStatusType statusType = statusCase.toStatusType(); - // APPLY 상태이고 subFilter가 있는 경우 세부 필터링 적용 - if (statusType == CampaignStatusType.APPLY && subFilter != null && !subFilter.trim().isEmpty()) { - LocalDate today = LocalDate.now(); - page = campaignStatusRepository.findByUserAndStatusAndIsActiveTrueWithSubFilter(user, statusType, subFilter.trim(), today, pageable); - } else { - page = campaignStatusRepository.findByUserAndStatusAndIsActiveTrue(user, statusType, pageable); + // 1단계: ID만 페이징해서 가져오기 + Page idPage = switch (statusCase) { + case APPLIED_WAITING -> campaignStatusRepository.findIdsByUserAndAppliedWaiting(user, statusType, today, pageable); + case APPLIED_COMPLETED -> campaignStatusRepository.findIdsByUserAndAppliedCompleted(user, statusType, today, pageable); + case RESULT_SELECTED -> campaignStatusRepository.findIdsByUserAndResultSelected(user, statusType, today, pageable); + case RESULT_NOT_SELECTED -> campaignStatusRepository.findIdsByUserAndResultNotSelected(user, statusType, today, pageable); + case REVIEW_IN_PROGRESS -> campaignStatusRepository.findIdsByUserAndReviewInProgress(user, statusType, today, pageable); + case REVIEW_COMPLETED -> campaignStatusRepository.findIdsByUserAndReviewCompleted(user, statusType, pageable); + }; + + // ID가 없으면 빈 리스트 반환 + if (idPage.getContent().isEmpty()) { + return PageListResponseDTO.builder() + .content(List.of()) + .page(idPage.getNumber()) + .size(idPage.getSize()) + .totalElements(idPage.getTotalElements()) + .totalPages(idPage.getTotalPages()) + .hasNext(idPage.hasNext()) + .hasPrevious(idPage.hasPrevious()) + .build(); } - List content = page.getContent().stream() + // 2단계: ID 리스트로 JOIN FETCH하여 실제 데이터 가져오기 (N+1 문제 방지) + List statuses = campaignStatusRepository.findByIdsWithFetch(idPage.getContent()); + + // ID 순서를 유지하기 위해 Map으로 변환 후 정렬 + Map statusMap = statuses.stream() + .collect(Collectors.toMap(CampaignStatus::getId, status -> status)); + + // ID 순서대로 정렬 + List orderedStatuses = idPage.getContent().stream() + .map(statusMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + List content = orderedStatuses.stream() .map(CampaignStatusResponseDTO::fromEntity) .toList(); + return PageListResponseDTO.builder() .content(content) - .page(page.getNumber()) - .size(page.getSize()) - .totalElements(page.getTotalElements()) - .totalPages(page.getTotalPages()) - .hasNext(page.hasNext()) - .hasPrevious(page.hasPrevious()) + .page(idPage.getNumber()) + .size(idPage.getSize()) + .totalElements(idPage.getTotalElements()) + .totalPages(idPage.getTotalPages()) + .hasNext(idPage.hasNext()) + .hasPrevious(idPage.hasPrevious()) .build(); } @@ -177,12 +183,15 @@ public PageListResponseDTO getStatusesByType(Long use public CampaignStatusCountResponseDTO getStatusCounts(Long userId) { User user = userRepository.findActiveById(userId) .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); + + LocalDate today = LocalDate.now(); return CampaignStatusCountResponseDTO.builder() - .apply(campaignStatusRepository.countByUserAndStatusAndIsActiveTrue(user, CampaignStatusType.APPLY)) - .selected(campaignStatusRepository.countByUserAndStatusAndIsActiveTrue(user, CampaignStatusType.SELECTED)) - .notSelected(campaignStatusRepository.countByUserAndStatusAndIsActiveTrue(user, CampaignStatusType.NOT_SELECTED)) - .reviewing(campaignStatusRepository.countByUserAndStatusAndIsActiveTrue(user, CampaignStatusType.REVIEWING)) - .ended(campaignStatusRepository.countByUserAndStatusAndIsActiveTrue(user, CampaignStatusType.ENDED)) + .appliedWaiting(campaignStatusRepository.countByUserAndAppliedWaiting(user, CampaignStatusCase.APPLIED_WAITING.toStatusType(), today)) + .appliedCompleted(campaignStatusRepository.countByUserAndAppliedCompleted(user, CampaignStatusCase.APPLIED_COMPLETED.toStatusType(), today)) + .resultSelected(campaignStatusRepository.countByUserAndResultSelected(user, CampaignStatusCase.RESULT_SELECTED.toStatusType(), today)) + .resultNotSelected(campaignStatusRepository.countByUserAndResultNotSelected(user, CampaignStatusCase.RESULT_NOT_SELECTED.toStatusType(), today)) + .reviewInProgress(campaignStatusRepository.countByUserAndReviewInProgress(user, CampaignStatusCase.REVIEW_IN_PROGRESS.toStatusType(), today)) + .reviewCompleted(campaignStatusRepository.countByUserAndReviewCompleted(user, CampaignStatusCase.REVIEW_COMPLETED.toStatusType())) .build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/aop/PerformanceAspect.java b/src/main/java/com/example/cherrydan/common/aop/PerformanceAspect.java deleted file mode 100644 index a086981e..00000000 --- a/src/main/java/com/example/cherrydan/common/aop/PerformanceAspect.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.cherrydan.common.aop; - -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.stereotype.Component; -import org.springframework.util.StopWatch; - -@Aspect -@Component -@Slf4j -public class PerformanceAspect { - @Around("@annotation(performanceMonitor)") - public Object measureExecutionTime(ProceedingJoinPoint joinPoint, PerformanceMonitor performanceMonitor) throws Throwable { - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - try { - return joinPoint.proceed(); - } finally { - stopWatch.stop(); - String methodName = joinPoint.getSignature().getName(); - Object[] args = joinPoint.getArgs(); - String keyword = args.length > 0 ? String.valueOf(args[0]) : "unknown"; - log.info("메서드: {}, 키워드: '{}', 실행시간: {}ms", methodName, keyword, stopWatch.getTotalTimeMillis()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/aop/PerformanceMonitor.java b/src/main/java/com/example/cherrydan/common/aop/PerformanceMonitor.java deleted file mode 100644 index 52330fd9..00000000 --- a/src/main/java/com/example/cherrydan/common/aop/PerformanceMonitor.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.example.cherrydan.common.aop; - -import java.lang.annotation.*; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface PerformanceMonitor { - String value() default ""; -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/aop/ServiceMetricsAspect.java b/src/main/java/com/example/cherrydan/common/aop/ServiceMetricsAspect.java new file mode 100644 index 00000000..6f620c99 --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/aop/ServiceMetricsAspect.java @@ -0,0 +1,53 @@ +package com.example.cherrydan.common.aop; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class ServiceMetricsAspect { + + private static final long SLOW_THRESHOLD_MS = 500; + + private final MeterRegistry meterRegistry; + + @Around("@within(org.springframework.stereotype.Service) && " + + "execution(public * *(..)) && " + + "!execution(* get*()) && " + + "!execution(* is*()) && " + + "!execution(* has*())") + public Object measureServiceMethod(ProceedingJoinPoint joinPoint) throws Throwable { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + + long startTime = System.currentTimeMillis(); + + try { + return joinPoint.proceed(); + } finally { + long executionTime = System.currentTimeMillis() - startTime; + + if (executionTime > SLOW_THRESHOLD_MS) { + log.warn("느린 서비스 메서드 감지: {}.{} - {}ms 소요", + className, methodName, executionTime); + } + + Timer.builder("service.method.execution") + .tag("service", className) + .tag("method", methodName) + .description("Service method execution time") + .register(meterRegistry) + .record(executionTime, TimeUnit.MILLISECONDS); + } + } +} diff --git a/src/main/java/com/example/cherrydan/common/config/AsyncConfig.java b/src/main/java/com/example/cherrydan/common/config/AsyncConfig.java index 16d8bdbc..697f5169 100644 --- a/src/main/java/com/example/cherrydan/common/config/AsyncConfig.java +++ b/src/main/java/com/example/cherrydan/common/config/AsyncConfig.java @@ -22,4 +22,16 @@ public Executor keywordTaskExecutor() { executor.initialize(); return executor; } + + @Bean("alertTaskExecutor") + public Executor alertTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); // 순차 처리 (t2.micro 최적화) + executor.setMaxPoolSize(2); // 최대 2개까지만 + executor.setQueueCapacity(10); // 큐 크기 축소 + executor.setThreadNamePrefix("alert-batch-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/constants/DeepLinkConstants.java b/src/main/java/com/example/cherrydan/common/constants/DeepLinkConstants.java new file mode 100644 index 00000000..93d519d9 --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/constants/DeepLinkConstants.java @@ -0,0 +1,34 @@ +package com.example.cherrydan.common.constants; + +/** + * 딥링크 관련 상수 및 유틸리티 클래스 + */ +public class DeepLinkConstants { + + private static final String SCHEME = "cherrydan"; + private static final String OAUTH_CALLBACK_PATH = "oauth/callback"; + + private DeepLinkConstants() { + throw new UnsupportedOperationException("상수 클래스는 인스턴스화할 수 없습니다"); + } + + /** + * OAuth 성공 딥링크 URL 생성 + * @param platform 플랫폼 코드 + * @return 딥링크 URL + */ + public static String buildOAuthSuccessUrl(String platform) { + return String.format("%s://%s?success=true&platform=%s", + SCHEME, OAUTH_CALLBACK_PATH, platform); + } + + /** + * OAuth 실패 딥링크 URL 생성 + * @param errorCode 에러 코드 + * @return 딥링크 URL + */ + public static String buildOAuthFailureUrl(String errorCode) { + return String.format("%s://%s?success=false&error=%s", + SCHEME, OAUTH_CALLBACK_PATH, errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/entity/BaseTimeEntity.java b/src/main/java/com/example/cherrydan/common/entity/BaseTimeEntity.java index e6c03fe1..6dca27d2 100644 --- a/src/main/java/com/example/cherrydan/common/entity/BaseTimeEntity.java +++ b/src/main/java/com/example/cherrydan/common/entity/BaseTimeEntity.java @@ -3,7 +3,9 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.Builder; import lombok.Getter; +import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -22,4 +24,8 @@ public abstract class BaseTimeEntity { @LastModifiedDate @Column(name = "updatedAt") private LocalDateTime updatedAt; + + @Setter + @Column(name = "deletedAt") + private LocalDateTime deletedAt; } diff --git a/src/main/java/com/example/cherrydan/common/exception/ErrorMessage.java b/src/main/java/com/example/cherrydan/common/exception/ErrorMessage.java index 9cef2014..b024f416 100644 --- a/src/main/java/com/example/cherrydan/common/exception/ErrorMessage.java +++ b/src/main/java/com/example/cherrydan/common/exception/ErrorMessage.java @@ -30,6 +30,12 @@ public enum ErrorMessage { OAUTH_USER_INFO_NOT_FOUND(BAD_REQUEST, "OAuth 사용자 정보를 가져올 수 없습니다."), OAUTH_PROVIDER_NOT_SUPPORTED(BAD_REQUEST, "지원하지 않는 OAuth 제공자입니다."), OAUTH_AUTHENTICATION_FAILED(UNAUTHORIZED, "OAuth 인증에 실패했습니다."), + OAUTH_EMAIL_NOT_FOUND(UNAUTHORIZED, "OAuth2 제공자로부터 이메일을 찾을 수 없습니다."), + OAUTH_USER_DELETED(UNAUTHORIZED, "탈퇴한 계정입니다. 계정 복구를 원하시면 고객센터에 문의해 주세요."), + OAUTH_PROVIDER_CONFLICT(UNAUTHORIZED, "이미 다른 소셜 계정으로 가입된 이메일입니다."), + OAUTH_STATE_EXPIRED(UNAUTHORIZED, "OAuth state가 만료되었습니다."), + OAUTH_STATE_INVALID(UNAUTHORIZED, "유효하지 않은 OAuth state입니다."), + OAUTH_STATE_PARSE_FAILED(UNAUTHORIZED, "OAuth state 파싱에 실패했습니다."), // Apple 관련 에러 APPLE_IDENTITY_TOKEN_INVALID(UNAUTHORIZED, "Apple Identity Token이 유효하지 않습니다."), @@ -91,12 +97,15 @@ public enum ErrorMessage { INVALID_ACTION(BAD_REQUEST, "잘못된 액션입니다. 'up' 또는 'down'을 입력해주세요."), // SNS 연동 관련 에러 + SNS_PLATFORM_CODE_EMPTY(BAD_REQUEST, "플랫폼 코드가 비어있습니다."), SNS_PLATFORM_NOT_SUPPORTED(BAD_REQUEST, "지원하지 않는 SNS 플랫폼입니다."), SNS_CONNECTION_NOT_FOUND(NOT_FOUND, "SNS 연동 정보를 찾을 수 없습니다."), SNS_TOKEN_ACQUISITION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SNS 액세스 토큰 획득에 실패했습니다."), SNS_USER_INFO_ACQUISITION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SNS 사용자 정보 획득에 실패했습니다."), SNS_CONNECTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SNS 연동에 실패했습니다."), SNS_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "SNS 토큰 갱신에 실패했습니다."), + SNS_CODE_CHALLENGE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "Code challenge 생성에 실패했습니다."), + // 문의 관련 에러 INQUIRY_NOT_FOUND(NOT_FOUND, "문의 정보를 찾을 수 없습니다."), diff --git a/src/main/java/com/example/cherrydan/common/exception/GlobalExceptionHandler.java b/src/main/java/com/example/cherrydan/common/exception/GlobalExceptionHandler.java index 6172794f..cb6960f5 100644 --- a/src/main/java/com/example/cherrydan/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/cherrydan/common/exception/GlobalExceptionHandler.java @@ -15,7 +15,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; -import org.springframework.web.bind.annotation.ResponseStatus; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; @@ -183,13 +182,22 @@ public ResponseEntity> handleExpiredJwtException(ExpiredJwtExc /** * JWT 유효성 검증 실패 예외 처리 */ - @ExceptionHandler({SignatureException.class, MalformedJwtException.class, UnsupportedJwtException.class, IllegalArgumentException.class}) + @ExceptionHandler({SignatureException.class, MalformedJwtException.class, UnsupportedJwtException.class}) public ResponseEntity> handleJwtValidationException(Exception ex) { logger.error("Invalid JWT token: {}", ex.getMessage()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResponse.error(HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다.")); } + /** + * IllegalArgumentException 예외 처리 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + logger.error("Invalid argument: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), ex.getMessage())); + } /** * 유효성 검사 실패 예외 처리 @@ -232,18 +240,28 @@ public ResponseEntity> handleRuntimeException(RuntimeException } @ExceptionHandler(OAuth2AuthenticationProcessingException.class) public ResponseEntity> handleOAuth2AuthenticationProcessingException(OAuth2AuthenticationProcessingException ex) { - logger.error("OAuth2AuthenticationProcessingException: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.error(HttpStatus.UNAUTHORIZED.value(), ex.getMessage())); + ErrorMessage errorMessage = ex.getErrorMessage(); + String message = ex.getMessage(); + HttpStatus status = HttpStatus.UNAUTHORIZED; + + if (errorMessage != null) { + status = errorMessage.getHttpStatus(); + message = errorMessage.getMessage(); + } + + logger.error("OAuth2AuthenticationProcessingException: {}", message); + return ResponseEntity.status(status) + .body(ApiResponse.error(status.value(), message)); } /** * 리소스 없음 예외 처리 */ @ExceptionHandler(NoResourceFoundException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public ApiResponse handleNoResourceFound(NoResourceFoundException ex) { - return ApiResponse.error(HttpStatus.NOT_FOUND.value(), "Not Found"); + public ResponseEntity> handleNoResourceFound(NoResourceFoundException ex) { + logger.warn("Resource not found: {}", ex.getResourcePath()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(HttpStatus.NOT_FOUND.value(), "요청하신 리소스를 찾을 수 없습니다.")); } /** diff --git a/src/main/java/com/example/cherrydan/common/exception/OAuthStateException.java b/src/main/java/com/example/cherrydan/common/exception/OAuthStateException.java new file mode 100644 index 00000000..69f88778 --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/exception/OAuthStateException.java @@ -0,0 +1,7 @@ +package com.example.cherrydan.common.exception; + +public class OAuthStateException extends BaseException { + public OAuthStateException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/example/cherrydan/common/util/CompositeAlertIterator.java b/src/main/java/com/example/cherrydan/common/util/CompositeAlertIterator.java new file mode 100644 index 00000000..f9e0152a --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/util/CompositeAlertIterator.java @@ -0,0 +1,38 @@ +package com.example.cherrydan.common.util; + +import com.example.cherrydan.activity.domain.ActivityAlert; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * 여러 Iterator를 하나로 합치는 유틸리티 + * D-1, D-Day 등 여러 조건의 알림을 순차적으로 처리 + */ +public class CompositeAlertIterator implements Iterator { + + private final List> iterators; + private int currentIndex = 0; + + @SafeVarargs + public CompositeAlertIterator(Iterator... iterators) { + this.iterators = Arrays.asList(iterators); + } + + @Override + public boolean hasNext() { + while (currentIndex < iterators.size()) { + if (iterators.get(currentIndex).hasNext()) { + return true; + } + currentIndex++; + } + return false; + } + + @Override + public ActivityAlert next() { + return iterators.get(currentIndex).next(); + } + } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/common/util/JwtSecretKeyProvider.java b/src/main/java/com/example/cherrydan/common/util/JwtSecretKeyProvider.java new file mode 100644 index 00000000..e6fbfeb8 --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/util/JwtSecretKeyProvider.java @@ -0,0 +1,41 @@ +package com.example.cherrydan.common.util; + +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class JwtSecretKeyProvider { + + private static final int MINIMUM_KEY_LENGTH = 32; + + private JwtSecretKeyProvider() { + throw new AssertionError("유틸리티 클래스는 인스턴스화할 수 없습니다"); + } + + public static SecretKey createSecretKey(String secret) { + validateSecretKey(secret); + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + private static void validateSecretKey(String secret) { + if (secret == null) { + log.error("JWT secret이 null입니다"); + throw new IllegalArgumentException("JWT secret은 필수입니다"); + } + + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + if (keyBytes.length < MINIMUM_KEY_LENGTH) { + log.error("JWT secret 길이가 부족합니다. 현재: {}바이트, 최소: {}바이트", + keyBytes.length, MINIMUM_KEY_LENGTH); + throw new IllegalArgumentException( + String.format("JWT secret은 최소 %d바이트 이상이어야 합니다 (현재: %d바이트)", + MINIMUM_KEY_LENGTH, keyBytes.length) + ); + } + + log.debug("JWT secret 검증 완료: {}바이트", keyBytes.length); + } +} diff --git a/src/main/java/com/example/cherrydan/common/util/PagedAlertIterator.java b/src/main/java/com/example/cherrydan/common/util/PagedAlertIterator.java new file mode 100644 index 00000000..0f9116e9 --- /dev/null +++ b/src/main/java/com/example/cherrydan/common/util/PagedAlertIterator.java @@ -0,0 +1,63 @@ +package com.example.cherrydan.common.util; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Collections; +import java.util.Iterator; +import java.util.function.Function; + +/** + * 페이징 기반 ActivityAlert Iterator + * 메모리 효율적인 대량 데이터 처리를 위한 유틸리티 + */ +@Slf4j +public class PagedAlertIterator implements Iterator { + + private final Function> pageLoader; + private final Function alertMapper; + private Iterator currentIterator = Collections.emptyIterator(); + private int currentPage = 0; + private boolean hasMorePages = true; + private static final int PAGE_SIZE = 500; + + public PagedAlertIterator(Function> pageLoader, + Function alertMapper) { + this.pageLoader = pageLoader; + this.alertMapper = alertMapper; + loadNextPage(); + } + + private void loadNextPage() { + if (!hasMorePages) { + return; + } + log.info("Loading page: {}", currentPage); + Page page = pageLoader.apply(PageRequest.of(currentPage++, PAGE_SIZE)); + log.info("Loaded pages: {}, Current page size: {}", page.getTotalPages(), page.getNumberOfElements()); + currentIterator = page.getContent().iterator(); + hasMorePages = page.hasNext(); + } + + @Override + public boolean hasNext() { + if (currentIterator.hasNext()) { + return true; + } + + if (hasMorePages) { + loadNextPage(); + return currentIterator.hasNext(); + } + + return false; + } + + @Override + public ActivityAlert next() { + return alertMapper.apply(currentIterator.next()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/fcm/dto/FCMTokenUpdateRequest.java b/src/main/java/com/example/cherrydan/fcm/dto/FCMTokenUpdateRequest.java index 8a433860..1dea21f2 100644 --- a/src/main/java/com/example/cherrydan/fcm/dto/FCMTokenUpdateRequest.java +++ b/src/main/java/com/example/cherrydan/fcm/dto/FCMTokenUpdateRequest.java @@ -1,5 +1,6 @@ package com.example.cherrydan.fcm.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; /** @@ -9,9 +10,10 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(description = "FCM 토큰 수정 요청") public class FCMTokenUpdateRequest { + @Schema(description = "디바이스 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long deviceId; private String fcmToken; - private Boolean isActive; private Boolean isAllowed; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/fcm/dto/NotificationRequest.java b/src/main/java/com/example/cherrydan/fcm/dto/NotificationRequest.java index de94ce03..91eb87b6 100644 --- a/src/main/java/com/example/cherrydan/fcm/dto/NotificationRequest.java +++ b/src/main/java/com/example/cherrydan/fcm/dto/NotificationRequest.java @@ -1,5 +1,6 @@ package com.example.cherrydan.fcm.dto; +import com.example.cherrydan.notification.domain.AlertMessage; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -62,10 +63,13 @@ public class NotificationRequest { @Builder.Default private String priority = "high"; - public static NotificationRequest createSimple(String title, String body) { + public static NotificationRequest create(AlertMessage alertMessage) { return NotificationRequest.builder() - .title(title) - .body(body) + .title(alertMessage.title()) + .body(alertMessage.body()) + .imageUrl(alertMessage.imageUrl()) + .data(alertMessage.data()) + .priority("high") .build(); } } diff --git a/src/main/java/com/example/cherrydan/fcm/repository/UserFCMTokenRepository.java b/src/main/java/com/example/cherrydan/fcm/repository/UserFCMTokenRepository.java index d7a803d9..bc0917ce 100644 --- a/src/main/java/com/example/cherrydan/fcm/repository/UserFCMTokenRepository.java +++ b/src/main/java/com/example/cherrydan/fcm/repository/UserFCMTokenRepository.java @@ -7,7 +7,6 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -67,4 +66,8 @@ public interface UserFCMTokenRepository extends JpaRepository findActiveTokensByUserIds(@Param("userIds") List userIds); + + @Modifying + @Query("DELETE FROM UserFCMToken t WHERE t.userId = :userId") + void deleteByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/example/cherrydan/fcm/service/FCMTokenService.java b/src/main/java/com/example/cherrydan/fcm/service/FCMTokenService.java index 31a9bca2..b0bfbe81 100644 --- a/src/main/java/com/example/cherrydan/fcm/service/FCMTokenService.java +++ b/src/main/java/com/example/cherrydan/fcm/service/FCMTokenService.java @@ -10,11 +10,9 @@ import com.example.cherrydan.fcm.repository.UserFCMTokenRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -75,15 +73,6 @@ public void updateFCMTokenWithStatus(Long userId, FCMTokenUpdateRequest request) token.updateFcmToken(request.getFcmToken()); } - // 활성화 상태 업데이트 - if (request.getIsActive() != null) { - if (request.getIsActive()) { - token.activate(); - } else { - token.deactivate(); - } - } - // 알림 허용 상태 업데이트 if (request.getIsAllowed() != null) { token.updateAllowedStatus(request.getIsAllowed()); @@ -116,31 +105,26 @@ public List getUserFCMTokens(Long userId) { @Transactional public void registerOrUpdateToken(FCMTokenRequest request) { try { - + Optional existingToken = tokenRepository .findByUserIdAndDeviceModelAndIsActiveTrue(request.getUserId(), request.getDeviceModel()); - - UserFCMToken token; - if (existingToken.isPresent()) { - token = existingToken.get(); - token.updateToken(request); - token.activate(); - log.info("FCM 토큰 업데이트 - 사용자: {}, 디바이스: {}", request.getUserId(), request.getDeviceModel()); - } else { - token = UserFCMToken.builder() - .userId(request.getUserId()) - .fcmToken(request.getFcmToken()) - .isActive(true) - .isAllowed(request.getIsAllowed() != null ? request.getIsAllowed() : true) - .deviceType(DeviceType.from(request.getDeviceType())) - .deviceModel(request.getDeviceModel()) - .appVersion(request.getAppVersion()) - .osVersion(request.getOsVersion()) - .build(); - tokenRepository.save(token); - log.info("새 FCM 토큰 등록 - 사용자: {}, 디바이스: {}", request.getUserId(), request.getDeviceModel()); + + UserFCMToken userFCMToken = existingToken + .orElseGet(() -> createInactiveFCMToken(request)); + + boolean isNewToken = existingToken.isEmpty(); + if (!isNewToken) { + userFCMToken.updateToken(request); } - + + userFCMToken.activate(); + tokenRepository.save(userFCMToken); + + log.info("{} FCM 토큰 - 사용자: {}, 디바이스: {}", + isNewToken ? "새" : "업데이트", + request.getUserId(), + request.getDeviceModel()); + } catch (IllegalArgumentException e) { log.error("잘못된 디바이스 타입: {}", request.getDeviceType()); } catch (FCMException e) { @@ -149,4 +133,42 @@ public void registerOrUpdateToken(FCMTokenRequest request) { log.error("서버 내부 에러 발생 {}", e.getMessage()); } } + + /** + * 비활성 상태의 FCM 토큰 생성 + */ + private UserFCMToken createInactiveFCMToken(FCMTokenRequest request) { + return UserFCMToken.builder() + .userId(request.getUserId()) + .fcmToken(request.getFcmToken()) + .isActive(false) + .isAllowed(request.getIsAllowed() != null ? request.getIsAllowed() : true) + .deviceType(DeviceType.from(request.getDeviceType())) + .deviceModel(request.getDeviceModel()) + .appVersion(request.getAppVersion()) + .osVersion(request.getOsVersion()) + .build(); + } + + /** + * 사용자의 모든 FCM 토큰 비활성화 (소프트 삭제) + * 사용자 탈퇴 시 호출 + */ + @Transactional + public void deactivateUserTokens(Long userId) { + List tokens = tokenRepository.findByUserId(userId); + tokens.forEach(UserFCMToken::deactivate); + log.info("사용자 {}의 모든 FCM 토큰 비활성화 완료: {} 개", userId, tokens.size()); + } + + /** + * 사용자의 모든 FCM 토큰 활성화 + * 사용자 복구 시 호출 + */ + @Transactional + public void activateUserTokens(Long userId) { + List tokens = tokenRepository.findByUserId(userId); + tokens.forEach(UserFCMToken::activate); + log.info("사용자 {}의 모든 FCM 토큰 활성화 완료: {} 개", userId, tokens.size()); + } } diff --git a/src/main/java/com/example/cherrydan/fcm/service/NotificationService.java b/src/main/java/com/example/cherrydan/fcm/service/NotificationService.java index 6c180cb3..fbf7aa1e 100644 --- a/src/main/java/com/example/cherrydan/fcm/service/NotificationService.java +++ b/src/main/java/com/example/cherrydan/fcm/service/NotificationService.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashSet; import java.util.List; import java.util.ArrayList; import java.util.stream.Collectors; @@ -28,38 +29,7 @@ public class NotificationService { private final UserFCMTokenRepository tokenRepository; - - /** - * 단일 사용자에게 알림 전송 - */ - public NotificationResultDto sendNotificationToUser(Long userId, NotificationRequest request) { - if (!isFirebaseInitialized()) { - log.error("Firebase가 초기화되지 않았습니다. FCM 기능을 사용할 수 없습니다."); - throw new NotificationException(ErrorMessage.NOTIFICATION_SERVICE_UNAVAILABLE); - } - - try { - List tokens = tokenRepository.findActiveTokensByUserId(userId); - - if (tokens.isEmpty()) { - log.error("사용자 {}의 활성화된 FCM 토큰이 없습니다.", userId); - throw new NotificationException(ErrorMessage.NOTIFICATION_USER_NO_TOKENS); - } - - List tokenStrings = tokens.stream() - .map(UserFCMToken::getFcmToken) - .collect(Collectors.toList()); - - return sendMulticastNotification(tokenStrings, request, tokens); - } catch (NotificationException e) { - log.error("사용자 {}에게 알림 전송 실패 (NotificationException): {}", userId, e.getMessage()); - return NotificationResultDto.multipleResult(0, 1, e.getMessage()); - } catch (Exception e) { - log.error("사용자 {}에게 알림 전송 실패: {}", userId, e.getMessage()); - return NotificationResultDto.multipleResult(0, 1, "알림 전송 중 오류가 발생했습니다"); - } - } - + /** * 여러 사용자에게 알림 전송 */ @@ -239,8 +209,10 @@ private void processBatchResponse(BatchResponse batchResponse, List toke * 성공한 토큰을 통해 성공한 사용자 ID 추출 */ private List extractSuccessfulUserIds(List successfulTokens, List tokenEntities) { + HashSet successfulTokensSet = new HashSet<>(successfulTokens); + return tokenEntities.stream() - .filter(tokenEntity -> successfulTokens.contains(tokenEntity.getFcmToken())) + .filter(tokenEntity -> successfulTokensSet.contains(tokenEntity.getFcmToken())) .map(UserFCMToken::getUserId) .distinct() // 한 사용자가 여러 토큰을 가질 수 있으므로 중복 제거 .collect(Collectors.toList()); diff --git a/src/main/java/com/example/cherrydan/inquiry/domain/Inquiry.java b/src/main/java/com/example/cherrydan/inquiry/domain/Inquiry.java index c511363d..9ce3be81 100644 --- a/src/main/java/com/example/cherrydan/inquiry/domain/Inquiry.java +++ b/src/main/java/com/example/cherrydan/inquiry/domain/Inquiry.java @@ -30,7 +30,8 @@ public class Inquiry extends BaseTimeEntity { @Column(name = "title", nullable = false, length = 100) private String title; - @Column(name = "content", columnDefinition = "TEXT") + @Lob + @Column(name = "content") private String content; @Enumerated(EnumType.STRING) @@ -38,7 +39,8 @@ public class Inquiry extends BaseTimeEntity { @Builder.Default private InquiryStatus status = InquiryStatus.PENDING; - @Column(name = "admin_reply", columnDefinition = "TEXT") + @Lob + @Column(name = "admin_reply") private String adminReply; @Column(name = "admin_id") diff --git a/src/main/java/com/example/cherrydan/inquiry/repository/InquiryRepository.java b/src/main/java/com/example/cherrydan/inquiry/repository/InquiryRepository.java index 66fb0c1f..b72f4f4a 100644 --- a/src/main/java/com/example/cherrydan/inquiry/repository/InquiryRepository.java +++ b/src/main/java/com/example/cherrydan/inquiry/repository/InquiryRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -17,4 +18,8 @@ public interface InquiryRepository extends JpaRepository { @Query("SELECT i FROM Inquiry i WHERE i.user.id = :userId AND i.status = :status AND i.user.isActive = true ORDER BY i.createdAt DESC") Page findByUserIdAndStatusOrderByCreatedAtDesc(@Param("userId") Long userId, @Param("status") InquiryStatus status, Pageable pageable); + + @Modifying + @Query("DELETE FROM Inquiry i WHERE i.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/com/example/cherrydan/notice/domain/NoticeBanner.java b/src/main/java/com/example/cherrydan/notice/domain/NoticeBanner.java index e691e77a..7ae65ca0 100644 --- a/src/main/java/com/example/cherrydan/notice/domain/NoticeBanner.java +++ b/src/main/java/com/example/cherrydan/notice/domain/NoticeBanner.java @@ -25,7 +25,8 @@ public class NoticeBanner extends BaseTimeEntity { private String title; private String subTitle; - private String imageUrl; + private String backgroundColor; + private int priority; private String bannerType; // NOTICE, EVENT, AD 등 private String linkType; // INTERNAL, EXTERNAL private Long targetId; // 내부 이동용(상세 id) diff --git a/src/main/java/com/example/cherrydan/notice/dto/NoticeBannerResponseDTO.java b/src/main/java/com/example/cherrydan/notice/dto/NoticeBannerResponseDTO.java index 32c3e03b..3106f888 100644 --- a/src/main/java/com/example/cherrydan/notice/dto/NoticeBannerResponseDTO.java +++ b/src/main/java/com/example/cherrydan/notice/dto/NoticeBannerResponseDTO.java @@ -5,6 +5,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import io.swagger.v3.oas.annotations.media.Schema; @Getter @NoArgsConstructor @@ -14,10 +15,11 @@ public class NoticeBannerResponseDTO { private Long id; private String title; private String subTitle; - private String imageUrl; + private String backgroundColor; private String bannerType; // NOTICE, EVENT, AD 등 private String linkType; // INTERNAL, EXTERNAL + @Schema(description = "내부 이동용(상세 id)", nullable = true) private Long targetId; // 내부 이동용(상세 id) - private String targetUrl; // 외부 이동용(광고 url) - private LocalDateTime updatedAt; + @Schema(description = "외부 이동용 URL", nullable = true) + private String targetUrl; // 외부 이동용 URL } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/notice/repository/NoticeBannerRepository.java b/src/main/java/com/example/cherrydan/notice/repository/NoticeBannerRepository.java index 90dda45e..a9ce77a1 100644 --- a/src/main/java/com/example/cherrydan/notice/repository/NoticeBannerRepository.java +++ b/src/main/java/com/example/cherrydan/notice/repository/NoticeBannerRepository.java @@ -5,5 +5,5 @@ import java.util.List; public interface NoticeBannerRepository extends JpaRepository { - List findByIsActiveTrue(); + List findByIsActiveTrueOrderByPriorityAsc(); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/notice/service/NoticeBannerServiceImpl.java b/src/main/java/com/example/cherrydan/notice/service/NoticeBannerServiceImpl.java index 69e27d1f..e8ebd9ae 100644 --- a/src/main/java/com/example/cherrydan/notice/service/NoticeBannerServiceImpl.java +++ b/src/main/java/com/example/cherrydan/notice/service/NoticeBannerServiceImpl.java @@ -15,12 +15,12 @@ public class NoticeBannerServiceImpl implements NoticeBannerService { @Override public List getActiveBanners() { - List banners = noticeBannerRepository.findByIsActiveTrue(); + List banners = noticeBannerRepository.findByIsActiveTrueOrderByPriorityAsc(); return banners.stream().map(banner -> NoticeBannerResponseDTO.builder() .id(banner.getId()) .title(banner.getTitle()) .subTitle(banner.getSubTitle()) - .imageUrl(banner.getImageUrl()) + .backgroundColor(banner.getBackgroundColor()) .bannerType(banner.getBannerType()) .linkType(banner.getLinkType()) .targetId(banner.getTargetId()) diff --git a/src/main/java/com/example/cherrydan/notification/domain/AlertMessage.java b/src/main/java/com/example/cherrydan/notification/domain/AlertMessage.java new file mode 100644 index 00000000..c57f3454 --- /dev/null +++ b/src/main/java/com/example/cherrydan/notification/domain/AlertMessage.java @@ -0,0 +1,13 @@ +package com.example.cherrydan.notification.domain; + +import java.util.Map; + +public interface AlertMessage { + String title(); + String body(); + Map data(); + + default String imageUrl() { + return null; + } +} diff --git a/src/main/java/com/example/cherrydan/notification/scheduler/NotificationScheduler.java b/src/main/java/com/example/cherrydan/notification/scheduler/NotificationScheduler.java index 8e5c651f..c52c5f14 100644 --- a/src/main/java/com/example/cherrydan/notification/scheduler/NotificationScheduler.java +++ b/src/main/java/com/example/cherrydan/notification/scheduler/NotificationScheduler.java @@ -1,6 +1,7 @@ package com.example.cherrydan.notification.scheduler; import com.example.cherrydan.activity.service.ActivityAlertService; +import com.example.cherrydan.user.service.UserDataCleanupService; import com.example.cherrydan.user.service.UserKeywordService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,13 +16,14 @@ @Component @RequiredArgsConstructor public class NotificationScheduler { - + private final ActivityAlertService activityAlertService; private final UserKeywordService userKeywordService; + private final UserDataCleanupService userDataCleanupService; /** - * 10분마다 실행 - 키워드 맞춤 알림 발송 (테스트용) + * 오전 11시에 실행 */ @Scheduled(cron = "0 0 11 * * ?", zone = "Asia/Seoul") public void sendDailyKeywordNotifications() { @@ -39,9 +41,9 @@ public void sendDailyKeywordNotifications() { } /** - * 10분마다 실행 - 키워드 맞춤 알림 대상 업데이트 (테스트용, 알림 발송보다 먼저) + * 오전 8시에 실행 - 키워드 맞춤 알림 대상 업데이트 */ - @Scheduled(cron = "0 30 7 * * ?", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 8 * * ?", zone = "Asia/Seoul") public void updateKeywordCampaignAlerts() { log.info("=== 새벽 키워드 알림 업데이트 작업 시작 ==="); @@ -57,9 +59,9 @@ public void updateKeywordCampaignAlerts() { } /** - * 10분마다 실행 - 활동 알림 대상 업데이트 (테스트용, 알림 발송보다 먼저) + * 오전 7시에 실행 - 활동 알림 대상 업데이트 */ - @Scheduled(cron = "0 0 6 * * ?", zone = "Asia/Seoul") + @Scheduled(cron = "0 0 7 * * ?", zone = "Asia/Seoul") public void updateActivityAlerts() { log.info("=== 활동 알림 업데이트 작업 시작 ==="); @@ -74,19 +76,36 @@ public void updateActivityAlerts() { } /** - * 10분마다 실행 - 활동 알림 발송 (테스트용) + * 오전 10시에 실행 - 활동 알림 발송 */ @Scheduled(cron = "0 0 10 * * ?", zone = "Asia/Seoul") public void sendActivityNotifications() { log.info("=== 활동 알림 발송 작업 시작 ==="); - + try { activityAlertService.sendActivityNotifications(); - + log.info("=== 활동 알림 발송 작업 완료 ==="); - + } catch (Exception e) { log.error("활동 알림 발송 작업 실패: {}", e.getMessage(), e); } } + + /** + * 매일 새벽 2시 실행 - 1년 경과한 소프트 딜리트 유저의 연관 데이터 삭제 + */ + @Scheduled(cron = "0 0 2 * * ?", zone = "Asia/Seoul") + public void cleanupExpiredUserData() { + log.info("=== 1년 경과 유저 데이터 삭제 작업 시작 ==="); + + try { + userDataCleanupService.cleanupExpiredUserData(); + + log.info("=== 1년 경과 유저 데이터 삭제 작업 완료 ==="); + + } catch (Exception e) { + log.error("1년 경과 유저 데이터 삭제 작업 실패: {}", e.getMessage(), e); + } + } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/config/SecurityConfig.java b/src/main/java/com/example/cherrydan/oauth/config/SecurityConfig.java index 3e8d9844..2d986a50 100644 --- a/src/main/java/com/example/cherrydan/oauth/config/SecurityConfig.java +++ b/src/main/java/com/example/cherrydan/oauth/config/SecurityConfig.java @@ -1,9 +1,7 @@ package com.example.cherrydan.oauth.config; +import com.example.cherrydan.oauth.security.jwt.CustomAuthenticationEntryPoint; import com.example.cherrydan.oauth.security.jwt.JwtAuthenticationFilter; -import com.example.cherrydan.oauth.security.oauth2.CustomOAuth2UserService; -import com.example.cherrydan.oauth.security.oauth2.OAuth2AuthenticationFailureHandler; -import com.example.cherrydan.oauth.security.oauth2.OAuth2AuthenticationSuccessHandler; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,10 +22,8 @@ @RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; - private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -47,24 +43,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // Apple 테스트 경로 허용 .requestMatchers("/api/auth/apple/**","/api/auth/naver/**","/api/auth/kakao/**","/api/auth/google/**").permitAll() .requestMatchers("/apple-login-test.html").permitAll() - // OAuth2 관련 경로 - .requestMatchers("/api/oauth2/**", "/api/login/oauth2/**").permitAll() // 캠페인 관련 경로 .requestMatchers("/api/campaigns/**").permitAll() // 공지사항/홈 광고 배너 관련 경로 .requestMatchers("/api/noticeboard/**").permitAll() .requestMatchers("/api/mypage/version").permitAll() + // sns 연동 콜백 + .requestMatchers("/api/v1/sns/oauth/**").permitAll() // 헬스 체크 관련 .requestMatchers("/actuator/**").permitAll() // 나머지는 인증 필요 .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .authorizationEndpoint(endpoint -> endpoint.baseUri("/api/oauth2/authorization")) - .redirectionEndpoint(endpoint -> endpoint.baseUri("/api/login/oauth2/code/*")) - .userInfoEndpoint(endpoint -> endpoint.userService(customOAuth2UserService)) - .successHandler(oAuth2AuthenticationSuccessHandler) - .failureHandler(oAuth2AuthenticationFailureHandler) + // 인증 실패 시 커스텀 EntryPoint 사용 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(customAuthenticationEntryPoint) ) // JWT 필터 추가 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/example/cherrydan/oauth/controller/AppleAuthController.java b/src/main/java/com/example/cherrydan/oauth/controller/AppleAuthController.java deleted file mode 100644 index 7672b5d7..00000000 --- a/src/main/java/com/example/cherrydan/oauth/controller/AppleAuthController.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.example.cherrydan.oauth.controller; - -import com.example.cherrydan.common.exception.AuthException; -import com.example.cherrydan.common.exception.ErrorMessage; -import com.example.cherrydan.common.response.ApiResponse; - -import com.example.cherrydan.oauth.dto.AppleLoginRequest; -import com.example.cherrydan.oauth.dto.LoginResponse; -import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; -import com.example.cherrydan.oauth.security.oauth2.CustomOAuth2UserService; -import com.example.cherrydan.oauth.security.oauth2.user.AppleOAuth2UserInfo; - -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; -import com.example.cherrydan.oauth.service.AppleIdentityTokenService; -import com.example.cherrydan.oauth.service.RefreshTokenService; -import com.example.cherrydan.user.domain.User; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@Slf4j -@RestController -@RequestMapping("/api/auth/apple") -@RequiredArgsConstructor -@Tag(name = "Apple 인증", description = "Apple Sign in with Apple 관련 API") -public class AppleAuthController { - - private final AppleIdentityTokenService appleIdentityTokenService; - private final CustomOAuth2UserService customOAuth2UserService; - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenService refreshTokenService; - - @PostMapping("/login") - @Operation(summary = "Apple 로그인", description = "iOS에서 받은 Identity Token으로 Apple 로그인 처리") - public ResponseEntity> appleLogin(@RequestBody AppleLoginRequest request) { - // 1. 입력 값 검증 - validateAppleLoginRequest(request); - - // 2. Apple Identity Token 검증 - Map userInfo = appleIdentityTokenService.verifyIdentityToken(request.getAccessToken()); - - // 3. OAuth2UserInfo 객체 생성 (JWT 정보 + iOS 정보 결합) - OAuth2UserInfo oAuth2UserInfo = new AppleOAuth2UserInfo(userInfo); - - // 4. 사용자 조회 또는 생성 (CustomOAuth2UserService 사용) - User user = customOAuth2UserService.processAppleUser(oAuth2UserInfo, request); - - // 5. Access Token과 Refresh Token 생성 - TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail()); - - // 6. Refresh Token을 DB에 저장 - refreshTokenService.saveOrUpdateRefreshToken(user, tokenDTO.getRefreshToken()); - - log.info("Apple 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); - - return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO,user.getId()))); - } - - /** - * Apple 로그인 요청 검증 - */ - private void validateAppleLoginRequest(AppleLoginRequest request) { - if (request == null) { - throw new AuthException(ErrorMessage.INVALID_REQUEST); - } - - if (!StringUtils.hasText(request.getAccessToken())) { - throw new AuthException(ErrorMessage.APPLE_USER_INFO_MISSING); - } - - // JWT 형식 기본 검증 (3개 파트로 구성되어야 함) - String[] tokenParts = request.getAccessToken().split("\\."); - if (tokenParts.length != 3) { - throw new AuthException(ErrorMessage.APPLE_IDENTITY_TOKEN_INVALID); - } - } -} diff --git a/src/main/java/com/example/cherrydan/oauth/controller/GoogleAuthController.java b/src/main/java/com/example/cherrydan/oauth/controller/GoogleAuthController.java deleted file mode 100644 index 90413c79..00000000 --- a/src/main/java/com/example/cherrydan/oauth/controller/GoogleAuthController.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.example.cherrydan.oauth.controller; - -import com.example.cherrydan.common.response.ApiResponse; - -import com.example.cherrydan.oauth.dto.GoogleLoginRequest; -import com.example.cherrydan.oauth.dto.LoginResponse; -import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; -import com.example.cherrydan.oauth.security.oauth2.CustomOAuth2UserService; -import com.example.cherrydan.oauth.security.oauth2.user.GoogleOAuth2UserInfo; - -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; -import com.example.cherrydan.oauth.service.GoogleIdentityTokenService; -import com.example.cherrydan.oauth.service.RefreshTokenService; -import com.example.cherrydan.user.domain.User; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@Slf4j -@RestController -@RequestMapping("/api/auth/google") -@RequiredArgsConstructor -@Tag(name = "Google 인증", description = "Google Sign-In 관련 API") -public class GoogleAuthController { - private final GoogleIdentityTokenService googleIdentityTokenService; - private final CustomOAuth2UserService customOAuth2UserService; - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenService refreshTokenService; - - @PostMapping("/login") - @Operation( - summary = "Google 모바일 로그인/회원가입", - description = """ - ### Google 모바일 SDK를 통해 로그인 후, 받은 ID Token으로 서버에 로그인/회원가입을 요청하는 API 입니다. - - **모바일 클라이언트 개발 순서:** - 1. 각 플랫폼(iOS/Android)에 맞는 Google Sign-In SDK를 사용하여 사용자의 구글 로그인을 처리합니다. - 2. 로그인 성공 시, Google로부터 **ID Token** 문자열을 발급받습니다. - 3. 발급받은 ID Token을 이 API의 Body에 담아 요청합니다. - 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. - 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. - - --- - - **iOS (Swift) 요청 예시:** - ```swift - guard let url = URL(string: "cherrydan.com/api/auth/google/login") else { return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body = ["idToken": "google_id_token_string"] - request.httpBody = try? JSONEncoder().encode(body) - - URLSession.shared.dataTask(with: request) { data, response, error in - // Handle response... - }.resume() - ``` - - **Android (Kotlin with Retrofit) 요청 예시:** - ```kotlin - // Retrofit Interface - interface ApiService { - @POST("api/auth/google/login") - suspend fun loginWithGoogle(@Body body: GoogleLoginRequest): Response - } - - // DTO - data class GoogleLoginRequest(val idToken: String) - - // ViewModel or Repository - suspend fun performGoogleLogin(idToken: String) { - val request = GoogleLoginRequest(idToken = idToken) - val response = yourRetrofitService.loginWithGoogle(request) - // Handle response... - } - ``` - """ - ) - public ResponseEntity> googleLogin(@RequestBody GoogleLoginRequest request) { - // 1. Google ID Token 검증 - GoogleIdToken.Payload payload = googleIdentityTokenService.verify(request.getAccessToken()); - - // 2. OAuth2UserInfo 객체 생성 - OAuth2UserInfo oAuth2UserInfo = new GoogleOAuth2UserInfo(payload); - - // 3. 사용자 조회 또는 생성 - User user = customOAuth2UserService.processGoogleUser(oAuth2UserInfo, request); - - // 4. Access Token과 Refresh Token 생성 - TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail()); - - // 5. Refresh Token을 DB에 저장 - refreshTokenService.saveOrUpdateRefreshToken(user, tokenDTO.getRefreshToken()); - - log.info("Google 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); - - return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/controller/KakaoAuthController.java b/src/main/java/com/example/cherrydan/oauth/controller/KakaoAuthController.java deleted file mode 100644 index e95e7cbe..00000000 --- a/src/main/java/com/example/cherrydan/oauth/controller/KakaoAuthController.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.example.cherrydan.oauth.controller; - -import com.example.cherrydan.common.response.ApiResponse; - -import com.example.cherrydan.oauth.dto.KakaoLoginRequest; -import com.example.cherrydan.oauth.dto.LoginResponse; -import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; -import com.example.cherrydan.oauth.security.oauth2.CustomOAuth2UserService; -import com.example.cherrydan.oauth.security.oauth2.user.KakaoOAuth2UserInfo; - -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; -import com.example.cherrydan.oauth.service.KakaoOAuthService; -import com.example.cherrydan.oauth.service.RefreshTokenService; -import com.example.cherrydan.user.domain.User; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@Slf4j -@RestController -@RequestMapping("/api/auth/kakao") -@RequiredArgsConstructor -@Tag(name = "Kakao 인증", description = "Kakao 로그인 관련 API") -public class KakaoAuthController { - - private final KakaoOAuthService kakaoOAuthService; - private final CustomOAuth2UserService customOAuth2UserService; - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenService refreshTokenService; - - @PostMapping("/login") - @Operation( - summary = "Kakao 모바일 로그인/회원가입", - description = """ - ### Kakao 모바일 SDK를 통해 로그인 후, 받은 액세스 토큰으로 서버에 로그인/회원가입을 요청하는 API 입니다. - - **모바일 클라이언트 개발 순서:** - 1. 각 플랫폼(iOS/Android)에 맞는 Kakao SDK를 사용하여 사용자의 카카오 로그인을 처리합니다. - 2. 로그인 성공 시, Kakao로부터 **액세스 토큰** 문자열을 발급받습니다. - 3. 발급받은 액세스 토큰을 이 API의 Body에 담아 요청합니다. - 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. - 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. - - --- - - **iOS (Swift) 요청 예시:** - ```swift - guard let url = URL(string: "cherrydan.com/api/auth/kakao/login") else { return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body = ["accessToken": "kakao_access_token_string"] - request.httpBody = try? JSONEncoder().encode(body) - - URLSession.shared.dataTask(with: request) { data, response, error in - // Handle response... - }.resume() - ``` - - **Android (Kotlin with Retrofit) 요청 예시:** - ```kotlin - // Retrofit Interface - interface ApiService { - @POST("api/auth/kakao/login") - suspend fun loginWithKakao(@Body body: KakaoLoginRequest): Response - } - - // DTO - data class KakaoLoginRequest(val accessToken: String) - - // ViewModel or Repository - suspend fun performKakaoLogin(accessToken: String) { - val request = KakaoLoginRequest(accessToken = accessToken) - val response = yourRetrofitService.loginWithKakao(request) - // Handle response... - } - ``` - """ - ) - public ResponseEntity> kakaoMobileLogin(@RequestBody KakaoLoginRequest request) { - // 1. Kakao 액세스 토큰으로 사용자 정보 조회 - Map kakaoUserInfo = kakaoOAuthService.getUserInfo(request.getAccessToken()); - - // 2. OAuth2UserInfo 객체 생성 - OAuth2UserInfo oAuth2UserInfo = new KakaoOAuth2UserInfo(kakaoUserInfo); - - // 3. 사용자 조회 또는 생성 - User user = customOAuth2UserService.processKakaoUser(oAuth2UserInfo, request); - - // 4. Access Token과 Refresh Token 생성 - TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail()); - - // 5. Refresh Token을 DB에 저장 - refreshTokenService.saveOrUpdateRefreshToken(user, tokenDTO.getRefreshToken()); - - log.info("Kakao 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); - - return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/controller/NaverAuthController.java b/src/main/java/com/example/cherrydan/oauth/controller/NaverAuthController.java deleted file mode 100644 index cbe99c9c..00000000 --- a/src/main/java/com/example/cherrydan/oauth/controller/NaverAuthController.java +++ /dev/null @@ -1,107 +0,0 @@ -package com.example.cherrydan.oauth.controller; - -import com.example.cherrydan.common.response.ApiResponse; - -import com.example.cherrydan.oauth.dto.LoginResponse; -import com.example.cherrydan.oauth.dto.NaverLoginRequest; -import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; -import com.example.cherrydan.oauth.security.oauth2.CustomOAuth2UserService; -import com.example.cherrydan.oauth.security.oauth2.user.NaverOAuth2UserInfo; - -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; -import com.example.cherrydan.oauth.service.NaverOAuthService; -import com.example.cherrydan.oauth.service.RefreshTokenService; -import com.example.cherrydan.user.domain.User; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; - -@Slf4j -@RestController -@RequestMapping("/api/auth/naver") -@RequiredArgsConstructor -@Tag(name = "Naver 인증", description = "Naver 로그인 관련 API") -public class NaverAuthController { - - private final NaverOAuthService naverOAuthService; - private final CustomOAuth2UserService customOAuth2UserService; - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenService refreshTokenService; - - @PostMapping("/login") - @Operation( - summary = "Naver 모바일 로그인/회원가입", - description = """ - ### Naver 모바일 SDK를 통해 로그인 후, 받은 액세스 토큰으로 서버에 로그인/회원가입을 요청하는 API 입니다. - - **모바일 클라이언트 개발 순서:** - 1. 각 플랫폼(iOS/Android)에 맞는 Naver SDK를 사용하여 사용자의 네이버 로그인을 처리합니다. - 2. 로그인 성공 시, Naver로부터 **액세스 토큰** 문자열을 발급받습니다. - 3. 발급받은 액세스 토큰을 이 API의 Body에 담아 요청합니다. - 4. 요청 성공 시, 응답으로 받은 **accessToken**과 **refreshToken**을 앱 내 안전한 곳(e.g., Keychain, Keystore)에 저장합니다. - 5. 이후 저희 서비스의 다른 API를 호출할 때는, `Authorization` 헤더에 `Bearer {accessToken}` 형식으로 토큰을 담아 요청합니다. - - --- - - **iOS (Swift) 요청 예시:** - ```swift - guard let url = URL(string: "cherrydan.com/api/auth/naver/login") else { return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body = ["accessToken": "naver_access_token_string"] - request.httpBody = try? JSONEncoder().encode(body) - - URLSession.shared.dataTask(with: request) { data, response, error in - // Handle response... - }.resume() - ``` - - **Android (Kotlin with Retrofit) 요청 예시:** - ```kotlin - // Retrofit Interface - interface ApiService { - @POST("api/auth/naver/login") - suspend fun loginWithNaver(@Body body: NaverLoginRequest): Response - } - - // DTO - data class NaverLoginRequest(val accessToken: String) - - // ViewModel or Repository - suspend fun performNaverLogin(accessToken: String) { - val request = NaverLoginRequest(accessToken = accessToken) - val response = yourRetrofitService.loginWithNaver(request) - // Handle response... - } - ``` - """ - ) - public ResponseEntity> naverMobileLogin(@RequestBody NaverLoginRequest request) { - // 1. Naver 액세스 토큰으로 사용자 정보 조회 - Map naverUserInfo = naverOAuthService.getUserInfo(request.getAccessToken()); - - // 2. OAuth2UserInfo 객체 생성 - OAuth2UserInfo oAuth2UserInfo = new NaverOAuth2UserInfo(naverUserInfo); - - // 3. 사용자 조회 또는 생성 - User user = customOAuth2UserService.processNaverUser(oAuth2UserInfo, request); - - // 4. Access Token과 Refresh Token 생성 - TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail()); - - // 5. Refresh Token을 DB에 저장 - refreshTokenService.saveOrUpdateRefreshToken(user, tokenDTO.getRefreshToken()); - - log.info("Naver 모바일 로그인 성공: userId={}, email={}, name={}", user.getId(), user.getEmail(), user.getName()); - - return ResponseEntity.ok(ApiResponse.success(new LoginResponse(tokenDTO, user.getId()))); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/controller/OAuthController.java b/src/main/java/com/example/cherrydan/oauth/controller/OAuthController.java new file mode 100644 index 00000000..11c8ac29 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/controller/OAuthController.java @@ -0,0 +1,75 @@ +package com.example.cherrydan.oauth.controller; + +import com.example.cherrydan.common.response.ApiResponse; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.dto.*; +import com.example.cherrydan.oauth.service.OAuthFacade; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 통합 OAuth 인증 컨트롤러 + * 기존 API 경로를 유지하면서 내부 구조만 개선 + * 각 제공자별 엔드포인트 제공 + */ +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "OAuth 인증", description = "OAuth 로그인 통합 API") +public class OAuthController { + + private final OAuthFacade oAuthFacade; + + /** + * Kakao 로그인 - 기존 경로 유지 + */ + @PostMapping("/kakao/login") + @Operation(summary = "Kakao 로그인", description = "Kakao OAuth 로그인 처리") + public ResponseEntity> kakaoLogin( + @RequestBody KakaoLoginRequest request) { + log.info("Kakao login request received"); + LoginResponse response = oAuthFacade.processOAuthLogin(AuthProvider.KAKAO, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * Google 로그인 - 기존 경로 유지 + */ + @PostMapping("/google/login") + @Operation(summary = "Google 로그인", description = "Google OAuth 로그인 처리") + public ResponseEntity> googleLogin( + @RequestBody GoogleLoginRequest request) { + log.info("Google login request received"); + LoginResponse response = oAuthFacade.processOAuthLogin(AuthProvider.GOOGLE, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * Naver 로그인 - 기존 경로 유지 + */ + @PostMapping("/naver/login") + @Operation(summary = "Naver 로그인", description = "Naver OAuth 로그인 처리") + public ResponseEntity> naverLogin( + @RequestBody NaverLoginRequest request) { + log.info("Naver login request received"); + LoginResponse response = oAuthFacade.processOAuthLogin(AuthProvider.NAVER, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * Apple 로그인 - 기존 경로 유지 + */ + @PostMapping("/apple/login") + @Operation(summary = "Apple 로그인", description = "Apple OAuth 로그인 처리") + public ResponseEntity> appleLogin( + @RequestBody AppleLoginRequest request) { + log.info("Apple login request received"); + LoginResponse response = oAuthFacade.processOAuthLogin(AuthProvider.APPLE, request); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/model/AuthProvider.java b/src/main/java/com/example/cherrydan/oauth/domain/AuthProvider.java similarity index 97% rename from src/main/java/com/example/cherrydan/oauth/model/AuthProvider.java rename to src/main/java/com/example/cherrydan/oauth/domain/AuthProvider.java index 9f13d43e..8aaedb13 100644 --- a/src/main/java/com/example/cherrydan/oauth/model/AuthProvider.java +++ b/src/main/java/com/example/cherrydan/oauth/domain/AuthProvider.java @@ -1,4 +1,4 @@ -package com.example.cherrydan.oauth.model; +package com.example.cherrydan.oauth.domain; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/com/example/cherrydan/oauth/model/RefreshToken.java b/src/main/java/com/example/cherrydan/oauth/domain/RefreshToken.java similarity index 93% rename from src/main/java/com/example/cherrydan/oauth/model/RefreshToken.java rename to src/main/java/com/example/cherrydan/oauth/domain/RefreshToken.java index 066acd37..a76ddbd4 100644 --- a/src/main/java/com/example/cherrydan/oauth/model/RefreshToken.java +++ b/src/main/java/com/example/cherrydan/oauth/domain/RefreshToken.java @@ -1,4 +1,4 @@ -package com.example.cherrydan.oauth.model; +package com.example.cherrydan.oauth.domain; import com.example.cherrydan.common.entity.BaseTimeEntity; import com.example.cherrydan.user.domain.User; diff --git a/src/main/java/com/example/cherrydan/oauth/dto/UserInfoDTO.java b/src/main/java/com/example/cherrydan/oauth/dto/UserInfoDTO.java index ae07e73f..bf5f3d53 100644 --- a/src/main/java/com/example/cherrydan/oauth/dto/UserInfoDTO.java +++ b/src/main/java/com/example/cherrydan/oauth/dto/UserInfoDTO.java @@ -1,6 +1,6 @@ package com.example.cherrydan.oauth.dto; -import com.example.cherrydan.oauth.model.AuthProvider; +import com.example.cherrydan.oauth.domain.AuthProvider; import com.example.cherrydan.user.domain.User; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/example/cherrydan/oauth/repository/RefreshTokenRepository.java b/src/main/java/com/example/cherrydan/oauth/repository/RefreshTokenRepository.java index be087a3b..1957af7f 100644 --- a/src/main/java/com/example/cherrydan/oauth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/example/cherrydan/oauth/repository/RefreshTokenRepository.java @@ -1,15 +1,23 @@ package com.example.cherrydan.oauth.repository; -import com.example.cherrydan.oauth.model.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import com.example.cherrydan.oauth.domain.RefreshToken; + import java.util.Optional; @Repository public interface RefreshTokenRepository extends JpaRepository { Optional findByRefreshToken(String refreshToken); Optional findByUserId(Long userId); - void deleteByUserId(Long userId); + + @Modifying + @Query("DELETE FROM RefreshToken rt WHERE rt.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); + Optional findByUserIdAndRefreshToken(Long userId, String refreshToken); } diff --git a/src/main/java/com/example/cherrydan/oauth/security/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/cherrydan/oauth/security/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..6968f247 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/security/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,42 @@ +package com.example.cherrydan.oauth.security.jwt; + +import com.example.cherrydan.common.response.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private static final String INVALID_TOKEN_MESSAGE = "유효하지 않은 토큰입니다."; + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.warn("인증되지 않은 사용자의 접근 시도: {}", request.getRequestURI()); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse errorResponse = ApiResponse.error( + HttpStatus.UNAUTHORIZED.value(), + INVALID_TOKEN_MESSAGE + ); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtAuthenticationFilter.java index 2b0f6596..f356a21d 100644 --- a/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtAuthenticationFilter.java @@ -72,11 +72,13 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } catch (ExpiredJwtException e) { log.warn("토큰이 만료되었습니다: {}", e.getMessage()); - sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."); + filterChain.doFilter(request, response); +// sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."); } catch (Exception ex) { log.error("JWT 인증 처리 중 오류 발생: {}", ex.getMessage()); SecurityContextHolder.clearContext(); - sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."); + filterChain.doFilter(request, response); +// sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."); } } diff --git a/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtTokenProvider.java b/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtTokenProvider.java index 83e14cef..004537c5 100644 --- a/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/example/cherrydan/oauth/security/jwt/JwtTokenProvider.java @@ -1,15 +1,14 @@ package com.example.cherrydan.oauth.security.jwt; +import com.example.cherrydan.common.util.JwtSecretKeyProvider; import com.example.cherrydan.oauth.dto.TokenDTO; import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.SignatureException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; @@ -26,7 +25,7 @@ public JwtTokenProvider( @Value("${jwt.secret}") String secret, @Value("${jwt.access-token.validity-in-minutes:60}") long accessTokenValidityInMinutes, @Value("${jwt.refresh-token.validity-in-days:14}") long refreshTokenValidityInDays) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.secretKey = JwtSecretKeyProvider.createSecretKey(secret); this.accessTokenValidityInMinutes = accessTokenValidityInMinutes; this.refreshTokenValidityInDays = refreshTokenValidityInDays; } @@ -94,6 +93,7 @@ public void validateToken(String token) { throw e; } catch (UnsupportedJwtException e) { log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); + throw e; } catch (MalformedJwtException e) { log.error("잘못된 형식의 JWT 토큰입니다: {}", e.getMessage()); throw e; diff --git a/src/main/java/com/example/cherrydan/oauth/security/jwt/UserDetailsImpl.java b/src/main/java/com/example/cherrydan/oauth/security/jwt/UserDetailsImpl.java index 58b8b7d9..3def5919 100644 --- a/src/main/java/com/example/cherrydan/oauth/security/jwt/UserDetailsImpl.java +++ b/src/main/java/com/example/cherrydan/oauth/security/jwt/UserDetailsImpl.java @@ -1,6 +1,6 @@ package com.example.cherrydan.oauth.security.jwt; -import com.example.cherrydan.oauth.model.AuthProvider; +import com.example.cherrydan.oauth.domain.AuthProvider; import com.example.cherrydan.user.domain.User; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserService.java b/src/main/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserService.java deleted file mode 100644 index df5eeb6a..00000000 --- a/src/main/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserService.java +++ /dev/null @@ -1,251 +0,0 @@ -package com.example.cherrydan.oauth.security.oauth2; - -import com.example.cherrydan.common.exception.AuthException; -import com.example.cherrydan.common.exception.ErrorMessage; -import com.example.cherrydan.fcm.dto.FCMTokenRequest; -import com.example.cherrydan.fcm.service.FCMTokenService; -import com.example.cherrydan.oauth.dto.AppleLoginRequest; -import com.example.cherrydan.oauth.dto.LoginRequest; -import com.example.cherrydan.oauth.model.AuthProvider; -import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; -import com.example.cherrydan.oauth.security.oauth2.exception.OAuth2AuthenticationProcessingException; -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; -import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfoFactory; -import com.example.cherrydan.user.domain.Role; -import com.example.cherrydan.user.domain.User; -import com.example.cherrydan.user.domain.UserLoginHistory; -import com.example.cherrydan.user.repository.UserRepository; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.authentication.InternalAuthenticationServiceException; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; -import com.example.cherrydan.user.repository.UserLoginHistoryRepository; - - -import java.time.LocalDateTime; -import java.util.Optional; - -/** - * 커스텀 OAuth2 사용자 서비스 - * Custom OAuth2 user service that processes OAuth2 login and user registration - */ -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - - private final UserRepository userRepository; - private final UserLoginHistoryRepository userLoginHistoryRepository; - private final FCMTokenService fcmTokenService; - - /** - * OAuth2 사용자 정보 로드 - * Load OAuth2 user information from the provider - */ - @Override - public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); - - try { - return processOAuth2User(oAuth2UserRequest, oAuth2User); - } catch (OAuth2AuthenticationProcessingException ex) { - throw ex; - } catch (Exception ex) { - log.error("OAuth2 인증 처리 중 오류 발생: {}", ex.getMessage()); - throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause()); - } - } - - /** - * OAuth2 사용자 정보 처리 - * Process OAuth2 user information and register or update user - */ - private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { - // 등록 ID 및 사용자 정보 가져오기 (Get registration ID and user info) - String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId(); - OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); - - // 이메일 확인 (Validate email) - validateEmail(oAuth2UserInfo); - - // 사용자 조회 또는 생성 (Find or create user) - User user = findOrCreateUser(oAuth2UserInfo, registrationId); - - saveLoginHistory(user.getId()); - - // UserDetails 객체 생성 및 반환 (Build and return UserDetails) - return UserDetailsImpl.build(user, oAuth2User.getAttributes()); - } - - /** - * OAuth 사용자 정보 처리 (공통 메서드 - 다형성 활용) - * Process OAuth user information using polymorphism - */ - public User processOAuthUser(OAuth2UserInfo userInfo, String provider, LoginRequest loginRequest) { - try { - validateEmail(userInfo); - User user = findOrCreateUser(userInfo, provider); - saveLoginHistory(user.getId()); - registerFCMTokenIfPresent(user.getId(), loginRequest); - - return user; - } catch (OAuth2AuthenticationProcessingException ex) { - throw ex; - } catch (Exception ex) { - log.error("{} 사용자 처리 중 오류 발생: {}", provider, ex.getMessage()); - throw new AuthException(ErrorMessage.INTERNAL_SERVER_ERROR); - } - } - - /** - * Apple 사용자 정보 처리 (Apple용 별도 메서드) - * Process Apple user information manually (not through OAuth2 flow) - */ - public User processAppleUser(OAuth2UserInfo appleUserInfo, AppleLoginRequest appleLoginRequest) { - return processOAuthUser(appleUserInfo, "apple", appleLoginRequest); - } - - /** - * Google 사용자 정보 처리 (ID Token 기반) - */ - public User processGoogleUser(OAuth2UserInfo googleUserInfo, LoginRequest loginRequest) { - return processOAuthUser(googleUserInfo, "google", loginRequest); - } - - /** - * Kakao 사용자 정보 처리 (액세스 토큰 기반) - */ - public User processKakaoUser(OAuth2UserInfo kakaoUserInfo, LoginRequest loginRequest) { - return processOAuthUser(kakaoUserInfo, "kakao", loginRequest); - } - - /** - * Naver 사용자 정보 처리 (액세스 토큰 기반) - */ - public User processNaverUser(OAuth2UserInfo naverUserInfo, LoginRequest loginRequest) { - return processOAuthUser(naverUserInfo, "naver", loginRequest); - } - - /** - * 이메일 유효성 검증 - * Validate email from OAuth2 provider - */ - private void validateEmail(OAuth2UserInfo oAuth2UserInfo) { - if (!StringUtils.hasText(oAuth2UserInfo.getEmail())) { - throw new OAuth2AuthenticationProcessingException("OAuth2 제공자로부터 이메일을 찾을 수 없습니다"); - } - } - - private void saveLoginHistory(Long id) { - try{ - UserLoginHistory loginHistory = UserLoginHistory.builder() - .userId(id) - .loginDate(LocalDateTime.now()) - .build(); - userLoginHistoryRepository.save(loginHistory); - } catch (Exception e) { - // 로그인 히스토리를 못 찍어도 기능상 문제 없기에 롤백 x - log.error("로그인 히스토리 저장 중 에러 발생: {}", e.getMessage()); - } - } - - /** - * 사용자 조회 또는 생성 - * Find existing user or create a new one - */ - private User findOrCreateUser(OAuth2UserInfo oAuth2UserInfo, String registrationId) { - AuthProvider provider = AuthProvider.valueOf(registrationId.toUpperCase()); - - // 삭제된 사용자 포함하여 이메일로 사용자 조회 - Optional userOptional = userRepository.findByEmail(oAuth2UserInfo.getEmail()); - - // 이메일로 사용자를 찾았으면 - if (userOptional.isPresent()) { - User existingUser = userOptional.get(); - - // 소프트 삭제된 사용자라면 로그인 거부 - if (existingUser.isDeleted()) { - throw new OAuth2AuthenticationProcessingException( - "탈퇴한 계정입니다. 계정 복구를 원하시면 고객센터에 문의해 주세요." - ); - } - - // 같은 제공자가 아니면 오류 발생 - if (existingUser.getProvider() != null && !existingUser.getProvider().equals(provider)) { - throw new OAuth2AuthenticationProcessingException( - String.format("이미 %s 계정으로 가입되어 있습니다. %s 계정으로 로그인해 주세요.", - existingUser.getProvider(), existingUser.getProvider()) - ); - } - - // 정보 업데이트 후 반환 - return updateExistingUser(existingUser, oAuth2UserInfo); - } else { - // 이메일로 사용자를 찾지 못했으면 새로 등록 - return registerNewUser(provider, oAuth2UserInfo); - } - } - - /** - * 신규 사용자 (role_user) 등록 - * Register a new user from OAuth2 information - */ - private User registerNewUser(AuthProvider provider, OAuth2UserInfo oAuth2UserInfo) { - User user = User.builder() - .email(oAuth2UserInfo.getEmail()) - .name(oAuth2UserInfo.getName()) - .picture(oAuth2UserInfo.getImageUrl()) - .socialId(oAuth2UserInfo.getId()) - .role(Role.ROLE_USER) - .provider(provider) - .build(); - - log.info("새 OAuth2 사용자 등록: {}", user.getEmail()); - return userRepository.save(user); - } - - /** - * 기존 사용자 정보 업데이트 - * Update existing user information - */ - private User updateExistingUser(User existingUser, OAuth2UserInfo oAuth2UserInfo) { - existingUser.updateOAuth2Info(oAuth2UserInfo.getName(), oAuth2UserInfo.getImageUrl()); - - log.info("기존 OAuth2 사용자 정보 업데이트: {}", existingUser.getEmail()); - return userRepository.save(existingUser); - } - - /** - * 디바이스 정보 및 FCM 토큰 등록 - * 디바이스 정보는 항상 저장하며, FCM 토큰이 없으면 null로 저장 - */ - protected void registerFCMTokenIfPresent(Long userId, LoginRequest loginRequest) { - String fcmToken = loginRequest.getFcmToken(); - log.info("디바이스 정보 및 FCM 토큰 처리: userId={}, fcmToken={}", userId, - fcmToken == null ? "null" : (fcmToken.trim().isEmpty() ? "empty" : "exists")); - - try { - FCMTokenRequest fcmRequest = FCMTokenRequest.builder() - .userId(userId) - .fcmToken(fcmToken != null && !fcmToken.trim().isEmpty() ? fcmToken : null) - .deviceType(loginRequest.getDeviceType()) - .deviceModel(loginRequest.getDeviceModel()) - .appVersion(loginRequest.getAppVersion()) - .osVersion(loginRequest.getOsVersion()) - .isAllowed(loginRequest.getIsAllowed()) - .build(); - fcmTokenService.registerOrUpdateToken(fcmRequest); - log.info("디바이스 정보 등록 성공: userId={}, deviceType={}, fcmToken={}", - userId, loginRequest.getDeviceType(), fcmToken != null ? "exists" : "null"); - } catch (Exception e) { - log.warn("디바이스 정보 등록 실패 (로그인은 성공): userId={}, error={}", userId, e.getMessage()); - } - } -} diff --git a/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java b/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java deleted file mode 100644 index 077613d6..00000000 --- a/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationFailureHandler.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.example.cherrydan.oauth.security.oauth2; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -@Slf4j -@Component -public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { - @Value("${oauth2.redirect.failure-url}") - private String redirectFailureUrl; - - @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) throws IOException { - - String errorMessage = exception.getLocalizedMessage(); - log.error("OAuth2 인증 실패: {}", errorMessage, exception); - - // UriComponentsBuilder를 사용하여 일관된 방식으로 리다이렉트 URL 생성 - String targetUrl = UriComponentsBuilder.fromUriString(redirectFailureUrl) - .queryParam("message", "OAuth 인증에 실패했습니다: " + errorMessage) - .build().toUriString(); - - getRedirectStrategy().sendRedirect(request, response, targetUrl); - } -} diff --git a/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java deleted file mode 100644 index c9269fbb..00000000 --- a/src/main/java/com/example/cherrydan/oauth/security/oauth2/OAuth2AuthenticationSuccessHandler.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.example.cherrydan.oauth.security.oauth2; - -import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; -import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; -import com.example.cherrydan.oauth.service.AuthService; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -@Slf4j -@Component -@RequiredArgsConstructor -public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final JwtTokenProvider authService; - private final JwtTokenProvider jwtTokenProvider; - - @Value("${oauth2.redirect.success-url}") - private String redirectSuccessUrl; - @Value("${oauth2.redirect.failure-url}") - private String redirectFailureUrl; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - - try { - UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); - - // AuthService를 통해 토큰 생성 (UserDetailsImpl 직접 전달) - TokenDTO tokenDTO = jwtTokenProvider.generateTokens(userDetails.getId(),userDetails.getEmail()); - - log.info("OAuth2 로그인 성공: userId={}, email={}, provider={}", - userDetails.getId(), userDetails.getEmail(), userDetails.getProvider()); - - // 리다이렉트 URL에 TokenDTO 정보 및 userId 포함 - String targetUrl = UriComponentsBuilder.fromUriString(redirectSuccessUrl) - .queryParam("accessToken", tokenDTO.getAccessToken()) - .queryParam("refreshToken", tokenDTO.getRefreshToken()) - .queryParam("userId", userDetails.getId()) - .build().toUriString(); - - // 리다이렉트 수행 - getRedirectStrategy().sendRedirect(request, response, targetUrl); - - } catch (Exception e) { - log.error("OAuth2 인증 성공 처리 중 오류 발생: {}", e.getMessage(), e); - - // 오류 발생 시 실패 URL로 리다이렉트 - String errorUrl = UriComponentsBuilder.fromUriString(redirectFailureUrl) - .queryParam("error", URLEncoder.encode("로그인 처리 중 오류가 발생했습니다.", StandardCharsets.UTF_8)) - .build().toUriString(); - - getRedirectStrategy().sendRedirect(request, response, errorUrl); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java b/src/main/java/com/example/cherrydan/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java index 8c754996..11723be2 100644 --- a/src/main/java/com/example/cherrydan/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java +++ b/src/main/java/com/example/cherrydan/oauth/security/oauth2/exception/OAuth2AuthenticationProcessingException.java @@ -1,9 +1,22 @@ package com.example.cherrydan.oauth.security.oauth2.exception; +import com.example.cherrydan.common.exception.ErrorMessage; import org.springframework.security.core.AuthenticationException; public class OAuth2AuthenticationProcessingException extends AuthenticationException { - public OAuth2AuthenticationProcessingException(String msg) { - super(msg); + private final ErrorMessage errorMessage; + + public OAuth2AuthenticationProcessingException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public OAuth2AuthenticationProcessingException(String message) { + super(message); + this.errorMessage = null; + } + + public ErrorMessage getErrorMessage() { + return errorMessage; } } diff --git a/src/main/java/com/example/cherrydan/oauth/security/oauth2/user/OAuth2UserInfoFactory.java b/src/main/java/com/example/cherrydan/oauth/security/oauth2/user/OAuth2UserInfoFactory.java deleted file mode 100644 index 69e8a84f..00000000 --- a/src/main/java/com/example/cherrydan/oauth/security/oauth2/user/OAuth2UserInfoFactory.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.cherrydan.oauth.security.oauth2.user; - -import com.example.cherrydan.common.exception.ErrorMessage; -import com.example.cherrydan.common.exception.OAuthException; -import com.example.cherrydan.oauth.model.AuthProvider; - -import java.util.Map; - -public class OAuth2UserInfoFactory { - - public static OAuth2UserInfo getOAuth2UserInfo(AuthProvider authProvider, Map attributes) { - switch (authProvider) { - case GOOGLE: - return new GoogleOAuth2UserInfo(attributes); - case KAKAO: - return new KakaoOAuth2UserInfo(attributes); - case NAVER: - return new NaverOAuth2UserInfo(attributes); - case APPLE: - return new AppleOAuth2UserInfo(attributes); - default: - throw new OAuthException(ErrorMessage.OAUTH_PROVIDER_NOT_SUPPORTED); - } - } - - // 기존 메서드도 유지 (하위 호환성) - public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { - try { - AuthProvider authProvider = AuthProvider.valueOf(registrationId.toUpperCase()); - return getOAuth2UserInfo(authProvider, attributes); - } catch (IllegalArgumentException e) { - throw new OAuthException(ErrorMessage.OAUTH_PROVIDER_NOT_SUPPORTED); - } - } -} diff --git a/src/main/java/com/example/cherrydan/oauth/service/AppleIdentityTokenService.java b/src/main/java/com/example/cherrydan/oauth/service/AppleIdentityTokenService.java index 39704e9a..6c4e0c41 100644 --- a/src/main/java/com/example/cherrydan/oauth/service/AppleIdentityTokenService.java +++ b/src/main/java/com/example/cherrydan/oauth/service/AppleIdentityTokenService.java @@ -46,12 +46,12 @@ public Map verifyIdentityToken(String identityToken) { // 2. JWT 토큰 검증 Claims claims = Jwts.parser() - .setSigningKey(publicKey) + .verifyWith(publicKey) .requireIssuer(APPLE_ISSUER) .requireAudience(APPLE_AUDIENCE) .build() - .parseClaimsJws(identityToken) - .getBody(); + .parseSignedClaims(identityToken) + .getPayload(); log.info("Apple Identity Token 검증 성공: sub={}", claims.getSubject()); diff --git a/src/main/java/com/example/cherrydan/oauth/service/AuthService.java b/src/main/java/com/example/cherrydan/oauth/service/AuthService.java index a4a2d681..26b18934 100644 --- a/src/main/java/com/example/cherrydan/oauth/service/AuthService.java +++ b/src/main/java/com/example/cherrydan/oauth/service/AuthService.java @@ -2,10 +2,9 @@ import com.example.cherrydan.common.exception.AuthException; import com.example.cherrydan.common.exception.ErrorMessage; - +import com.example.cherrydan.oauth.domain.RefreshToken; import com.example.cherrydan.oauth.dto.RefreshTokenDTO; import com.example.cherrydan.oauth.dto.TokenDTO; -import com.example.cherrydan.oauth.model.RefreshToken; import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; import com.example.cherrydan.user.domain.User; import lombok.RequiredArgsConstructor; @@ -24,7 +23,7 @@ public class AuthService { private final RefreshTokenService refreshTokenService; private final JwtTokenProvider tokenProvider; - private final UserLoginHistoryRepository userLoginHistoryRepository; + private final UserLoginHistoryService userLoginHistoryService; @Transactional public TokenDTO refreshToken(RefreshTokenDTO refreshToken) { @@ -39,8 +38,7 @@ public TokenDTO refreshToken(RefreshTokenDTO refreshToken) { // 기존 RefreshToken 엔티티의 토큰값만 업데이트 refreshTokenEntity.setRefreshToken(newTokens.getRefreshToken()); - // 로그인 히스토리 저장 - saveLoginHistory(user.getId()); + userLoginHistoryService.recordLogin(user.getId()); log.info("토큰 갱신 완료: 사용자 ID = {}", user.getId()); @@ -52,18 +50,5 @@ public void logout(Long userId) { refreshTokenService.deleteRefreshTokenByUserId(userId); log.info("사용자 {} 로그아웃 완료", userId); } - - private void saveLoginHistory(Long id) { - try{ - UserLoginHistory loginHistory = UserLoginHistory.builder() - .userId(id) - .loginDate(LocalDateTime.now()) - .build(); - userLoginHistoryRepository.save(loginHistory); - } catch (Exception e) { - log.error("로그인 히스토리 저장 중 에러 발생: {}", e.getMessage()); - throw new AuthException(ErrorMessage.INTERNAL_SERVER_ERROR); - } - } } diff --git a/src/main/java/com/example/cherrydan/oauth/service/NaverOAuthService.java b/src/main/java/com/example/cherrydan/oauth/service/NaverOAuthService.java deleted file mode 100644 index c8e031c8..00000000 --- a/src/main/java/com/example/cherrydan/oauth/service/NaverOAuthService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.cherrydan.oauth.service; - -import com.example.cherrydan.common.exception.AuthException; -import com.example.cherrydan.common.exception.ErrorMessage; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -public class NaverOAuthService { - - private final RestTemplate restTemplate; - - public Map getUserInfo(String accessToken) { - try { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - - HttpEntity entity = new HttpEntity<>(headers); - - ResponseEntity response = restTemplate.exchange( - "https://openapi.naver.com/v1/nid/me", - HttpMethod.GET, - entity, - Map.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - log.info("Naver 사용자 정보 조회 성공"); - return response.getBody(); - } else { - log.error("Naver 사용자 정보 조회 실패: {}", response.getStatusCode()); - throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); - } - - } catch (Exception e) { - log.error("Naver 사용자 정보 조회 중 오류 발생: {}", e.getMessage(), e); - throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/service/OAuthFacade.java b/src/main/java/com/example/cherrydan/oauth/service/OAuthFacade.java new file mode 100644 index 00000000..f529736b --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/service/OAuthFacade.java @@ -0,0 +1,111 @@ +package com.example.cherrydan.oauth.service; + +import com.example.cherrydan.common.exception.AuthException; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.dto.LoginRequest; +import com.example.cherrydan.oauth.dto.LoginResponse; +import com.example.cherrydan.oauth.dto.TokenDTO; +import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.cherrydan.oauth.strategy.OAuthStrategy; +import com.example.cherrydan.user.domain.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.PostConstruct; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * OAuth 인증 Facade 서비스 + * 모든 OAuth 제공자의 로그인 처리를 통합 관리 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthFacade { + + private final List strategies; + private final OAuthUserProcessingService oAuthDomainService; + private final UserLoginHistoryService loginHistoryService; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + + private final Map strategyMap = new HashMap<>(); + + @PostConstruct + public void initStrategies() { + strategies.forEach(strategy -> + strategyMap.put(strategy.getProvider(), strategy) + ); + log.info("OAuth strategies initialized: {}", strategyMap.keySet()); + } + + /** + * 통합 OAuth 로그인 처리 + * @param provider OAuth 제공자 + * @param loginRequest 로그인 요청 정보 + * @return 로그인 응답 (JWT 토큰 포함) + */ + @Transactional + public LoginResponse processOAuthLogin(AuthProvider provider, LoginRequest loginRequest) { + log.info("Processing OAuth login for provider: {}", provider); + + // 1. 적절한 전략 선택 + OAuthStrategy strategy = getStrategy(provider); + + // 2. 사용자 정보 조회 + OAuth2UserInfo userInfo = strategy.getUserInfo(loginRequest.getAccessToken()); + + // 3. 도메인 서비스를 통한 사용자 처리 + User user = oAuthDomainService.processOAuthUser(userInfo, provider, loginRequest); + + // 4. 로그인 기록 저장 + loginHistoryService.recordLogin(user.getId()); + + // 5. JWT 토큰 생성 + TokenDTO tokenDTO = jwtTokenProvider.generateTokens(user.getId(), user.getEmail()); + + // 6. Refresh Token 저장 + refreshTokenService.saveOrUpdateRefreshToken(user, tokenDTO.getRefreshToken()); + + log.info("{} OAuth login successful: userId={}, email={}", + provider, user.getId(), user.getEmail()); + + return new LoginResponse(tokenDTO, user.getId()); + } + + + /** + * OAuth2 flow를 통한 로그인 처리 (웹 기반) + * Spring Security OAuth2와 통합 + */ + @Transactional + public User processOAuth2User(OAuth2UserInfo userInfo, AuthProvider provider) { + log.info("Processing OAuth2 user for provider: {}", provider); + + // 도메인 서비스를 통한 사용자 처리 (FCM 토큰 없이) + User user = oAuthDomainService.processOAuthUser(userInfo, provider, null); + + // 로그인 기록 + loginHistoryService.recordLogin(user.getId()); + + return user; + } + + /** + * 제공자에 맞는 전략 가져오기 + */ + private OAuthStrategy getStrategy(AuthProvider provider) { + OAuthStrategy strategy = strategyMap.get(provider); + if (strategy == null) { + log.error("No strategy found for provider: {}", provider); + throw new AuthException(ErrorMessage.OAUTH_PROVIDER_NOT_SUPPORTED); + } + return strategy; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/service/OAuthUserProcessingService.java b/src/main/java/com/example/cherrydan/oauth/service/OAuthUserProcessingService.java new file mode 100644 index 00000000..765ef9c1 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/service/OAuthUserProcessingService.java @@ -0,0 +1,187 @@ +package com.example.cherrydan.oauth.service; + +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.fcm.dto.FCMTokenRequest; +import com.example.cherrydan.fcm.service.FCMTokenService; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.dto.LoginRequest; +import com.example.cherrydan.oauth.security.oauth2.exception.OAuth2AuthenticationProcessingException; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.cherrydan.user.domain.Role; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.Optional; + +/** + * OAuth 도메인 서비스 + * 사용자 생성, 업데이트 등 도메인 로직을 담당 + * DDD의 Domain Service 패턴 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class OAuthUserProcessingService { + + private final UserRepository userRepository; + private final FCMTokenService fcmTokenService; + + /** + * OAuth 사용자 처리 (찾기 또는 생성) + * @param userInfo OAuth 제공자로부터 받은 사용자 정보 + * @param provider OAuth 제공자 + * @param loginRequest 로그인 요청 정보 (FCM 토큰 등 포함) + * @return 처리된 사용자 엔티티 + */ + public User processOAuthUser(OAuth2UserInfo userInfo, AuthProvider provider, LoginRequest loginRequest) { + // 1. 이메일 검증 + validateEmail(userInfo); + + // 2. 사용자 조회 또는 생성 + User user = findOrCreateUser(userInfo, provider); + + // 3. FCM 토큰 처리 (loginRequest가 있는 경우만) + if (loginRequest != null) { + registerDeviceInfo(user.getId(), loginRequest); + } + + log.info("OAuth user processed successfully: userId={}, provider={}", + user.getId(), provider); + + return user; + } + + /** + * 이메일 유효성 검증 + */ + private void validateEmail(OAuth2UserInfo userInfo) { + if (!StringUtils.hasText(userInfo.getEmail())) { + log.error("OAuth email not found or empty"); + throw new OAuth2AuthenticationProcessingException(ErrorMessage.OAUTH_EMAIL_NOT_FOUND); + } + } + + /** + * 사용자 조회 또는 생성 + * 비즈니스 규칙: + * 1. 이메일로 기존 사용자 조회 + * 2. 삭제된 사용자가 1년 이내면 계정 복구 + * 3. 삭제된 사용자가 1년 초과면 로그인 거부 + * 4. 다른 제공자로 가입된 경우 오류 + * 5. 기존 사용자면 정보 업데이트 + * 6. 신규 사용자면 생성 + */ + private User findOrCreateUser(OAuth2UserInfo userInfo, AuthProvider provider) { + String email = userInfo.getEmail(); + Optional userOptional = userRepository.findByEmail(email); + + if (userOptional.isPresent()) { + User existingUser = userOptional.get(); + + // 삭제된 사용자 처리 + if (existingUser.isDeleted()) { + // 1년 이내 삭제된 사용자는 복구 + if (existingUser.isRestorableWithin1Year()) { + log.info("Restoring deleted user within 1 years: email={}", email); + existingUser.restore(); + } else { + // 1년 초과한 경우 -> 자동 삭제 + log.error("Permanently deleted user attempted to login: email={}", email); + throw new OAuth2AuthenticationProcessingException(ErrorMessage.OAUTH_USER_DELETED); + } + } + + // 제공자 일치 검증 + validateProvider(existingUser, provider); + + // 기존 사용자 정보 업데이트 + return updateExistingUser(existingUser, userInfo); + } else { + // 신규 사용자 생성 + return createNewUser(userInfo, provider); + } + } + + /** + * OAuth 제공자 일치 검증 + */ + private void validateProvider(User user, AuthProvider provider) { + if (user.getProvider() != null && !user.getProvider().equals(provider)) { + String errorMessage = String.format( + "이미 %s 계정으로 가입되어 있습니다. %s 계정으로 로그인해 주세요.", + user.getProvider(), user.getEmail() + ); + log.error("Provider mismatch: expected={}, actual={}", user.getProvider(), provider); + throw new OAuth2AuthenticationProcessingException(errorMessage); + } + } + + /** + * 신규 사용자 생성 + * Rich Domain Model 패턴 적용 + */ + private User createNewUser(OAuth2UserInfo userInfo, AuthProvider provider) { + User newUser = User.builder() + .email(userInfo.getEmail()) + .name(userInfo.getName()) + .picture(userInfo.getImageUrl()) + .socialId(userInfo.getId()) + .role(Role.ROLE_USER) + .provider(provider) + .build(); + + log.info("Creating new OAuth user: email={}, provider={}", + newUser.getEmail(), provider); + + return userRepository.save(newUser); + } + + /** + * 기존 사용자 정보 업데이트 + * User 엔티티의 도메인 메서드 활용 + */ + private User updateExistingUser(User user, OAuth2UserInfo userInfo) { + // User 엔티티의 도메인 메서드 호출 + user.updateOAuth2Info(userInfo.getName(), userInfo.getImageUrl()); + + log.info("Updating existing OAuth user: userId={}, email={}", + user.getId(), user.getEmail()); + + return userRepository.save(user); + } + + /** + * 디바이스 정보 및 FCM 토큰 등록 + * 실패해도 로그인은 계속 진행 + */ + private void registerDeviceInfo(Long userId, LoginRequest loginRequest) { + try { + String fcmToken = loginRequest.getFcmToken(); + + FCMTokenRequest fcmRequest = FCMTokenRequest.builder() + .userId(userId) + .fcmToken(fcmToken != null && !fcmToken.trim().isEmpty() ? fcmToken : null) + .deviceType(loginRequest.getDeviceType()) + .deviceModel(loginRequest.getDeviceModel()) + .appVersion(loginRequest.getAppVersion()) + .osVersion(loginRequest.getOsVersion()) + .isAllowed(loginRequest.getIsAllowed()) + .build(); + + fcmTokenService.registerOrUpdateToken(fcmRequest); + + log.info("Device info registered: userId={}, deviceType={}", + userId, loginRequest.getDeviceType()); + + } catch (Exception e) { + log.warn("Failed to register device info (login continues): userId={}, error={}", + userId, e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/service/RefreshTokenService.java b/src/main/java/com/example/cherrydan/oauth/service/RefreshTokenService.java index 96ac8504..1faab028 100644 --- a/src/main/java/com/example/cherrydan/oauth/service/RefreshTokenService.java +++ b/src/main/java/com/example/cherrydan/oauth/service/RefreshTokenService.java @@ -3,7 +3,7 @@ import com.example.cherrydan.common.exception.ErrorMessage; import com.example.cherrydan.common.exception.RefreshTokenException; import com.example.cherrydan.common.exception.UserException; -import com.example.cherrydan.oauth.model.RefreshToken; +import com.example.cherrydan.oauth.domain.RefreshToken; import com.example.cherrydan.oauth.repository.RefreshTokenRepository; import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; import com.example.cherrydan.user.domain.User; @@ -31,6 +31,7 @@ public class RefreshTokenService { public void saveOrUpdateRefreshToken(User user, String refreshTokenValue) { // 기존 토큰 확인 (userId로만 조회) Optional existingToken = refreshTokenRepository.findByUserId(user.getId()); + if (existingToken.isPresent()) { // 기존 토큰의 값만 업데이트 existingToken.get().setRefreshToken(refreshTokenValue); diff --git a/src/main/java/com/example/cherrydan/oauth/service/UserLoginHistoryService.java b/src/main/java/com/example/cherrydan/oauth/service/UserLoginHistoryService.java new file mode 100644 index 00000000..e79df3c2 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/service/UserLoginHistoryService.java @@ -0,0 +1,54 @@ +package com.example.cherrydan.oauth.service; + +import com.example.cherrydan.user.domain.UserLoginHistory; +import com.example.cherrydan.user.repository.UserLoginHistoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 사용자 로그인 기록 관리 서비스 + * 로그인 기록 관리 책임을 분리하여 단일 책임 원칙 준수 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class UserLoginHistoryService { + + private final UserLoginHistoryRepository loginHistoryRepository; + + /** + * 로그인 기록 저장 + * 로그인 기록 실패가 전체 로그인 프로세스에 영향을 주지 않도록 처리 + */ + public void recordLogin(Long userId) { + try { + UserLoginHistory loginHistory = createLoginHistory(userId); + loginHistoryRepository.save(loginHistory); + + log.info("Login history recorded: userId={}, timestamp={}", + userId, loginHistory.getLoginDate()); + + } catch (Exception e) { + // 로그인 기록 저장 실패는 로그인 프로세스를 중단시키지 않음 + log.error("Failed to record login history for userId={}: {}", userId, e.getMessage()); + } + } + + /** + * 로그인 기록 엔티티 생성 + * 팩토리 메서드 패턴 적용 + */ + private UserLoginHistory createLoginHistory(Long userId) { + return UserLoginHistory.builder() + .userId(userId) + .loginDate(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/strategy/AppleOAuthStrategy.java b/src/main/java/com/example/cherrydan/oauth/strategy/AppleOAuthStrategy.java new file mode 100644 index 00000000..695d094b --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/strategy/AppleOAuthStrategy.java @@ -0,0 +1,67 @@ +package com.example.cherrydan.oauth.strategy; + +import com.example.cherrydan.common.exception.AuthException; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.security.oauth2.user.AppleOAuth2UserInfo; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.cherrydan.oauth.service.AppleIdentityTokenService; +import com.example.cherrydan.oauth.strategy.OAuthStrategy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Apple OAuth 인증 전략 구현체 + * Identity Token 기반 인증 처리 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleOAuthStrategy implements OAuthStrategy { + + private final AppleIdentityTokenService appleIdentityTokenService; + + @Override + public OAuth2UserInfo getUserInfo(String token) { + validateToken(token); + + try { + // Apple Identity Token 검증 및 파싱 + Map claims = appleIdentityTokenService.verifyIdentityToken(token); + + // AppleOAuth2UserInfo가 기대하는 형식으로 변환 + Map attributes = new HashMap<>(); + attributes.put("sub", claims.get("sub")); + attributes.put("email", claims.get("email")); + attributes.put("email_verified", claims.get("email_verified")); + attributes.put("is_private_email", claims.get("is_private_email")); + + log.info("Apple 사용자 정보 조회 성공: sub={}", claims.get("sub")); + return new AppleOAuth2UserInfo(attributes); + + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Apple Identity Token 처리 중 오류 발생: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + } + + @Override + public AuthProvider getProvider() { + return AuthProvider.APPLE; + } + + @Override + public void validateToken(String token) { + if (!StringUtils.hasText(token)) { + log.error("Apple Identity Token이 비어있습니다"); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/strategy/GoogleOAuthStrategy.java b/src/main/java/com/example/cherrydan/oauth/strategy/GoogleOAuthStrategy.java new file mode 100644 index 00000000..063fa6d7 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/strategy/GoogleOAuthStrategy.java @@ -0,0 +1,73 @@ +package com.example.cherrydan.oauth.strategy; + +import com.example.cherrydan.common.exception.AuthException; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.security.oauth2.user.GoogleOAuth2UserInfo; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; +import com.example.cherrydan.oauth.service.GoogleIdentityTokenService; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * Google OAuth 인증 전략 구현체 + * ID Token 기반 인증 처리 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GoogleOAuthStrategy implements OAuthStrategy { + + private final GoogleIdentityTokenService googleIdentityTokenService; + + @Override + public OAuth2UserInfo getUserInfo(String token) { + validateToken(token); + + try { + // Google ID Token 검증 및 파싱 + GoogleIdToken.Payload payload = googleIdentityTokenService.verify(token); + + if (payload == null) { + log.error("Google ID Token 검증 실패"); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + + // GoogleOAuth2UserInfo가 기대하는 형식으로 변환 + Map attributes = new HashMap<>(); + attributes.put("sub", payload.getSubject()); + attributes.put("name", payload.get("name")); + attributes.put("email", payload.getEmail()); + attributes.put("picture", payload.get("picture")); + attributes.put("email_verified", payload.getEmailVerified()); + + log.info("Google 사용자 정보 조회 성공: email={}", payload.getEmail()); + return new GoogleOAuth2UserInfo(attributes); + + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Google ID Token 처리 중 오류 발생: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + } + + @Override + public AuthProvider getProvider() { + return AuthProvider.GOOGLE; + } + + @Override + public void validateToken(String token) { + if (!StringUtils.hasText(token)) { + log.error("Google ID Token이 비어있습니다"); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/service/KakaoOAuthService.java b/src/main/java/com/example/cherrydan/oauth/strategy/KakaoOAuthStrategy.java similarity index 54% rename from src/main/java/com/example/cherrydan/oauth/service/KakaoOAuthService.java rename to src/main/java/com/example/cherrydan/oauth/strategy/KakaoOAuthStrategy.java index cd8a7af7..86256c17 100644 --- a/src/main/java/com/example/cherrydan/oauth/service/KakaoOAuthService.java +++ b/src/main/java/com/example/cherrydan/oauth/strategy/KakaoOAuthStrategy.java @@ -1,34 +1,45 @@ -package com.example.cherrydan.oauth.service; +package com.example.cherrydan.oauth.strategy; import com.example.cherrydan.common.exception.AuthException; import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.security.oauth2.user.KakaoOAuth2UserInfo; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import java.util.Map; +/** + * Kakao OAuth 인증 전략 구현체 + */ @Slf4j -@Service +@Component @RequiredArgsConstructor -public class KakaoOAuthService { +public class KakaoOAuthStrategy implements OAuthStrategy { + private static final String KAKAO_USER_INFO_ENDPOINT = "https://kapi.kakao.com/v2/user/me"; private final RestTemplate restTemplate; - public Map getUserInfo(String accessToken) { + @Override + public OAuth2UserInfo getUserInfo(String token) { + validateToken(token); + try { HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); + headers.setBearerAuth(token); HttpEntity entity = new HttpEntity<>(headers); ResponseEntity response = restTemplate.exchange( - "https://kapi.kakao.com/v2/user/me", + KAKAO_USER_INFO_ENDPOINT, HttpMethod.GET, entity, Map.class @@ -36,15 +47,30 @@ public Map getUserInfo(String accessToken) { if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { log.info("Kakao 사용자 정보 조회 성공"); - return response.getBody(); + return new KakaoOAuth2UserInfo(response.getBody()); } else { log.error("Kakao 사용자 정보 조회 실패: {}", response.getStatusCode()); throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); } + } catch (AuthException e) { + throw e; } catch (Exception e) { log.error("Kakao 사용자 정보 조회 중 오류 발생: {}", e.getMessage(), e); throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); } } -} \ No newline at end of file + + @Override + public AuthProvider getProvider() { + return AuthProvider.KAKAO; + } + + @Override + public void validateToken(String token) { + if (!StringUtils.hasText(token)) { + log.error("Kakao 액세스 토큰이 비어있습니다"); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/strategy/NaverOAuthStrategy.java b/src/main/java/com/example/cherrydan/oauth/strategy/NaverOAuthStrategy.java new file mode 100644 index 00000000..a0088c64 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/strategy/NaverOAuthStrategy.java @@ -0,0 +1,85 @@ +package com.example.cherrydan.oauth.strategy; + +import com.example.cherrydan.common.exception.AuthException; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.security.oauth2.user.NaverOAuth2UserInfo; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * Naver OAuth 인증 전략 구현체 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NaverOAuthStrategy implements OAuthStrategy { + + private static final String NAVER_USER_INFO_ENDPOINT = "https://openapi.naver.com/v1/nid/me"; + private final RestTemplate restTemplate; + + @Override + public OAuth2UserInfo getUserInfo(String token) { + validateToken(token); + + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + NAVER_USER_INFO_ENDPOINT, + HttpMethod.GET, + entity, + Map.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map responseBody = response.getBody(); + String resultCode = (String) responseBody.get("resultcode"); + + if (!"00".equals(resultCode)) { + log.error("Naver API 오류: resultCode={}, message={}", + resultCode, responseBody.get("message")); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + + log.info("Naver 사용자 정보 조회 성공"); + return new NaverOAuth2UserInfo(responseBody); + } else { + log.error("Naver 사용자 정보 조회 실패: {}", response.getStatusCode()); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + + } catch (AuthException e) { + throw e; + } catch (Exception e) { + log.error("Naver 사용자 정보 조회 중 오류 발생: {}", e.getMessage(), e); + throw new AuthException(ErrorMessage.OAUTH_AUTHENTICATION_FAILED); + } + } + + @Override + public AuthProvider getProvider() { + return AuthProvider.NAVER; + } + + @Override + public void validateToken(String token) { + if (!StringUtils.hasText(token)) { + log.error("Naver 액세스 토큰이 비어있습니다"); + throw new AuthException(ErrorMessage.AUTH_INVALID_TOKEN); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/oauth/strategy/OAuthStrategy.java b/src/main/java/com/example/cherrydan/oauth/strategy/OAuthStrategy.java new file mode 100644 index 00000000..b11d5b48 --- /dev/null +++ b/src/main/java/com/example/cherrydan/oauth/strategy/OAuthStrategy.java @@ -0,0 +1,40 @@ +package com.example.cherrydan.oauth.strategy; + +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.security.oauth2.user.OAuth2UserInfo; + +/** + * OAuth 제공자별 인증 전략 인터페이스 + * Strategy Pattern을 통해 각 OAuth 제공자의 특성을 캡슐화 + */ +public interface OAuthStrategy { + + /** + * 액세스 토큰을 사용하여 사용자 정보 조회 + * @param token OAuth 제공자의 액세스 토큰 또는 ID 토큰 + * @return OAuth2UserInfo 사용자 정보 + */ + OAuth2UserInfo getUserInfo(String token); + + /** + * OAuth 제공자 타입 반환 + * @return AuthProvider 제공자 타입 + */ + AuthProvider getProvider(); + + /** + * 토큰 유효성 검증 + * @param token 검증할 토큰 + * @throws com.example.cherrydan.common.exception.AuthException 토큰이 유효하지 않은 경우 + */ + void validateToken(String token); + + /** + * 전략이 지원하는 제공자인지 확인 + * @param provider 확인할 제공자 + * @return 지원 여부 + */ + default boolean supports(AuthProvider provider) { + return getProvider() == provider; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/sns/config/SnsConfig.java b/src/main/java/com/example/cherrydan/sns/config/SnsConfig.java index 3d9cdfcd..138db2ee 100644 --- a/src/main/java/com/example/cherrydan/sns/config/SnsConfig.java +++ b/src/main/java/com/example/cherrydan/sns/config/SnsConfig.java @@ -14,7 +14,6 @@ @Configuration @RequiredArgsConstructor public class SnsConfig { - // 블로그 인증 방식으로 변경되어 WebClient가 더 이상 필요하지 않음 @Bean public WebClient webClient() { diff --git a/src/main/java/com/example/cherrydan/sns/controller/SnsController.java b/src/main/java/com/example/cherrydan/sns/controller/SnsController.java index e5965354..ea400749 100644 --- a/src/main/java/com/example/cherrydan/sns/controller/SnsController.java +++ b/src/main/java/com/example/cherrydan/sns/controller/SnsController.java @@ -1,5 +1,8 @@ package com.example.cherrydan.sns.controller; +import com.example.cherrydan.common.constants.DeepLinkConstants; +import com.example.cherrydan.common.exception.OAuthStateException; +import com.example.cherrydan.common.exception.SnsException; import com.example.cherrydan.common.response.ApiResponse; import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; import com.example.cherrydan.sns.domain.SnsPlatform; @@ -12,14 +15,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Mono; +import java.net.URI; import java.util.List; @RestController @@ -34,32 +36,51 @@ public class SnsController { @Operation(summary = "OAuth 인증 URL 생성") @GetMapping("/oauth/{platform}/auth-url") - public ApiResponse getAuthUrl(@PathVariable("platform") String platform) { + public ResponseEntity> getAuthUrl( + @AuthenticationPrincipal UserDetailsImpl userDetails, + @PathVariable("platform") String platform) { SnsPlatform snsPlatform = SnsPlatform.fromPlatformCode(platform); - String authUrl = snsOAuthService.getAuthUrl(snsPlatform); - return ApiResponse.success(snsPlatform.getDisplayName() + " 인증 URL 생성 성공", authUrl); + String authUrl = snsOAuthService.getAuthUrlWithState(snsPlatform, userDetails.getId()); + return ResponseEntity.ok(ApiResponse.success(snsPlatform.getDisplayName() + " 인증 URL 생성 성공", authUrl)); } @Operation(summary = "OAuth 콜백 처리") @GetMapping("/oauth/{platform}/callback") - public Mono> handleOAuthCallback( - @AuthenticationPrincipal UserDetailsImpl userDetails, + public Mono> handleOAuthCallback( @PathVariable("platform") String platform, - @RequestParam("code") String code) { + @RequestParam("code") String code, + @RequestParam("state") String state) { SnsPlatform snsPlatform = SnsPlatform.fromPlatformCode(platform); - User user = userService.getUserById(userDetails.getId()); - - return snsOAuthService.connect(user, code, snsPlatform) - .map(response -> ApiResponse.success(snsPlatform.getDisplayName() + " 연동이 완료되었습니다.", response)); + + return snsOAuthService.handleCallback(code, state, snsPlatform) + .map(response -> { + String deeplink = DeepLinkConstants.buildOAuthSuccessUrl(platform); + log.info("OAuth 성공 - 딥링크 리다이렉트: {}", deeplink); + ResponseEntity redirect = ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(deeplink)) + .build(); + + return redirect; + }) + .onErrorResume(error -> { + String errorCode = mapExceptionToErrorCode(error); + String deeplink = DeepLinkConstants.buildOAuthFailureUrl(errorCode); + log.error("OAuth 실패 - 딥링크 리다이렉트: {} (에러 코드: {})", deeplink, errorCode, error); + ResponseEntity redirect = ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(deeplink)) + .build(); + + return Mono.just(redirect); + }); } @Operation(summary = "사용자 SNS 연동 목록 조회") @GetMapping("/connections") - public ApiResponse> getConnections( + public ResponseEntity>> getConnections( @AuthenticationPrincipal UserDetailsImpl userDetails) { User user = userService.getUserById(userDetails.getId()); List response = snsOAuthService.getUserSnsConnections(user); - return ApiResponse.success("SNS 연동 목록 조회 성공", response); + return ResponseEntity.ok(ApiResponse.success("SNS 연동 목록 조회 성공", response)); } @Operation(summary = "SNS 연동 해제") @@ -72,4 +93,22 @@ public ResponseEntity> disconnectSns( snsOAuthService.disconnectSns(user, snsPlatform); return ResponseEntity.ok(ApiResponse.success(snsPlatform.getDisplayName() + " 연동이 해제되었습니다.", null)); } -} + + /** + * 예외를 에러 코드로 매핑 + * @param error 예외 + * @return 에러 코드 + */ + private String mapExceptionToErrorCode(Throwable error) { + if (error instanceof SnsException snsException) { + return snsException.getErrorMessage().name(); + } + + if (error instanceof OAuthStateException oauthStateException) { + return oauthStateException.getErrorMessage().name(); + } + + log.warn("매핑되지 않은 예외 타입: {}", error.getClass().getName()); + return "UNKNOWN_ERROR"; + } +} diff --git a/src/main/java/com/example/cherrydan/sns/domain/SnsConnection.java b/src/main/java/com/example/cherrydan/sns/domain/SnsConnection.java index d4874d74..868b3eec 100644 --- a/src/main/java/com/example/cherrydan/sns/domain/SnsConnection.java +++ b/src/main/java/com/example/cherrydan/sns/domain/SnsConnection.java @@ -35,12 +35,6 @@ public class SnsConnection extends BaseTimeEntity { private String snsUrl; - @Lob - private String accessToken; - - @Lob - private String refreshToken; - @Column(name = "platform_user_id") private String platformUserId; @@ -51,21 +45,9 @@ public class SnsConnection extends BaseTimeEntity { @Builder.Default private Boolean isActive = true; - @Column(name = "expires_at") - private LocalDateTime expiresAt; - - public void updateSnsInfo(String snsUserId, String snsUrl, String accessToken, String refreshToken, LocalDateTime expiresAt) { + public void updateSnsInfo(String snsUserId, String snsUrl) { this.snsUserId = snsUserId; this.snsUrl = snsUrl; - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.expiresAt = expiresAt; - } - - public void updateTokens(String accessToken, String refreshToken, LocalDateTime expiresAt) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.expiresAt = expiresAt; } public void setPlatformUserId(String platformUserId) { @@ -83,8 +65,4 @@ public void setIsActive(Boolean isActive) { public void deactivate() { this.isActive = false; } - - public boolean isExpired() { - return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); - } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/sns/domain/SnsPlatform.java b/src/main/java/com/example/cherrydan/sns/domain/SnsPlatform.java index b5b46a43..ce38e3cc 100644 --- a/src/main/java/com/example/cherrydan/sns/domain/SnsPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/domain/SnsPlatform.java @@ -1,5 +1,7 @@ package com.example.cherrydan.sns.domain; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.SnsException; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -18,11 +20,11 @@ public enum SnsPlatform { * platformCode로부터 SnsPlatform을 찾습니다. * @param platformCode 플랫폼 코드 * @return SnsPlatform - * @throws IllegalArgumentException 지원하지 않는 플랫폼인 경우 + * @throws SnsException 지원하지 않는 플랫폼인 경우 */ public static SnsPlatform fromPlatformCode(String platformCode) { if (platformCode == null || platformCode.trim().isEmpty()) { - throw new IllegalArgumentException("플랫폼 코드가 비어있습니다."); + throw new SnsException(ErrorMessage.SNS_PLATFORM_CODE_EMPTY); } for (SnsPlatform platform : values()) { @@ -31,7 +33,7 @@ public static SnsPlatform fromPlatformCode(String platformCode) { } } - throw new IllegalArgumentException("지원하지 않는 플랫폼입니다: " + platformCode); + throw new SnsException(ErrorMessage.SNS_PLATFORM_NOT_SUPPORTED); } /** diff --git a/src/main/java/com/example/cherrydan/sns/dto/SnsConnectionResponse.java b/src/main/java/com/example/cherrydan/sns/dto/SnsConnectionResponse.java index 346f3c47..d0997e67 100644 --- a/src/main/java/com/example/cherrydan/sns/dto/SnsConnectionResponse.java +++ b/src/main/java/com/example/cherrydan/sns/dto/SnsConnectionResponse.java @@ -2,15 +2,20 @@ import com.example.cherrydan.sns.domain.SnsConnection; import com.example.cherrydan.sns.domain.SnsPlatform; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; import lombok.Builder; import lombok.Getter; @Getter @Builder public class SnsConnectionResponse { + @Schema(description = "플랫폼 (유튜브, 인스타 등)", requiredMode = Schema.RequiredMode.REQUIRED) private final SnsPlatform platform; + @Schema(description = "유저의 연결된 sns 링크", requiredMode = Schema.RequiredMode.REQUIRED) private final String snsUrl; - private final boolean isConnected; + @Schema(description = "유저의 특정 플랫폼 연결 유무", requiredMode = Schema.RequiredMode.REQUIRED) + private final Boolean isConnected; public static SnsConnectionResponse from(SnsConnection connection) { return SnsConnectionResponse.builder() @@ -19,11 +24,4 @@ public static SnsConnectionResponse from(SnsConnection connection) { .isConnected(true) .build(); } - - public static SnsConnectionResponse notConnected(SnsPlatform platform) { - return SnsConnectionResponse.builder() - .platform(platform) - .isConnected(false) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/sns/repository/SnsConnectionRepository.java b/src/main/java/com/example/cherrydan/sns/repository/SnsConnectionRepository.java index 72b37ce9..2593ff96 100644 --- a/src/main/java/com/example/cherrydan/sns/repository/SnsConnectionRepository.java +++ b/src/main/java/com/example/cherrydan/sns/repository/SnsConnectionRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -33,4 +34,10 @@ public interface SnsConnectionRepository extends JpaRepository findAllByUser(@Param("user") User user); + + @Modifying + @Query("DELETE FROM SnsConnection sc WHERE sc.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); + + List findByUserId(@Param("id") Long userId); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/sns/service/AbstractOAuthPlatform.java b/src/main/java/com/example/cherrydan/sns/service/AbstractOAuthPlatform.java index e480842a..efc16778 100644 --- a/src/main/java/com/example/cherrydan/sns/service/AbstractOAuthPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/service/AbstractOAuthPlatform.java @@ -1,5 +1,7 @@ package com.example.cherrydan.sns.service; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.SnsException; import com.example.cherrydan.sns.config.SnsOAuthProperties; import com.example.cherrydan.sns.domain.SnsPlatform; import com.example.cherrydan.sns.dto.TokenResponse; @@ -25,13 +27,14 @@ public abstract class AbstractOAuthPlatform implements OAuthPlatform { protected final SnsOAuthProperties snsOAuthProperties; @Override - public String generateAuthUrl() { + public String generateAuthUrl(String state) { SnsOAuthProperties.PlatformConfig config = getPlatformConfig(); UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(config.getAuthUrl()) .queryParam(getClientIdParamName(), config.getClientId()) .queryParam("redirect_uri", config.getRedirectUri()) .queryParam("scope", config.getScope()) - .queryParam("response_type", "code"); + .queryParam("response_type", "code") + .queryParam("state", state); // 플랫폼별 추가 파라미터 설정 addAuthUrlParameters(builder, config); @@ -74,7 +77,7 @@ public Mono getUserInfo(String accessToken) { protected SnsOAuthProperties.PlatformConfig getPlatformConfig() { SnsOAuthProperties.PlatformConfig config = snsOAuthProperties.getPlatformConfig(getPlatformCode()); if (config == null) { - throw new RuntimeException(getPlatform() + " 설정을 찾을 수 없습니다."); + throw new SnsException(ErrorMessage.SNS_PLATFORM_NOT_SUPPORTED); } return config; } diff --git a/src/main/java/com/example/cherrydan/sns/service/InstagramOAuthPlatform.java b/src/main/java/com/example/cherrydan/sns/service/InstagramOAuthPlatform.java index b02b5f61..db58edcd 100644 --- a/src/main/java/com/example/cherrydan/sns/service/InstagramOAuthPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/service/InstagramOAuthPlatform.java @@ -4,6 +4,7 @@ import com.example.cherrydan.sns.domain.SnsPlatform; import com.example.cherrydan.sns.dto.UserInfo; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; @@ -16,7 +17,6 @@ */ @Slf4j @Component -@org.springframework.context.annotation.Profile({"local", "dev", "test"}) public class InstagramOAuthPlatform extends AbstractOAuthPlatform { public InstagramOAuthPlatform(WebClient webClient, SnsOAuthProperties snsOAuthProperties) { diff --git a/src/main/java/com/example/cherrydan/sns/service/NaverBlogService.java b/src/main/java/com/example/cherrydan/sns/service/NaverBlogService.java index 0929ae47..b8c7b8a9 100644 --- a/src/main/java/com/example/cherrydan/sns/service/NaverBlogService.java +++ b/src/main/java/com/example/cherrydan/sns/service/NaverBlogService.java @@ -29,7 +29,7 @@ public String verifyAndSave(String code, String blogUrl, User user, SnsPlatform SyndFeedInput input = new SyndFeedInput(); SyndFeed feed = input.build(new XmlReader(new URL(rssUrl))); String description = feed.getDescription(); - + if (description != null && description.contains(code)) { Optional existing = snsConnectionRepository.findByUserAndPlatform(user, platform); if (existing.isPresent()) { @@ -59,21 +59,21 @@ public String verifyAndSave(String code, String blogUrl, User user, SnsPlatform private String getRssUrl(String blogUrl) { if (blogUrl == null || blogUrl.isBlank()) { - throw new IllegalArgumentException(ErrorMessage.NAVER_BLOG_INVALID_URL.getMessage()); + throw new SnsException(ErrorMessage.NAVER_BLOG_INVALID_URL); } String url = blogUrl.trim().toLowerCase(); String domain = url.replaceFirst("^https?://", ""); if (!domain.startsWith("m.blog.naver.com") && !domain.startsWith("blog.naver.com")) { - throw new IllegalArgumentException(ErrorMessage.NAVER_BLOG_INVALID_URL.getMessage()); + throw new SnsException(ErrorMessage.NAVER_BLOG_INVALID_URL); } String id = domain.replaceFirst("^(m\\.)?blog\\.naver\\.com/", "").split("[/?#]")[0]; if (id.isBlank()) { - throw new IllegalArgumentException(ErrorMessage.NAVER_BLOG_INVALID_URL.getMessage()); + throw new SnsException(ErrorMessage.NAVER_BLOG_INVALID_URL); } if (!id.matches("^[a-zA-Z0-9._-]+$")) { - throw new IllegalArgumentException(ErrorMessage.NAVER_BLOG_INVALID_ID.getMessage()); + throw new SnsException(ErrorMessage.NAVER_BLOG_INVALID_ID); } return "https://rss.blog.naver.com/" + id; diff --git a/src/main/java/com/example/cherrydan/sns/service/OAuthPlatform.java b/src/main/java/com/example/cherrydan/sns/service/OAuthPlatform.java index 051a4f31..45d6e02e 100644 --- a/src/main/java/com/example/cherrydan/sns/service/OAuthPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/service/OAuthPlatform.java @@ -13,9 +13,10 @@ public interface OAuthPlatform { /** * OAuth 인증 URL을 생성합니다. + * @param state OAuth state 파라미터 (CSRF 방지 및 사용자 식별용) * @return 인증 URL */ - String generateAuthUrl(); + String generateAuthUrl(String state); /** * 인증 코드를 사용하여 액세스 토큰을 획득합니다. diff --git a/src/main/java/com/example/cherrydan/sns/service/OAuthStateService.java b/src/main/java/com/example/cherrydan/sns/service/OAuthStateService.java new file mode 100644 index 00000000..1668f023 --- /dev/null +++ b/src/main/java/com/example/cherrydan/sns/service/OAuthStateService.java @@ -0,0 +1,69 @@ +package com.example.cherrydan.sns.service; + +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.OAuthStateException; +import com.example.cherrydan.common.util.JwtSecretKeyProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.SignatureException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; + +@Slf4j +@Service +public class OAuthStateService { + + private final SecretKey secretKey; + private static final long STATE_VALIDITY_IN_MINUTES = 5; + + public OAuthStateService(@Value("${jwt.secret}") String secret) { + this.secretKey = JwtSecretKeyProvider.createSecretKey(secret); + } + + public String createState(Long userId) { + Instant now = Instant.now(); + Instant expiration = now.plus(STATE_VALIDITY_IN_MINUTES, ChronoUnit.MINUTES); + + String state = Jwts.builder() + .subject(userId.toString()) + .claim("type", "oauth_state") + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + + log.info("OAuth state 생성 완료: userId={}", userId); + return state; + } + + public Long parseState(String state) { + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(state) + .getPayload(); + + Long userId = Long.parseLong(claims.getSubject()); + log.info("OAuth state 검증 성공: userId={}", userId); + return userId; + + } catch (ExpiredJwtException e) { + log.error("만료된 OAuth state: {}", e.getMessage()); + throw new OAuthStateException(ErrorMessage.OAUTH_STATE_EXPIRED); + } catch (SignatureException e) { + log.error("변조된 OAuth state 감지: {}", e.getMessage()); + throw new OAuthStateException(ErrorMessage.OAUTH_STATE_INVALID); + } catch (Exception e) { + log.error("OAuth state 파싱 실패: {}", e.getMessage()); + throw new OAuthStateException(ErrorMessage.OAUTH_STATE_PARSE_FAILED); + } + } +} diff --git a/src/main/java/com/example/cherrydan/sns/service/SnsOAuthService.java b/src/main/java/com/example/cherrydan/sns/service/SnsOAuthService.java index 8d0ac8c4..677571f8 100644 --- a/src/main/java/com/example/cherrydan/sns/service/SnsOAuthService.java +++ b/src/main/java/com/example/cherrydan/sns/service/SnsOAuthService.java @@ -9,6 +9,7 @@ import com.example.cherrydan.sns.dto.UserInfo; import com.example.cherrydan.sns.repository.SnsConnectionRepository; import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -35,6 +36,22 @@ public class SnsOAuthService { private final SnsConnectionRepository snsConnectionRepository; private final Map oauthPlatforms; + private final OAuthStateService oAuthStateService; + private final UserService userService; + + /** + * OAuth 콜백을 처리합니다. + * State 파싱, 사용자 조회, SNS 연동을 한 번에 처리합니다. + * @param code OAuth 인증 코드 + * @param state OAuth state (userId 암호화) + * @param platform SNS 플랫폼 + * @return 연동 결과 + */ + public Mono handleCallback(String code, String state, SnsPlatform platform) { + Long userId = oAuthStateService.parseState(state); + User user = userService.getUserById(userId); + return connect(user, code, platform); + } /** * OAuth 인증을 통해 SNS 플랫폼과 연동합니다. @@ -55,11 +72,9 @@ public Mono connect(User user, String code, SnsPlatform p .flatMap(tokenResponse -> oauthPlatform.getUserInfo(tokenResponse.getAccessToken()) .map(userInfo -> { SnsConnection connection = createOrUpdateConnection( - user, platform, userInfo.getId(), userInfo.getUrl(), - tokenResponse.getAccessToken(), tokenResponse.getRefreshToken(), - calculateExpiresAt(tokenResponse.getExpiresIn()) + user, platform, userInfo.getId(), userInfo.getUrl() ); - + log.info("{} 연동 완료: user={}, userId={}", platform, user.getId(), userInfo.getId()); return SnsConnectionResponse.from(connection); })) @@ -69,14 +84,26 @@ public Mono connect(User user, String code, SnsPlatform p }); } + /** + * OAuth 인증 URL을 생성합니다 (userId로 state 자동 생성). + * @param platform SNS 플랫폼 + * @param userId 사용자 ID + * @return 인증 URL + */ + public String getAuthUrlWithState(SnsPlatform platform, Long userId) { + String state = oAuthStateService.createState(userId); + return getAuthUrl(platform, state); + } + /** * OAuth 인증 URL을 생성합니다. * @param platform SNS 플랫폼 + * @param state OAuth state 파라미터 (CSRF 방지 및 사용자 식별용) * @return 인증 URL */ - public String getAuthUrl(SnsPlatform platform) { + public String getAuthUrl(SnsPlatform platform, String state) { OAuthPlatform oauthPlatform = getOAuthPlatform(platform); - return oauthPlatform.generateAuthUrl(); + return oauthPlatform.generateAuthUrl(state); } /** @@ -151,31 +178,15 @@ private OAuthPlatform getOAuthPlatform(SnsPlatform platform) { * @param platform SNS 플랫폼 * @param snsUserId SNS 사용자 ID * @param snsUrl SNS URL - * @param accessToken 액세스 토큰 - * @param refreshToken 리프레시 토큰 - * @param expiresAt 만료 시간 * @return SNS 연동 정보 */ - private SnsConnection createOrUpdateConnection(User user, SnsPlatform platform, String snsUserId, - String snsUrl, String accessToken, String refreshToken, - LocalDateTime expiresAt) { + private SnsConnection createOrUpdateConnection(User user, SnsPlatform platform, String snsUserId, String snsUrl) { SnsConnection connection = snsConnectionRepository.findByUserAndPlatformIgnoreActive(user, platform) .orElseGet(() -> SnsConnection.builder().user(user).platform(platform).build()); - - connection.updateSnsInfo(snsUserId, snsUrl, accessToken, refreshToken, expiresAt); + + connection.updateSnsInfo(snsUserId, snsUrl); connection.setIsActive(true); - - return snsConnectionRepository.save(connection); - } - /** - * 토큰 만료 시간을 계산합니다. - * @param expiresIn 만료 시간(초) - * @return 만료 시간 - */ - private LocalDateTime calculateExpiresAt(Integer expiresIn) { - return expiresIn != null ? - LocalDateTime.now().plusSeconds(expiresIn) : - LocalDateTime.now().plusYears(1); // 기본값 1년 + return snsConnectionRepository.save(connection); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/sns/service/TikTokOAuthPlatform.java b/src/main/java/com/example/cherrydan/sns/service/TikTokOAuthPlatform.java index 76458476..faf71282 100644 --- a/src/main/java/com/example/cherrydan/sns/service/TikTokOAuthPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/service/TikTokOAuthPlatform.java @@ -1,12 +1,18 @@ package com.example.cherrydan.sns.service; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.SnsException; import com.example.cherrydan.sns.config.SnsOAuthProperties; import com.example.cherrydan.sns.domain.SnsPlatform; +import com.example.cherrydan.sns.dto.TokenResponse; import com.example.cherrydan.sns.dto.UserInfo; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Mono; @@ -23,7 +29,6 @@ */ @Slf4j @Component -@org.springframework.context.annotation.Profile({"local", "dev", "test"}) public class TikTokOAuthPlatform extends AbstractOAuthPlatform { // 임시로 Map에 저장 (실제로는 Redis나 세션 사용해야 함) @@ -39,13 +44,13 @@ protected String getClientIdParamName() { } @Override - public String generateAuthUrl() { + public String generateAuthUrl(String state) { SnsOAuthProperties.PlatformConfig config = getPlatformConfig(); // PKCE용 code verifier와 challenge 생성 String codeVerifier = generateCodeVerifier(); String codeChallenge = generateCodeChallenge(codeVerifier); - String state = generateRandomState(); +// String state = generateRandomState(); // state를 키로 하여 codeVerifier 저장 codeVerifierStore.put(state, codeVerifier); @@ -63,14 +68,14 @@ public String generateAuthUrl() { } @Override - public Mono getAccessToken(String code) { + public Mono getAccessToken(String code) { // TikTok OAuth에서는 state 파라미터를 받아와야 하지만, // 현재 구조상 어려우므로 임시로 테스트 환경에서는 우회 log.warn("TikTok OAuth는 PKCE가 필요하지만 현재 구조상 제한이 있습니다."); // 실제 TikTok API 호출 시도 SnsOAuthProperties.PlatformConfig config = getPlatformConfig(); - org.springframework.util.MultiValueMap formData = new org.springframework.util.LinkedMultiValueMap<>(); + MultiValueMap formData = new LinkedMultiValueMap<>(); formData.add("client_key", config.getClientId()); formData.add("client_secret", config.getClientSecret()); formData.add("grant_type", "authorization_code"); @@ -86,10 +91,10 @@ public Mono getAccessToken(String c return webClient.post() .uri(config.getTokenUrl()) - .contentType(org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) .bodyValue(formData) .retrieve() - .bodyToMono(com.example.cherrydan.sns.dto.TokenResponse.class) + .bodyToMono(TokenResponse.class) .doOnError(error -> log.error("TikTok 토큰 획득 실패: {}", error.getMessage())); } @@ -116,7 +121,7 @@ private String generateCodeChallenge(String codeVerifier) { return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); } catch (Exception e) { log.error("Code challenge 생성 실패", e); - throw new RuntimeException("Code challenge 생성 실패", e); + throw new SnsException(ErrorMessage.SNS_CODE_CHALLENGE_FAILED); } } diff --git a/src/main/java/com/example/cherrydan/sns/service/YouTubeOAuthPlatform.java b/src/main/java/com/example/cherrydan/sns/service/YouTubeOAuthPlatform.java index 2e8ac606..e52acf87 100644 --- a/src/main/java/com/example/cherrydan/sns/service/YouTubeOAuthPlatform.java +++ b/src/main/java/com/example/cherrydan/sns/service/YouTubeOAuthPlatform.java @@ -1,5 +1,7 @@ package com.example.cherrydan.sns.service; +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.SnsException; import com.example.cherrydan.sns.config.SnsOAuthProperties; import com.example.cherrydan.sns.domain.SnsPlatform; import com.example.cherrydan.sns.dto.UserInfo; @@ -37,16 +39,22 @@ public Mono getUserInfo(String accessToken) { .retrieve() .bodyToMono(Map.class) .doOnError(error -> log.error("YouTube 사용자 정보 조회 실패: {}", error.getMessage())) - .map(response -> { + .flatMap(response -> { List> items = (List>) response.get("items"); + if (items == null || items.isEmpty()){ + return Mono.error(new SnsException(ErrorMessage.SNS_CONNECTION_FAILED)); + } + Map item = items.get(0); String id = (String) item.get("id"); + Map snippet = (Map) item.get("snippet"); String customUrl = (String) snippet.get("customUrl"); String channelUrl = customUrl != null && customUrl.startsWith("@") ? "https://www.youtube.com/" + customUrl : "https://www.youtube.com/channel/" + id; - return new UserInfo(id, channelUrl); + + return Mono.just(new UserInfo(id, channelUrl)); }); } diff --git a/src/main/java/com/example/cherrydan/user/controller/UserKeywordController.java b/src/main/java/com/example/cherrydan/user/controller/UserKeywordController.java index 932881fc..70b8c176 100644 --- a/src/main/java/com/example/cherrydan/user/controller/UserKeywordController.java +++ b/src/main/java/com/example/cherrydan/user/controller/UserKeywordController.java @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; import org.springframework.data.domain.Page; import org.springframework.format.annotation.DateTimeFormat; @@ -25,6 +26,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -42,33 +44,35 @@ public class UserKeywordController { summary = "내 키워드 알림 목록 조회", description = """ 사용자의 키워드 알림 히스토리를 조회합니다. - + **쿼리 파라미터 예시:** - ?page=0&size=20&sort=alertDate,desc - ?page=1&size=10&sort=alertDate,asc - + **정렬 가능한 필드:** - alertDate: 알림 발송 날짜 (기본값, DESC) - + **여러 정렬 조건 (쿼리 파라미터):** - ?sort=alertDate,desc&sort=id,asc (복수 정렬) - ?sort=alertDate,desc (단일 정렬, 기본값) - ?sort=alertDate,asc (오래된 순) - + + sort : 정렬 기준 (예: alertDate,desc) -> 선택사항 + **주의:** 이는 Request Body가 아닌 **Query Parameter**입니다. """, security = { @SecurityRequirement(name = "bearerAuth") } ) @GetMapping("/alerts") - public ApiResponse> getUserKeywordAlerts( + public ResponseEntity>> getUserKeywordAlerts( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @PageableDefault(size = 20, sort = "alertDate") Pageable pageable ) { Page alerts = userKeywordService.getUserKeywordAlerts(currentUser.getId(), pageable); PageListResponseDTO response = PageListResponseDTO.from(alerts); - return ApiResponse.success("키워드 알림 목록 조회 성공", response); + return ResponseEntity.ok(ApiResponse.success("키워드 알림 목록 조회 성공", response)); } - + @Operation( summary = "내 키워드 목록 조회", @@ -76,13 +80,13 @@ public ApiResponse> getUser security = { @SecurityRequirement(name = "bearerAuth") } ) @GetMapping("/me") - public ApiResponse> getMyKeywords( + public ResponseEntity>> getMyKeywords( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser ) { if (currentUser == null) throw new AuthException(ErrorMessage.AUTH_UNAUTHORIZED); List keywords = userKeywordService.getKeywords(currentUser.getId()) .stream().map(UserKeywordResponseDTO::fromKeyword).toList(); - return ApiResponse.success("키워드 목록 조회 성공", keywords); + return ResponseEntity.ok(ApiResponse.success("키워드 목록 조회 성공", keywords)); } @Operation( @@ -91,13 +95,13 @@ public ApiResponse> getMyKeywords( security = { @SecurityRequirement(name = "bearerAuth") } ) @PostMapping("/me") - public ApiResponse addMyKeyword( - @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, + public ResponseEntity> addMyKeyword( + @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestBody UserKeywordRequestDTO request ) { if (currentUser == null) throw new AuthException(ErrorMessage.AUTH_UNAUTHORIZED); userKeywordService.addKeyword(currentUser.getId(), request.getKeyword()); - return ApiResponse.success("키워드 등록 성공"); + return ResponseEntity.ok(ApiResponse.success("키워드 등록 성공")); } @Operation( @@ -106,13 +110,13 @@ public ApiResponse addMyKeyword( security = { @SecurityRequirement(name = "bearerAuth") } ) @DeleteMapping("/me/{keywordId}") - public ApiResponse deleteMyKeyword( - @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, + public ResponseEntity> deleteMyKeyword( + @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @PathVariable("keywordId") Long keywordId ) { if (currentUser == null) throw new AuthException(ErrorMessage.AUTH_UNAUTHORIZED); userKeywordService.removeKeywordById(currentUser.getId(), keywordId); - return ApiResponse.success("키워드 삭제 성공"); + return ResponseEntity.ok(ApiResponse.success("키워드 삭제 성공")); } @Operation( @@ -120,20 +124,23 @@ public ApiResponse deleteMyKeyword( description = """ 특정 키워드로 매칭된 캠페인 목록을 조회합니다. 등록되지 않은 키워드로 조회 시 404 에러를 반환합니다. - + **쿼리 파라미터 예시:** - ?page=0&size=20&keyword=고기 - ?page=1&size=10&keyword=서울 - - **정렬**: 캠페인 생성 시각 내림차순 (고정) - **검색**: MySQL FULLTEXT 검색 사용 (ngram 파서) - + + page : 페이지 번호 + + size : 페이지 크기 (한 페이지당 캠페인 수) + + sort : 정렬 기준 (예: reviewerAnnouncement,desc) -> 선택사항 + **주의:** 이는 Request Body가 아닌 **Query Parameter**입니다. """, security = { @SecurityRequirement(name = "bearerAuth") } ) @GetMapping("/campaigns/personalized") - public ApiResponse> getPersonalizedCampaignsByKeyword( + public ResponseEntity>> getPersonalizedCampaignsByKeyword( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestParam("keyword") String keyword, @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @@ -141,7 +148,7 @@ public ApiResponse> getPersonalizedCamp ) { Page campaigns = userKeywordService.getPersonalizedCampaignsByKeyword(keyword, date, currentUser.getId(), pageable); PageListResponseDTO response = PageListResponseDTO.from(campaigns); - return ApiResponse.success("맞춤형 캠페인 조회 성공", response); + return ResponseEntity.ok(ApiResponse.success("맞춤형 캠페인 조회 성공", response)); } @Operation( @@ -150,12 +157,12 @@ public ApiResponse> getPersonalizedCamp security = { @SecurityRequirement(name = "bearerAuth") } ) @DeleteMapping("/alerts") - public ApiResponse deleteKeywordAlert( + public ResponseEntity> deleteKeywordAlert( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestBody AlertIdsRequestDTO request ) { userKeywordService.deleteKeywordAlert(currentUser.getId(), request.getAlertIds()); - return ApiResponse.success("키워드 알림 삭제 성공", null); + return ResponseEntity.ok(ApiResponse.success("키워드 알림 삭제 성공", null)); } @Operation( @@ -164,11 +171,11 @@ public ApiResponse deleteKeywordAlert( security = { @SecurityRequirement(name = "bearerAuth") } ) @PatchMapping("/alerts/read") - public ApiResponse markKeywordAlertsAsRead( + public ResponseEntity> markKeywordAlertsAsRead( @Parameter(hidden = true) @AuthenticationPrincipal UserDetailsImpl currentUser, @RequestBody AlertIdsRequestDTO request ) { userKeywordService.markKeywordAlertsAsRead(currentUser.getId(), request.getAlertIds()); - return ApiResponse.success("키워드 알림 읽음 처리 성공", null); + return ResponseEntity.ok(ApiResponse.success("키워드 알림 읽음 처리 성공", null)); } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/domain/KeywordCampaignAlert.java b/src/main/java/com/example/cherrydan/user/domain/KeywordCampaignAlert.java index 0fcac5e7..3ff0de60 100644 --- a/src/main/java/com/example/cherrydan/user/domain/KeywordCampaignAlert.java +++ b/src/main/java/com/example/cherrydan/user/domain/KeywordCampaignAlert.java @@ -47,7 +47,7 @@ public void markAsNotified() { // 발송 완료 상태로 변경 (간단!) this.alertStage = 1; } - + public boolean isNotified() { return alertStage > 0; } @@ -58,4 +58,11 @@ public boolean isNotified() { public void markAsRead() { this.isRead = true; } + + /** + * 숨김 처리 (소프트 삭제) + */ + public void hide() { + this.isVisibleToUser = false; + } } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/domain/User.java b/src/main/java/com/example/cherrydan/user/domain/User.java index abc47113..d46e72bf 100644 --- a/src/main/java/com/example/cherrydan/user/domain/User.java +++ b/src/main/java/com/example/cherrydan/user/domain/User.java @@ -1,7 +1,7 @@ package com.example.cherrydan.user.domain; import com.example.cherrydan.common.entity.BaseTimeEntity; -import com.example.cherrydan.oauth.model.AuthProvider; +import com.example.cherrydan.oauth.domain.AuthProvider; import com.example.cherrydan.utils.MaskingUtil; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -10,6 +10,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -87,15 +88,26 @@ public String getMaskedEmail() { // 소프트 삭제 public void softDelete() { this.isActive = false; + this.setDeletedAt(LocalDateTime.now()); } // 계정 복구 public void restore() { this.isActive = true; + this.setDeletedAt(null); } // 활성 상태 확인 public boolean isDeleted() { return !this.isActive; } + + // 1년 이내 복구 가능 여부 확인 + public boolean isRestorableWithin1Year() { + if (this.getDeletedAt() == null) { + return false; + } + LocalDateTime oneYearAgo = LocalDateTime.now().minusYears(1); + return this.getDeletedAt().isAfter(oneYearAgo); + } } diff --git a/src/main/java/com/example/cherrydan/user/domain/UserLoginHistory.java b/src/main/java/com/example/cherrydan/user/domain/UserLoginHistory.java index fbe0565f..4e51e997 100644 --- a/src/main/java/com/example/cherrydan/user/domain/UserLoginHistory.java +++ b/src/main/java/com/example/cherrydan/user/domain/UserLoginHistory.java @@ -9,6 +9,7 @@ @Table(name = "user_login_history") @Entity @Builder +@Getter @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode(callSuper = false) diff --git a/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertMessage.java b/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertMessage.java new file mode 100644 index 00000000..6663e4f6 --- /dev/null +++ b/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertMessage.java @@ -0,0 +1,45 @@ +package com.example.cherrydan.user.domain.vo; + +import com.example.cherrydan.notification.domain.AlertMessage; + +import java.util.Map; + +/** + * 키워드 알림 메시지 Value Object + */ +public record KeywordAlertMessage( + String title, + String body, + Map data +) implements AlertMessage { + private static final String DEFAULT_TITLE = "체리단"; + private static final String MESSAGE_TEMPLATE = "'%s' 키워드 캠페인이 %s건 등록됐어요."; + private static final String TYPE = "keyword_campaign"; + private static final String ACTION = "open_personalized_page"; + + /** + * 키워드와 캠페인 수로부터 알림 메시지 생성 + * + * @param keyword 키워드 + * @param campaignCount 캠페인 수 + * @param policy 알림 정책 + * @return 알림 메시지 + */ + public static KeywordAlertMessage create( + String keyword, + int campaignCount, + KeywordAlertPolicy policy + ) { + String countText = policy.formatCountText(campaignCount); + String body = String.format(MESSAGE_TEMPLATE, keyword, countText); + + Map data = Map.of( + "type", TYPE, + "keyword", keyword, + "dailyNewCount", String.valueOf(campaignCount), + "action", ACTION + ); + + return new KeywordAlertMessage(DEFAULT_TITLE, body, data); + } +} diff --git a/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertPolicy.java b/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertPolicy.java new file mode 100644 index 00000000..1483e897 --- /dev/null +++ b/src/main/java/com/example/cherrydan/user/domain/vo/KeywordAlertPolicy.java @@ -0,0 +1,58 @@ +package com.example.cherrydan.user.domain.vo; + +import org.springframework.util.Assert; + +import static org.springframework.util.Assert.*; + +/** + * 키워드 알림 정책 Value Object + */ +public record KeywordAlertPolicy(int highThreshold, int lowThreshold) { + + public static final KeywordAlertPolicy DEFAULT = new KeywordAlertPolicy(100, 10); + + public KeywordAlertPolicy { + state(highThreshold > lowThreshold, "상위 임계값은 하위 임계값보다 커야 합니다"); + state(lowThreshold > 0, "임계값은 0보다 커야 합니다"); + } + + /** + * 캠페인 수를 범위별 텍스트로 변환 + * + * @param count 캠페인 수 + * @return 포맷된 텍스트 (예: "10+", "100+", "5") + */ + public String formatCountText(int count) { + if (count >= highThreshold) { + return highThreshold + "+"; + } + if (count >= lowThreshold) { + return lowThreshold + "+"; + } + return String.valueOf(count); + } + + /** + * 캠페인 수에 따른 알림 단계 결정 + * + * @param count 캠페인 수 + * @return 알림 단계 (0: 미발송, 1: 10+건 발송, 2: 100+건 발송) + */ + public int determineAlertStage(int count) { + if (count >= highThreshold) return 2; + if (count >= lowThreshold) return 1; + return 0; + } + + /** + * 특정 단계의 알림을 발송해야 하는지 확인 + * + * @param count 캠페인 수 + * @param currentStage 현재 알림 단계 + * @return 발송 필요 여부 + */ + public boolean shouldNotify(int count, int currentStage) { + int requiredStage = determineAlertStage(count); + return requiredStage > currentStage; + } +} diff --git a/src/main/java/com/example/cherrydan/user/dto/AlertIdsRequestDTO.java b/src/main/java/com/example/cherrydan/user/dto/AlertIdsRequestDTO.java index 952bad72..53b2dd72 100644 --- a/src/main/java/com/example/cherrydan/user/dto/AlertIdsRequestDTO.java +++ b/src/main/java/com/example/cherrydan/user/dto/AlertIdsRequestDTO.java @@ -1,5 +1,6 @@ package com.example.cherrydan.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -9,6 +10,8 @@ @Getter @NoArgsConstructor @AllArgsConstructor +@Schema(description = "알림 ID 리스트 요청") public class AlertIdsRequestDTO { + @Schema(description = "알림 ID 리스트", example = "[1, 2, 3]", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private List alertIds; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/dto/KeywordCampaignAlertResponseDTO.java b/src/main/java/com/example/cherrydan/user/dto/KeywordCampaignAlertResponseDTO.java index b88d496f..a5985d05 100644 --- a/src/main/java/com/example/cherrydan/user/dto/KeywordCampaignAlertResponseDTO.java +++ b/src/main/java/com/example/cherrydan/user/dto/KeywordCampaignAlertResponseDTO.java @@ -15,20 +15,20 @@ @Builder @Schema(description = "키워드 캠페인 알림 응답 DTO") public class KeywordCampaignAlertResponseDTO { - - @Schema(description = "알림 ID", example = "1") + + @Schema(description = "알림 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long id; - - @Schema(description = "매칭된 키워드", example = "뷰티") + + @Schema(description = "매칭된 키워드", example = "뷰티", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String keyword; - - @Schema(description = "읽음 상태", example = "false") + + @Schema(description = "읽음 상태", example = "false", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Boolean isRead; - @Schema(description = "매칭된 캠페인 수", example = "10") + @Schema(description = "매칭된 캠페인 수", example = "10", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Integer campaignCount; - @Schema(description = "알림 날짜", example = "2024-07-21") + @Schema(description = "알림 날짜", example = "2024-07-21", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private LocalDate alertDate; /** diff --git a/src/main/java/com/example/cherrydan/user/dto/UserKeywordRequestDTO.java b/src/main/java/com/example/cherrydan/user/dto/UserKeywordRequestDTO.java index ccdf5d06..620cf77b 100644 --- a/src/main/java/com/example/cherrydan/user/dto/UserKeywordRequestDTO.java +++ b/src/main/java/com/example/cherrydan/user/dto/UserKeywordRequestDTO.java @@ -1,5 +1,6 @@ package com.example.cherrydan.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -9,6 +10,8 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(description = "사용자 키워드 요청") public class UserKeywordRequestDTO { + @Schema(description = "키워드", example = "뷰티", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String keyword; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/dto/UserKeywordResponseDTO.java b/src/main/java/com/example/cherrydan/user/dto/UserKeywordResponseDTO.java index 46b43913..6c524568 100644 --- a/src/main/java/com/example/cherrydan/user/dto/UserKeywordResponseDTO.java +++ b/src/main/java/com/example/cherrydan/user/dto/UserKeywordResponseDTO.java @@ -1,6 +1,7 @@ package com.example.cherrydan.user.dto; import com.example.cherrydan.user.domain.UserKeyword; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,8 +11,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(description = "사용자 키워드 응답") public class UserKeywordResponseDTO { + @Schema(description = "키워드 ID", example = "1", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private Long id; + + @Schema(description = "키워드", example = "뷰티", nullable = false, requiredMode = Schema.RequiredMode.REQUIRED) private String keyword; public static UserKeywordResponseDTO fromKeyword(UserKeyword keyword) { diff --git a/src/main/java/com/example/cherrydan/user/dto/UserUpdateRequestDTO.java b/src/main/java/com/example/cherrydan/user/dto/UserUpdateRequestDTO.java index 93d56ab8..80bf0a51 100644 --- a/src/main/java/com/example/cherrydan/user/dto/UserUpdateRequestDTO.java +++ b/src/main/java/com/example/cherrydan/user/dto/UserUpdateRequestDTO.java @@ -1,6 +1,7 @@ package com.example.cherrydan.user.dto; import com.example.cherrydan.user.domain.Gender; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -10,8 +11,12 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(description = "사용자 정보 수정 요청") public class UserUpdateRequestDTO { + @Schema(description = "닉네임", example = "cherrydan", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String nickname; + @Schema(description = "출생년도", example = "1990", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private Integer birthYear; + @Schema(description = "성별", example = "MALE", nullable = true, requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String gender; } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/repository/KeywordCampaignAlertRepository.java b/src/main/java/com/example/cherrydan/user/repository/KeywordCampaignAlertRepository.java index a835d1c8..1de061ab 100644 --- a/src/main/java/com/example/cherrydan/user/repository/KeywordCampaignAlertRepository.java +++ b/src/main/java/com/example/cherrydan/user/repository/KeywordCampaignAlertRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.time.LocalDate; @@ -27,4 +28,21 @@ public interface KeywordCampaignAlertRepository extends JpaRepository findTodayUnnotifiedAlerts(@Param("alertDate") LocalDate alertDate); + + /** + * 사용자가 키워드로 캠페인 조회시 해당 키워드 알림을 읽음 처리 + */ + @Query("UPDATE KeywordCampaignAlert kca SET kca.isRead = true WHERE kca.user.id = :userId AND kca.isVisibleToUser = true AND kca.alertDate = :date AND kca.keyword = :keyword AND kca.isRead = false") + @Modifying + void markAsReadByUserAndKeyword(@Param("userId") Long userId, @Param("keyword") String keyword, @Param("date") LocalDate date); + + /** + * 사용자의 미읽은 키워드 알림 개수 조회 + */ + @Query("SELECT COUNT(kca) FROM KeywordCampaignAlert kca WHERE kca.user.id = :userId AND kca.isRead = false AND kca.isVisibleToUser = true") + Long countUnreadByUserId(@Param("userId") Long userId); + + @Modifying + @Query("DELETE FROM KeywordCampaignAlert kca WHERE kca.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/repository/UserKeywordRepository.java b/src/main/java/com/example/cherrydan/user/repository/UserKeywordRepository.java index 63d956c0..6529d702 100644 --- a/src/main/java/com/example/cherrydan/user/repository/UserKeywordRepository.java +++ b/src/main/java/com/example/cherrydan/user/repository/UserKeywordRepository.java @@ -20,9 +20,12 @@ public interface UserKeywordRepository extends JpaRepository @Query("SELECT uk FROM UserKeyword uk WHERE uk.id = :keywordId AND uk.user.id = :userId AND uk.user.isActive = true") Optional findByIdAndUserId(@Param("keywordId") Long keywordId, @Param("userId") Long userId); + @Query("SELECT COUNT(uk) FROM UserKeyword uk WHERE uk.user.id = :userId AND uk.user.isActive = true") + long countByUserId(@Param("userId") Long userId); + /** * 모든 사용자 키워드를 키워드별로 그룹핑하여 조회 (배치 최적화) - 활성 사용자만 */ - @Query("SELECT uk FROM UserKeyword uk JOIN FETCH uk.user u LEFT JOIN FETCH u.pushSettings WHERE uk.user.isActive = true") + @Query("SELECT uk FROM UserKeyword uk JOIN FETCH uk.user u WHERE uk.user.isActive = true") List findAllWithUser(); } \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/repository/UserRepository.java b/src/main/java/com/example/cherrydan/user/repository/UserRepository.java index 9755134a..d5ab5d3a 100644 --- a/src/main/java/com/example/cherrydan/user/repository/UserRepository.java +++ b/src/main/java/com/example/cherrydan/user/repository/UserRepository.java @@ -1,12 +1,13 @@ package com.example.cherrydan.user.repository; -import com.example.cherrydan.oauth.model.AuthProvider; +import com.example.cherrydan.oauth.domain.AuthProvider; import com.example.cherrydan.user.domain.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -20,29 +21,18 @@ public interface UserRepository extends JpaRepository { @Query("SELECT u FROM User u WHERE u.email = :email AND u.isActive = true") Optional findActiveByEmail(@Param("email") String email); - @Query("SELECT u FROM User u WHERE u.provider = :provider AND u.socialId = :socialId AND u.isActive = true") - Optional findActiveByProviderAndSocialId(@Param("provider") AuthProvider provider, @Param("socialId") String socialId); - // 기존 메서드들 (소프트 삭제된 사용자도 포함) Optional findByEmail(String email); - Optional findByProviderAndSocialId(AuthProvider provider, String socialId); - // 이메일 존재 여부 확인 (활성 사용자만) @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.isActive = true") boolean existsActiveByEmail(@Param("email") String email); - // 특정 이메일이 다른 OAuth 제공자로 이미 가입되었는지 확인 (활성 사용자만) - @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.email = :email AND u.provider != :provider AND u.isActive = true") - boolean existsActiveByEmailAndProviderNot(@Param("email") String email, @Param("provider") AuthProvider provider); - // 기존 메서드들 (하위 호환성) boolean existsByEmail(String email); - - boolean existsByEmailAndProviderNot(String email, AuthProvider provider); - - // 모든 활성 사용자 조회 - @Query("SELECT u FROM User u WHERE u.isActive = true") - List findAllActive(); - + + // 1년 이전에 소프트 딜리트된 유저 조회 + @Query("SELECT u FROM User u WHERE u.isActive = false AND u.deletedAt < :oneYearAgo") + List findUsersDeletedBefore(@Param("oneYearAgo") LocalDateTime oneYearAgo); + } diff --git a/src/main/java/com/example/cherrydan/user/service/KeywordProcessingService.java b/src/main/java/com/example/cherrydan/user/service/KeywordProcessingService.java index b1af7974..abd28179 100644 --- a/src/main/java/com/example/cherrydan/user/service/KeywordProcessingService.java +++ b/src/main/java/com/example/cherrydan/user/service/KeywordProcessingService.java @@ -6,6 +6,8 @@ import com.example.cherrydan.user.domain.KeywordCampaignAlert; import com.example.cherrydan.user.domain.User; import com.example.cherrydan.user.domain.UserKeyword; +import com.example.cherrydan.user.domain.vo.KeywordAlertMessage; +import com.example.cherrydan.user.domain.vo.KeywordAlertPolicy; import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; import com.example.cherrydan.campaign.service.CampaignServiceImpl; import lombok.RequiredArgsConstructor; @@ -20,6 +22,8 @@ import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; + + /** * 키워드 관련 비동기 처리를 담당하는 서비스 * AOP 프록시가 정상 작동하도록 별도 서비스로 분리 @@ -28,8 +32,6 @@ @Service @RequiredArgsConstructor public class KeywordProcessingService { - - private final KeywordCampaignAlertRepository keywordAlertRepository; private final CampaignServiceImpl campaignService; private final NotificationService notificationService; @@ -96,33 +98,9 @@ public CompletableFuture> sendKeywordNotificationAsyn try { // 간단! campaignCount가 어제 신규 증가분 int dailyNewCount = alerts.get(0).getCampaignCount(); - - String title = "체리단"; - String countText; - - // 신규 증가분에 따른 표시 방식 - if (dailyNewCount >= 100) { - countText = "+100"; // 100+건 - } else if (dailyNewCount >= 10) { - countText = "+10"; // 10+건 - } else { - countText = String.valueOf(dailyNewCount); // 정확한 수 (1~9건) - } - - String body = String.format("'%s' 키워드 캠페인이 %s건 등록됐어요. \n지금 체리단에서 확인해 보세요.", - keyword, countText); - - NotificationRequest notificationRequest = NotificationRequest.builder() - .title(title) - .body(body) - .data(java.util.Map.of( - "type", "keyword_campaign", - "keyword", keyword, - "dailyNewCount", String.valueOf(dailyNewCount), - "action", "open_personalized_page" - )) - .priority("high") - .build(); + + KeywordAlertMessage keywordAlertMessage = KeywordAlertMessage.create(keyword, dailyNewCount, KeywordAlertPolicy.DEFAULT); + NotificationRequest notificationRequest = NotificationRequest.create(keywordAlertMessage); // 같은 키워드를 가진 사용자들에게 단체 발송 List userIds = alerts.stream() diff --git a/src/main/java/com/example/cherrydan/user/service/UserDataCleanupService.java b/src/main/java/com/example/cherrydan/user/service/UserDataCleanupService.java new file mode 100644 index 00000000..82f6caeb --- /dev/null +++ b/src/main/java/com/example/cherrydan/user/service/UserDataCleanupService.java @@ -0,0 +1,46 @@ +package com.example.cherrydan.user.service; + +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDataCleanupService { + + private static final int DAYS_UNTIL_PERMANENT_DELETE = 365; + + private final UserRepository userRepository; + private final UserRelatedDataDeletionService userRelatedDataDeletionService; + + public void cleanupExpiredUserData() { + LocalDateTime oneYearAgo = LocalDateTime.now().minusDays(DAYS_UNTIL_PERMANENT_DELETE); + List expiredUsers = userRepository.findUsersDeletedBefore(oneYearAgo); + + if (expiredUsers.isEmpty()) { + log.info("1년 경과한 소프트 딜리트 유저가 없습니다"); + return; + } + + log.info("{}명의 1년 경과 소프트 딜리트 유저 데이터 삭제를 시작합니다", expiredUsers.size()); + + int successCount = 0; + + for (User user : expiredUsers) { + try { + userRelatedDataDeletionService.deleteUserRelatedData(user.getId()); + successCount++; + } catch (Exception e) { + log.error("유저 ID {}의 연관 데이터 삭제 실패: {}", user.getId(), e.getMessage(), e); + } + } + + log.info("1년 경과 유저 데이터 삭제 완료 - 성공: {}건, 총: {}건", successCount, expiredUsers.size()); + } +} diff --git a/src/main/java/com/example/cherrydan/user/service/UserKeywordService.java b/src/main/java/com/example/cherrydan/user/service/UserKeywordService.java index a118b49e..756a0b33 100644 --- a/src/main/java/com/example/cherrydan/user/service/UserKeywordService.java +++ b/src/main/java/com/example/cherrydan/user/service/UserKeywordService.java @@ -44,7 +44,7 @@ public void addKeyword(Long userId, String keyword) { throw new UserException(ErrorMessage.USER_KEYWORD_ALREADY_EXISTS); } - long keywordCount = userKeywordRepository.findByUserId(userId).size(); + long keywordCount = userKeywordRepository.countByUserId(userId); if (keywordCount >= 5) { throw new UserException(ErrorMessage.USER_KEYWORD_LIMIT_EXCEEDED); } @@ -75,7 +75,7 @@ public void removeKeywordById(Long userId, Long keywordId) { /** * 키워드 맞춤 캠페인 알림 대상 업데이트 (10개와 100개를 넘는 순간에만) - * 새벽 5시에 배치 처리로 실행 + * 새벽 7시 30분에 배치 처리로 실행 */ @Transactional public void updateKeywordCampaignAlerts() { @@ -184,27 +184,28 @@ public Page getUserKeywordAlerts(Long userId, P /** * 특정 키워드로 맞춤형 캠페인 목록 조회 */ - @Transactional(readOnly = true) + @Transactional public Page getPersonalizedCampaignsByKeyword(String keyword, LocalDate date, Long userId, Pageable pageable) { return campaignService.getPersonalizedCampaignsByKeyword(keyword, date, userId, pageable); } /** - * 맞춤형 알림 삭제 (배열) + * 맞춤형 알림 삭제 (소프트 삭제) */ @Transactional public void deleteKeywordAlert(Long userId, List alertIds) { List alerts = keywordAlertRepository.findAllById(alertIds); - + // 모든 알림이 해당 사용자의 것인지 확인 for (KeywordCampaignAlert alert : alerts) { if (!alert.getUser().getId().equals(userId)) { throw new UserException(ErrorMessage.USER_KEYWORD_ACCESS_DENIED); } + alert.hide(); } - - keywordAlertRepository.deleteAll(alerts); - log.info("키워드 알림 삭제 완료: userId={}, count={}", userId, alertIds.size()); + + keywordAlertRepository.saveAll(alerts); + log.info("키워드 알림 숨김 처리 완료: userId={}, count={}", userId, alertIds.size()); } /** diff --git a/src/main/java/com/example/cherrydan/user/service/UserRelatedDataDeletionService.java b/src/main/java/com/example/cherrydan/user/service/UserRelatedDataDeletionService.java new file mode 100644 index 00000000..dc1fc15b --- /dev/null +++ b/src/main/java/com/example/cherrydan/user/service/UserRelatedDataDeletionService.java @@ -0,0 +1,45 @@ +package com.example.cherrydan.user.service; + +import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.campaign.repository.BookmarkRepository; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.fcm.repository.UserFCMTokenRepository; +import com.example.cherrydan.inquiry.repository.InquiryRepository; +import com.example.cherrydan.oauth.repository.RefreshTokenRepository; +import com.example.cherrydan.sns.repository.SnsConnectionRepository; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserRelatedDataDeletionService { + + private final SnsConnectionRepository snsConnectionRepository; + private final CampaignStatusRepository campaignStatusRepository; + private final InquiryRepository inquiryRepository; + private final BookmarkRepository bookmarkRepository; + private final ActivityAlertRepository activityAlertRepository; + private final KeywordCampaignAlertRepository keywordCampaignAlertRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final UserFCMTokenRepository userFCMTokenRepository; + + @Transactional + public void deleteUserRelatedData(Long userId) { + log.info("유저 ID {}의 연관 데이터 삭제 시작", userId); + + snsConnectionRepository.deleteByUserId(userId); + campaignStatusRepository.deleteByUserId(userId); + inquiryRepository.deleteByUserId(userId); + bookmarkRepository.deleteByUserId(userId); + activityAlertRepository.deleteByUserId(userId); + keywordCampaignAlertRepository.deleteByUserId(userId); + refreshTokenRepository.deleteByUserId(userId); + userFCMTokenRepository.deleteByUserId(userId); + + log.info("유저 ID {}의 모든 연관 데이터 삭제 완료", userId); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/cherrydan/user/service/UserService.java b/src/main/java/com/example/cherrydan/user/service/UserService.java index acf44c98..49fb54ff 100644 --- a/src/main/java/com/example/cherrydan/user/service/UserService.java +++ b/src/main/java/com/example/cherrydan/user/service/UserService.java @@ -2,6 +2,7 @@ import com.example.cherrydan.common.exception.ErrorMessage; import com.example.cherrydan.common.exception.UserException; +import com.example.cherrydan.fcm.service.FCMTokenService; import com.example.cherrydan.oauth.dto.UserInfoDTO; import com.example.cherrydan.oauth.security.jwt.UserDetailsImpl; import com.example.cherrydan.user.domain.User; @@ -18,6 +19,7 @@ public class UserService { private final UserRepository userRepository; + private final FCMTokenService fcmTokenService; public UserInfoDTO getCurrentUser(UserDetailsImpl currentUser) { User user = getActiveUserById(currentUser.getId()); @@ -50,8 +52,10 @@ public User updateUserProfile(Long userId, UserUpdateRequestDTO request) { @Transactional public void deleteUser(Long userId) { User user = getActiveUserById(userId); - user.softDelete(); // 소프트 삭제 적용 + user.softDelete(); userRepository.save(user); + + fcmTokenService.deactivateUserTokens(userId); } @Transactional @@ -60,6 +64,8 @@ public void restoreUser(Long userId) { .orElseThrow(() -> new UserException(ErrorMessage.USER_NOT_FOUND)); user.restore(); userRepository.save(user); + + fcmTokenService.activateUserTokens(userId); } // 활성 사용자만 조회 (기본 메서드) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 20b723bc..4a942c6c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -138,4 +138,10 @@ management: readinessstate: enabled: true livenessstate: - enabled: true \ No newline at end of file + enabled: true + +springdoc: + swagger-ui: + doc-expansion: list + defaultModelsExpandDepth: -1 + defaultModelExpandDepth: 5 \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/activity/service/ActivityAlertServiceIntegrationTest.java b/src/test/java/com/example/cherrydan/activity/service/ActivityAlertServiceIntegrationTest.java new file mode 100644 index 00000000..8e1899a9 --- /dev/null +++ b/src/test/java/com/example/cherrydan/activity/service/ActivityAlertServiceIntegrationTest.java @@ -0,0 +1,574 @@ +package com.example.cherrydan.activity.service; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.campaign.domain.Bookmark; +import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.domain.CampaignType; +import com.example.cherrydan.campaign.repository.BookmarkRepository; +import com.example.cherrydan.campaign.repository.CampaignRepository; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.fcm.domain.DeviceType; +import com.example.cherrydan.fcm.domain.UserFCMToken; +import com.example.cherrydan.fcm.dto.NotificationRequest; +import com.example.cherrydan.fcm.dto.NotificationResultDto; +import com.example.cherrydan.fcm.repository.UserFCMTokenRepository; +import com.example.cherrydan.fcm.service.NotificationService; +import com.example.cherrydan.user.domain.Gender; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("local") +class ActivityAlertServiceIntegrationTest { + + @Autowired + private ActivityAlertService activityAlertService; + + @Autowired + private ActivityAlertRepository activityAlertRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private BookmarkRepository bookmarkRepository; + + @Autowired + private CampaignStatusRepository campaignStatusRepository; + + @Autowired + private UserFCMTokenRepository fcmTokenRepository; + + @Autowired + private NotificationService notificationService; + + @PersistenceContext + private EntityManager entityManager; + + private User testUser; + private final String TEST_FCM_TOKEN = "fNfetmFitk3Itehb2g2sxQ:APA91bEmxLFOpnVDHP-fha1rLWQNfAYPPL1qmIRIrSX4MddF3BUrAuDwoCpt4VGsC5EvBGHHBQdFTGWAZax-9v7z7UsFDhy1SDhP664zWctmdjw_dCiPm0I"; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.builder() + .nickname("테스트유저") + .email("test@example.com") + .gender(Gender.MALE) + .build(); + testUser = userRepository.save(testUser); + + // FCM 토큰 저장 + UserFCMToken fcmToken = UserFCMToken.builder() + .userId(testUser.getId()) + .fcmToken(TEST_FCM_TOKEN) + .deviceType(DeviceType.ANDROID) + .isActive(true) + .isAllowed(true) + .build(); + fcmTokenRepository.save(fcmToken); + + System.out.println("=== 테스트 환경 설정 완료 ==="); + System.out.println("사용자 ID: " + testUser.getId()); + System.out.println("FCM 토큰: " + TEST_FCM_TOKEN); + } + + @AfterEach + void cleanup() { + // 테스트 데이터 정리 (외래키 순서 고려) + if (testUser == null) { + return; // 사용자가 생성되지 않았으면 정리할 것 없음 + } + + try { + // 알림 삭제 + activityAlertRepository.deleteAll(); + + // 북마크와 상태 삭제 + bookmarkRepository.deleteAll(); + campaignStatusRepository.deleteAll(); + + // 캠페인 삭제 + campaignRepository.deleteAll(); + + // FCM 토큰 삭제 + fcmTokenRepository.deleteAll(); + + // auth_token 테이블 직접 삭제 - EntityManagerFactory에서 EntityManager 가져와서 트랜잭션 처리 + EntityManager em = entityManager.getEntityManagerFactory().createEntityManager(); + try { + em.getTransaction().begin(); + int deletedRows = em.createNativeQuery("DELETE FROM auth_token WHERE user_id = :userId") + .setParameter("userId", testUser.getId()) + .executeUpdate(); + em.getTransaction().commit(); + System.out.println("auth_token 삭제 완료: " + deletedRows + "개 행"); + } catch (Exception e) { + if (em.getTransaction().isActive()) { + em.getTransaction().rollback(); + } + System.err.println("auth_token 삭제 실패: " + e.getMessage()); + } finally { + em.close(); + } + + // 사용자 삭제 시도 (실패해도 테스트 계속 진행) + try { + userRepository.deleteAll(); + } catch (Exception e) { + System.err.println("사용자 삭제 실패 (auth_token 외래키 제약): " + e.getMessage()); + // 테스트는 계속 진행 + } + } catch (Exception e) { + System.err.println("테스트 데이터 정리 중 오류: " + e.getMessage()); + e.printStackTrace(); + } + } + + @Test + @DisplayName("BOOKMARK_DEADLINE_D1 - 북마크 D-1 알림 생성 및 FCM 전송 테스트") + void testBookmarkDeadlineD1Alert() throws Exception { + // Given: 내일 마감되는 캠페인 생성 + LocalDate tomorrow = LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(1); + Campaign campaign = createCampaign("테스트 캠페인", tomorrow, CampaignType.PRODUCT); + Bookmark bookmark = createBookmark(testUser, campaign, true); + + // When: 알림 생성 + long startMemory = getUsedMemory(); + activityAlertService.updateActivityAlerts(); + + // 비동기 처리 대기 + Thread.sleep(2000); + + long endMemory = getUsedMemory(); + System.out.println("메모리 사용량: " + (endMemory - startMemory) / 1024 / 1024 + " MB"); + + // Then: 알림 확인 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.BOOKMARK_DEADLINE_D1); + + // FCM 전송 테스트 (실제 FCM 서버로 전송) + System.out.println("알림 생성 성공: " + alerts.get(0).getAlertType()); + // sendFCMNotification은 자체 @Transactional이 있어서 안전함 + sendFCMNotification(alerts.get(0), "BOOKMARK_DEADLINE_D1"); + } + + @Test + @DisplayName("BOOKMARK_DEADLINE_DDAY - 북마크 D-Day 알림 생성 및 FCM 전송 테스트") + void testBookmarkDeadlineDDayAlert() throws Exception { + // Given: 오늘 마감되는 캠페인 생성 + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + Campaign campaign = createCampaign("북마크 테스트 캠페인", today, CampaignType.PRODUCT); + createBookmark(testUser, campaign, true); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.BOOKMARK_DEADLINE_DDAY); + + sendFCMNotification(alerts.get(0), "BOOKMARK_DEADLINE_DDAY"); + } + + @Test + @DisplayName("APPLY_RESULT_DDAY - 선정 결과 D-Day 알림 생성 및 FCM 전송 테스트") + void testApplyResultDDayAlert() throws Exception { + // Given: 오늘 선정발표 캠페인 생성 + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + Campaign campaign = Campaign.builder() + .title("선정발표 테스트 캠페인") + .detailUrl("https://example.com/selection_test") + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(10)) + .applyEnd(today.minusDays(10)) + .reviewerAnnouncement(today) // 오늘 선정발표 + .campaignType(CampaignType.PRODUCT) + .isActive(true) + .sourceSite("테스트") + .build(); + campaign = campaignRepository.save(campaign); + + // CampaignStatus 생성 (APPLIED) + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.APPLY) + .build(); + campaignStatusRepository.save(status); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.APPLY_RESULT_DDAY); + + sendFCMNotification(alerts.get(0), "APPLY_RESULT_DDAY"); + } + + @Test + @DisplayName("SELECTED_VISIT_D3 - 방문 D-3 알림 생성 및 FCM 전송 테스트") + void testSelectedVisitD3Alert() throws Exception { + // Given: 3일 후 방문 마감 캠페인 생성 (REGION 타입) + LocalDate visitDeadline = LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(3); + Campaign campaign = Campaign.builder() + .title("방문 테스트 캠페인 D-3") + .detailUrl("https://example.com/visit_d3_test") + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(10)) + .applyEnd(visitDeadline.minusDays(10)) + .contentSubmissionEnd(visitDeadline) // 3일 후 방문 마감 + .campaignType(CampaignType.REGION) + .isActive(true) + .sourceSite("테스트") + .build(); + campaign = campaignRepository.save(campaign); + + // CampaignStatus 생성 (SELECTED) + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.SELECTED) + .build(); + campaignStatusRepository.save(status); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.SELECTED_VISIT_D3); + + sendFCMNotification(alerts.get(0), "SELECTED_VISIT_D3"); + } + + @Test + @DisplayName("SELECTED_VISIT_DDAY - 방문 D-Day 알림 생성 및 FCM 전송 테스트") + void testSelectedVisitDDayAlert() throws Exception { + // Given: 오늘 방문 마감 캠페인 생성 (REGION 타입) + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + Campaign campaign = Campaign.builder() + .title("방문 D-Day 테스트 캠페인") + .detailUrl("https://example.com/visit_dday_test") + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(10)) + .applyEnd(today.minusDays(10)) + .contentSubmissionEnd(today) // 오늘 방문 마감 + .campaignType(CampaignType.REGION) + .isActive(true) + .sourceSite("테스트") + .build(); + campaign = campaignRepository.save(campaign); + + // CampaignStatus 생성 (SELECTED) + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.SELECTED) + .build(); + campaignStatusRepository.save(status); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.SELECTED_VISIT_DDAY); + + sendFCMNotification(alerts.get(0), "SELECTED_VISIT_DDAY"); + } + + @Test + @DisplayName("REVIEWING_DEADLINE_D3 - 리뷰 작성 D-3 알림 생성 및 FCM 전송 테스트") + void testReviewingDeadlineD3Alert() throws Exception { + // Given: 3일 후 리뷰 마감 캠페인 생성 + LocalDate reviewDeadline = LocalDate.now(ZoneId.of("Asia/Seoul")).plusDays(3); + Campaign campaign = Campaign.builder() + .title("리뷰 D-3 테스트 캠페인") + .detailUrl("https://example.com/review_d3_test") + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(20)) + .applyEnd(reviewDeadline.minusDays(20)) + .contentSubmissionEnd(reviewDeadline) // 3일 후 리뷰 마감 + .campaignType(CampaignType.PRODUCT) + .isActive(true) + .sourceSite("테스트") + .build(); + campaign = campaignRepository.save(campaign); + + // CampaignStatus 생성 (REVIEWING) + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.REVIEWING) + .build(); + campaignStatusRepository.save(status); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.REVIEWING_DEADLINE_D3); + + sendFCMNotification(alerts.get(0), "REVIEWING_DEADLINE_D3"); + } + + @Test + @DisplayName("REVIEWING_DEADLINE_DDAY - 리뷰 작성 D-Day 알림 생성 및 FCM 전송 테스트") + void testReviewingDeadlineDDayAlert() throws Exception { + // Given: 오늘 리뷰 마감 캠페인 생성 + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + Campaign campaign = Campaign.builder() + .title("리뷰 D-Day 테스트 캠페인") + .detailUrl("https://example.com/review_dday_test") + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(20)) + .applyEnd(today.minusDays(20)) + .contentSubmissionEnd(today) // 오늘 리뷰 마감 + .campaignType(CampaignType.PRODUCT) + .isActive(true) + .sourceSite("테스트") + .build(); + campaign = campaignRepository.save(campaign); + + // CampaignStatus 생성 (REVIEWING) + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.REVIEWING) + .build(); + campaignStatusRepository.save(status); + + // When: 알림 생성 + activityAlertService.updateActivityAlerts(); + Thread.sleep(2000); + + // Then: 알림 확인 및 FCM 전송 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + assertThat(alerts).hasSize(1); + assertThat(alerts.get(0).getAlertType()).isEqualTo(ActivityAlertType.REVIEWING_DEADLINE_DDAY); + + sendFCMNotification(alerts.get(0), "REVIEWING_DEADLINE_DDAY"); + } + + + @Test + @DisplayName("모든 알림 타입 통합 테스트 - 대량 데이터 및 메모리 효율성 검증") + void testAllAlertTypesWithLargeData() throws Exception { + // Given: 대량 데이터 생성 (각 타입별 100개씩) + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + + System.out.println("=== 대량 데이터 생성 시작 ==="); + + // 1. 북마크 D-1 알림용 데이터 (100개) + for (int i = 0; i < 100; i++) { + Campaign campaign = createCampaign("북마크 D-1 캠페인 " + i, + today.plusDays(1), CampaignType.PRODUCT); + createBookmark(testUser, campaign, true); + } + + // 2. 북마크 D-Day 알림용 데이터 (100개) + for (int i = 0; i < 100; i++) { + Campaign campaign = createCampaign("북마크 D-Day 캠페인 " + i, + today, CampaignType.PRODUCT); + createBookmark(testUser, campaign, true); + } + + // 3. 선정 결과 알림용 데이터 (100개) + for (int i = 0; i < 100; i++) { + Campaign campaign = Campaign.builder() + .title("선정결과 캠페인 " + i) + .detailUrl("https://example.com/selection_" + i) + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(10)) + .applyEnd(today.minusDays(10)) + .reviewerAnnouncement(today) // 오늘 선정발표 + .campaignType(CampaignType.PRODUCT) + .isActive(true) + .sourceSite("테스트") + .build(); + campaignRepository.save(campaign); + + CampaignStatus status = CampaignStatus.builder() + .user(testUser) + .campaign(campaign) + .status(CampaignStatusType.APPLY) + .build(); + campaignStatusRepository.save(status); + } + + System.out.println("총 300개 테스트 데이터 생성 완료"); + + // When: 알림 생성 (배치 처리) + System.out.println("=== 배치 처리 시작 ==="); + long startTime = System.currentTimeMillis(); + long startMemory = getUsedMemory(); + + activityAlertService.updateActivityAlerts(); + + // 비동기 처리 완료 대기 + Thread.sleep(5000); + + long endTime = System.currentTimeMillis(); + long endMemory = getUsedMemory(); + + // Then: 성능 측정 결과 + System.out.println("=== 성능 측정 결과 ==="); + System.out.println("처리 시간: " + (endTime - startTime) + " ms"); + System.out.println("메모리 사용량: " + (endMemory - startMemory) / 1024 / 1024 + " MB"); + + // 알림 생성 확인 + List alerts = activityAlertRepository.findByUserIdAndIsVisibleToUserTrue(testUser.getId(), + org.springframework.data.domain.Pageable.unpaged()).getContent(); + System.out.println("생성된 알림 개수: " + alerts.size()); + + // 타입별 카운트 + Map typeCount = alerts.stream() + .collect(java.util.stream.Collectors.groupingBy( + ActivityAlert::getAlertType, + java.util.stream.Collectors.counting() + )); + + System.out.println("=== 타입별 알림 개수 ==="); + typeCount.forEach((type, count) -> + System.out.println(type + ": " + count + "개")); + + // 샘플 FCM 전송 (첫 번째 알림만) + if (!alerts.isEmpty()) { + sendFCMNotification(alerts.get(0), "통합 테스트 샘플"); + } + } + + // Helper 메서드들 + + private Campaign createCampaign(String title, LocalDate applyEnd, CampaignType type) { + Campaign campaign = Campaign.builder() + .title(title) + .detailUrl("https://example.com/" + title.replace(" ", "_") + "_" + System.currentTimeMillis()) + .benefit("10000 포인트") + .imageUrl("https://example.com/image.jpg") + .recruitCount(100) + .applicantCount(0) + .applyStart(LocalDate.now().minusDays(10)) + .applyEnd(applyEnd) + .campaignType(type) + .isActive(true) + .sourceSite("테스트") + .build(); + return campaignRepository.save(campaign); + } + + private Bookmark createBookmark(User user, Campaign campaign, boolean isActive) { + Bookmark bookmark = Bookmark.builder() + .user(user) + .campaign(campaign) + .isActive(isActive) + .build(); + return bookmarkRepository.save(bookmark); + } + + protected void sendFCMNotification(ActivityAlert alert, String testType) { + try { + // JPQL로 fetch join을 사용해 Campaign까지 한번에 로드 + ActivityAlert freshAlert = entityManager.createQuery( + "SELECT a FROM ActivityAlert a " + + "JOIN FETCH a.campaign " + + "WHERE a.id = :id", ActivityAlert.class) + .setParameter("id", alert.getId()) + .getSingleResult(); + + Campaign campaign = freshAlert.getCampaign(); + String campaignTitle = campaign.getTitle(); + Long campaignId = campaign.getId(); + + NotificationRequest request = NotificationRequest.builder() + .title(freshAlert.getAlertType().getTitle()) + .body(freshAlert.getAlertType().getBodyTemplate(campaignTitle)) + .data(Map.of( + "type", testType, + "alertId", String.valueOf(freshAlert.getId()), + "campaignId", String.valueOf(campaignId), + "timestamp", String.valueOf(System.currentTimeMillis()) + )) + .priority("high") + .build(); + + NotificationResultDto result = notificationService.sendNotificationToToken( + TEST_FCM_TOKEN, request); + + System.out.println("=== FCM 전송 결과 (" + testType + ") ==="); + System.out.println("성공: " + (result.getSuccessCount() > 0)); + System.out.println("메시지: " + alert.getAlertType().getBodyTemplate(alert.getCampaign().getTitle())); + + } catch (Exception e) { + System.out.println("FCM 전송 실패: " + e.getMessage()); + } + } + + private long getUsedMemory() { + Runtime runtime = Runtime.getRuntime(); + runtime.gc(); + return runtime.totalMemory() - runtime.freeMemory(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/activity/service/AlertServiceIntegrationTest.java b/src/test/java/com/example/cherrydan/activity/service/AlertServiceIntegrationTest.java new file mode 100644 index 00000000..6d6f7244 --- /dev/null +++ b/src/test/java/com/example/cherrydan/activity/service/AlertServiceIntegrationTest.java @@ -0,0 +1,237 @@ +package com.example.cherrydan.activity.service; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.activity.dto.UnreadAlertCountResponseDTO; +import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.repository.CampaignRepository; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.user.domain.Gender; +import com.example.cherrydan.user.domain.KeywordCampaignAlert; +import com.example.cherrydan.user.domain.Role; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; +import com.example.cherrydan.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +class AlertServiceIntegrationTest { + + @Autowired + private AlertService alertService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private ActivityAlertRepository activityAlertRepository; + + @Autowired + private KeywordCampaignAlertRepository keywordCampaignAlertRepository; + + private User testUser; + private User otherUser; + private Campaign testCampaign; + + @BeforeEach + void setUp() { + testUser = userRepository.save(User.builder() + .name("테스트유저") + .email("test@example.com") + .socialId("test123") + .provider(AuthProvider.KAKAO) + .role(Role.ROLE_USER) + .gender(Gender.MALE) + .isActive(true) + .build()); + + otherUser = userRepository.save(User.builder() + .name("다른유저") + .email("other@example.com") + .socialId("other123") + .provider(AuthProvider.KAKAO) + .role(Role.ROLE_USER) + .gender(Gender.FEMALE) + .isActive(true) + .build()); + + testCampaign = campaignRepository.save(Campaign.builder() + .title("테스트 캠페인") + .detailUrl("https://test.com/campaign1") + .isActive(true) + .build()); + } + + @Test + @DisplayName("미읽은 알림 개수를 정확히 조회한다") + void getUnreadAlertCount_success() { + // Given: 테스트 유저의 미읽은 알림 생성 + createActivityAlert(testUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_D1, false, true); // 미읽음, 활동 알림 + createActivityAlert(testUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_DDAY, false, true); // 미읽음, 활동 알림 + createKeywordAlert(testUser, "뷰티", false, true); // 미읽음, 키워드 알림 + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then + assertThat(result.getTotalCount()).isEqualTo(3L); + assertThat(result.getActivityAlertCount()).isEqualTo(2L); + assertThat(result.getKeywordAlertCount()).isEqualTo(1L); + } + + @Test + @DisplayName("읽은 알림은 개수에 포함되지 않는다") + void getUnreadAlertCount_excludeReadAlerts() { + // Given + createActivityAlert(testUser, testCampaign, ActivityAlertType.APPLY_RESULT_DDAY, false, true); // 미읽음 + createActivityAlert(testUser, testCampaign, ActivityAlertType.SELECTED_VISIT_D3, true, true); // 읽음 + createKeywordAlert(testUser, "맛집", false, true); // 미읽음 + createKeywordAlert(testUser, "카페", true, true); // 읽음 + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then + assertThat(result.getTotalCount()).isEqualTo(2L); + assertThat(result.getActivityAlertCount()).isEqualTo(1L); + assertThat(result.getKeywordAlertCount()).isEqualTo(1L); + } + + @Test + @DisplayName("보이지 않는 알림은 개수에 포함되지 않는다") + void getUnreadAlertCount_excludeInvisibleAlerts() { + // Given + createActivityAlert(testUser, testCampaign, ActivityAlertType.SELECTED_VISIT_DDAY, false, true); // 보임 + createActivityAlert(testUser, testCampaign, ActivityAlertType.REVIEWING_DEADLINE_D3, false, false); // 숨김 + createKeywordAlert(testUser, "뷰티", false, true); // 보임 + createKeywordAlert(testUser, "맛집", false, false); // 숨김 + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then + assertThat(result.getTotalCount()).isEqualTo(2L); + assertThat(result.getActivityAlertCount()).isEqualTo(1L); + assertThat(result.getKeywordAlertCount()).isEqualTo(1L); + } + + @Test + @DisplayName("다른 사용자의 알림은 개수에 포함되지 않는다") + void getUnreadAlertCount_excludeOtherUserAlerts() { + // Given: 테스트 유저의 알림 + createActivityAlert(testUser, testCampaign, ActivityAlertType.REVIEWING_DEADLINE_DDAY, false, true); + createKeywordAlert(testUser, "뷰티", false, true); + + // 다른 유저의 알림 + createActivityAlert(otherUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_D1, false, true); + createKeywordAlert(otherUser, "맛집", false, true); + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then: 테스트 유저의 알림만 카운트 + assertThat(result.getTotalCount()).isEqualTo(2L); + assertThat(result.getActivityAlertCount()).isEqualTo(1L); + assertThat(result.getKeywordAlertCount()).isEqualTo(1L); + } + + @Test + @DisplayName("미읽은 알림이 없으면 0을 반환한다") + void getUnreadAlertCount_noUnreadAlerts() { + // Given: 읽은 알림만 존재 + createActivityAlert(testUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_DDAY, true, true); + createKeywordAlert(testUser, "뷰티", true, true); + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then + assertThat(result.getTotalCount()).isEqualTo(0L); + assertThat(result.getActivityAlertCount()).isEqualTo(0L); + assertThat(result.getKeywordAlertCount()).isEqualTo(0L); + } + + @Test + @DisplayName("알림이 전혀 없으면 0을 반환한다") + void getUnreadAlertCount_noAlerts() { + // Given: 알림 없음 + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then + assertThat(result.getTotalCount()).isEqualTo(0L); + assertThat(result.getActivityAlertCount()).isEqualTo(0L); + assertThat(result.getKeywordAlertCount()).isEqualTo(0L); + } + + @Test + @DisplayName("복합 조건 테스트: 미읽음, 읽음, 숨김, 다른 유저 알림이 섞여있을 때") + void getUnreadAlertCount_complexScenario() { + // Given + // 테스트 유저의 미읽은 알림 (카운트 O) + createActivityAlert(testUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_D1, false, true); + createActivityAlert(testUser, testCampaign, ActivityAlertType.BOOKMARK_DEADLINE_DDAY, false, true); + createKeywordAlert(testUser, "뷰티", false, true); + + // 테스트 유저의 읽은 알림 (카운트 X) + createActivityAlert(testUser, testCampaign, ActivityAlertType.APPLY_RESULT_DDAY, true, true); + createKeywordAlert(testUser, "맛집", true, true); + + // 테스트 유저의 숨김 알림 (카운트 X) + createActivityAlert(testUser, testCampaign, ActivityAlertType.SELECTED_VISIT_D3, false, false); + createKeywordAlert(testUser, "카페", false, false); + + // 다른 유저의 미읽은 알림 (카운트 X) + createActivityAlert(otherUser, testCampaign, ActivityAlertType.SELECTED_VISIT_DDAY, false, true); + createKeywordAlert(otherUser, "서울", false, true); + + // When + UnreadAlertCountResponseDTO result = alertService.getUnreadAlertCount(testUser.getId()); + + // Then: 테스트 유저의 미읽은 + 보이는 알림만 카운트 + assertThat(result.getTotalCount()).isEqualTo(3L); + assertThat(result.getActivityAlertCount()).isEqualTo(2L); + assertThat(result.getKeywordAlertCount()).isEqualTo(1L); + } + + private void createActivityAlert(User user, Campaign campaign, ActivityAlertType alertType, boolean isRead, boolean isVisible) { + ActivityAlert alert = ActivityAlert.builder() + .user(user) + .campaign(campaign) + .alertDate(LocalDate.now()) + .alertType(alertType) + .alertStage(0) + .isVisibleToUser(isVisible) + .isRead(isRead) + .build(); + activityAlertRepository.save(alert); + } + + private void createKeywordAlert(User user, String keyword, boolean isRead, boolean isVisible) { + KeywordCampaignAlert alert = KeywordCampaignAlert.builder() + .user(user) + .keyword(keyword) + .campaignCount(10) + .alertDate(LocalDate.now()) + .alertStage(0) + .isVisibleToUser(isVisible) + .isRead(isRead) + .build(); + keywordCampaignAlertRepository.save(alert); + } +} diff --git a/src/test/java/com/example/cherrydan/campaign/repository/BookmarkRepositoryQueryTest.java b/src/test/java/com/example/cherrydan/campaign/repository/BookmarkRepositoryQueryTest.java new file mode 100644 index 00000000..0c888172 --- /dev/null +++ b/src/test/java/com/example/cherrydan/campaign/repository/BookmarkRepositoryQueryTest.java @@ -0,0 +1,168 @@ +package com.example.cherrydan.campaign.repository; + +import com.example.cherrydan.campaign.domain.Bookmark; +import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.domain.CampaignType; +import com.example.cherrydan.fcm.domain.DeviceType; +import com.example.cherrydan.fcm.domain.UserFCMToken; +import com.example.cherrydan.fcm.repository.UserFCMTokenRepository; +import com.example.cherrydan.user.domain.Gender; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("local") +class BookmarkRepositoryQueryTest { + + @Autowired + private BookmarkRepository bookmarkRepository; + + @Autowired + private CampaignRepository campaignRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserFCMTokenRepository userFCMTokenRepository; + + private User testUser; + private Campaign testCampaign; + private Bookmark testBookmark; + private UserFCMToken testToken; + + @BeforeEach + void setUp() { + cleanUp(); + + testUser = User.builder() + .email("test-" + System.currentTimeMillis() + "@example.com") + .name("테스트유저") + .gender(Gender.MALE) + .birthYear(1990) + .isActive(true) + .build(); + userRepository.save(testUser); + + testCampaign = Campaign.builder() + .title("테스트 캠페인") + .imageUrl("https://example.com/image.jpg") + .detailUrl("https://example.com/detail-" + System.currentTimeMillis()) + .campaignType(CampaignType.REGION) + .applyStart(LocalDate.now().minusDays(5)) + .applyEnd(LocalDate.now().plusDays(1)) + .isActive(true) + .build(); + campaignRepository.save(testCampaign); + + testBookmark = Bookmark.builder() + .user(testUser) + .campaign(testCampaign) + .isActive(true) + .build(); + bookmarkRepository.save(testBookmark); + + testToken = UserFCMToken.builder() + .userId(testUser.getId()) + .fcmToken("test-fcm-token-" + System.currentTimeMillis()) + .deviceType(DeviceType.ANDROID) + .isAllowed(true) + .isActive(true) + .build(); + userFCMTokenRepository.save(testToken); + } + + @AfterEach + void tearDown() { + cleanUp(); + } + + private void cleanUp() { + bookmarkRepository.deleteAll(); + campaignRepository.deleteAll(); + userFCMTokenRepository.deleteAll(); + } + + @Test + @DisplayName("findActiveBookmarksByApplyEndDate 쿼리가 정상적으로 실행되는지 테스트") + void testFindActiveBookmarksByApplyEndDate() { + LocalDate targetDate = LocalDate.now().plusDays(1); + PageRequest pageRequest = PageRequest.of(0, 10); + + Page result = bookmarkRepository.findActiveBookmarksByApplyEndDate( + targetDate, + pageRequest + ); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getId()).isEqualTo(testBookmark.getId()); + assertThat(result.getContent().get(0).getCampaign().getTitle()).isEqualTo("테스트 캠페인"); + assertThat(result.getContent().get(0).getUser().getName()).isEqualTo("테스트유저"); + } + + @Test + @DisplayName("알림이 비활성화된 사용자는 조회되지 않는지 테스트") + void testFindActiveBookmarksByApplyEndDate_NotAllowedUser() { + testToken.updateAllowedStatus(false); + userFCMTokenRepository.save(testToken); + + LocalDate targetDate = LocalDate.now().plusDays(1); + PageRequest pageRequest = PageRequest.of(0, 10); + + Page result = bookmarkRepository.findActiveBookmarksByApplyEndDate( + targetDate, + pageRequest + ); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + } + + @Test + @DisplayName("비활성화된 캠페인은 조회되지 않는지 테스트") + void testFindActiveBookmarksByApplyEndDate_InactiveCampaign() { + Campaign inactiveCampaign = Campaign.builder() + .title("비활성 캠페인") + .imageUrl("https://example.com/image2.jpg") + .detailUrl("https://example.com/detail2-" + System.currentTimeMillis()) + .campaignType(CampaignType.REGION) + .applyStart(LocalDate.now().minusDays(5)) + .applyEnd(LocalDate.now().plusDays(1)) + .isActive(false) + .build(); + campaignRepository.save(inactiveCampaign); + + Bookmark inactiveBookmark = Bookmark.builder() + .user(testUser) + .campaign(inactiveCampaign) + .isActive(true) + .build(); + bookmarkRepository.save(inactiveBookmark); + + LocalDate targetDate = LocalDate.now().plusDays(1); + PageRequest pageRequest = PageRequest.of(0, 10); + + Page result = bookmarkRepository.findActiveBookmarksByApplyEndDate( + targetDate, + pageRequest + ); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getCampaign().getIsActive()).isTrue(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncSchedulerTest.java b/src/test/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncSchedulerTest.java new file mode 100644 index 00000000..4d2d897d --- /dev/null +++ b/src/test/java/com/example/cherrydan/campaign/scheduler/CampaignSearchSyncSchedulerTest.java @@ -0,0 +1,123 @@ +package com.example.cherrydan.campaign.scheduler; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("local") +@Transactional +@DisplayName("CampaignSearchSyncScheduler 통합테스트") +class CampaignSearchSyncSchedulerTest { + + @Autowired + private CampaignSearchSyncScheduler campaignSearchSyncScheduler; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("campaigns_daily_search 테이블 동기화가 성공적으로 수행된다") + void syncDailySearchTable_Success() { + // given + Integer beforeCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + // when + campaignSearchSyncScheduler.syncDailySearchTable(); + + // then + Integer afterCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + assertThat(afterCount).isNotNull(); + assertThat(afterCount).isGreaterThanOrEqualTo(0); + + // 2025-11-29 데이터가 있다면 동기화 후 campaigns_daily_search에 데이터가 있어야 함 + Integer campaignCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns WHERE DATE(created_at) = '2025-11-29' AND is_active = 1", + Integer.class + ); + + assertThat(afterCount).isEqualTo(campaignCount); + } + + @Test + @DisplayName("동기화된 데이터의 컬럼 값이 올바르게 복사된다") + void syncDailySearchTable_CorrectDataCopied() { + // when + campaignSearchSyncScheduler.syncDailySearchTable(); + + // then + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + if (count > 0) { + jdbcTemplate.query( + "SELECT id, title, created_at FROM campaigns_daily_search LIMIT 1", + rs -> { + Long id = rs.getLong("id"); + String title = rs.getString("title"); + + assertThat(id).isNotNull(); + assertThat(title).isNotNull(); + assertThat(title).isNotEmpty(); + } + ); + } + } + + @Test + @DisplayName("TRUNCATE 후 INSERT가 정상적으로 수행된다") + void syncDailySearchTable_TruncateAndInsert() { + // given - 첫 번째 동기화 + campaignSearchSyncScheduler.syncDailySearchTable(); + Integer firstSyncCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + // when - 두 번째 동기화 (TRUNCATE 후 재생성) + campaignSearchSyncScheduler.syncDailySearchTable(); + Integer secondSyncCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + // then - 두 번 동기화해도 같은 데이터만 들어있어야 함 + assertThat(firstSyncCount).isEqualTo(secondSyncCount); + } + + @Test + @DisplayName("is_active가 1인 캠페인만 동기화된다") + void syncDailySearchTable_OnlyActiveTrue() { + // when + campaignSearchSyncScheduler.syncDailySearchTable(); + + // then + Integer dailySearchCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns_daily_search", + Integer.class + ); + + Integer activeCampaignCount = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM campaigns WHERE DATE(created_at) = '2025-11-29' AND is_active = 1", + Integer.class + ); + + assertThat(dailySearchCount).isEqualTo(activeCampaignCount); + } +} + diff --git a/src/test/java/com/example/cherrydan/campaign/service/CampaignServiceHybridSearchTest.java b/src/test/java/com/example/cherrydan/campaign/service/CampaignServiceHybridSearchTest.java new file mode 100644 index 00000000..df0d7522 --- /dev/null +++ b/src/test/java/com/example/cherrydan/campaign/service/CampaignServiceHybridSearchTest.java @@ -0,0 +1,131 @@ +package com.example.cherrydan.campaign.service; + +import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.dto.CampaignResponseDTO; +import com.example.cherrydan.campaign.repository.BookmarkRepository; +import com.example.cherrydan.campaign.repository.CampaignRepository; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; +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.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("CampaignService 하이브리드 검색 전략 테스트") +class CampaignServiceHybridSearchTest { + + @Mock + private CampaignRepository campaignRepository; + + @Mock + private BookmarkRepository bookmarkRepository; + + @Mock + private KeywordCampaignAlertRepository keywordCampaignAlertRepository; + + @InjectMocks + private CampaignServiceImpl campaignService; + + @Test + @DisplayName("오늘 날짜로 조회 시 FULLTEXT 검색을 사용한다") + void getPersonalizedCampaigns_WithTodayDate_UseFulltext() { + // given + String keyword = "부산"; + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + + List mockCampaigns = Collections.emptyList(); + Set mockBookmarks = Collections.emptySet(); + + when(campaignRepository.searchDailyCampaignsByFulltext( + eq("+" + keyword + "*"), + eq(0), + eq(10) + )).thenReturn(mockCampaigns); + + when(campaignRepository.countDailyCampaignsByFulltext(eq(keyword))) + .thenReturn(0L); + + when(bookmarkRepository.findBookmarkedCampaignIds(eq(userId), anyList())) + .thenReturn(mockBookmarks); + + // when + Page result = campaignService.getPersonalizedCampaignsByKeyword( + keyword, today, userId, pageable + ); + + // then + verify(campaignRepository, times(1)).searchDailyCampaignsByFulltext( + eq("+" + keyword + "*"), + eq(0), + eq(10) + ); + verify(campaignRepository, times(1)).countDailyCampaignsByFulltext(eq(keyword)); + verify(campaignRepository, never()).findByKeywordSimpleLike(anyString(), any(LocalDate.class), anyInt(), anyInt()); + verify(campaignRepository, never()).countByKeywordSimpleLike(anyString(), any(LocalDate.class)); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + } + + @Test + @DisplayName("과거 날짜로 조회 시 Simple LIKE 검색을 사용한다") + void getPersonalizedCampaigns_WithPastDate_UseSimpleLike() { + // given + String keyword = "서울"; + LocalDate pastDate = LocalDate.of(2025, 11, 28); + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + + List mockCampaigns = Collections.emptyList(); + Set mockBookmarks = Collections.emptySet(); + + when(campaignRepository.findByKeywordSimpleLike( + eq(keyword), + eq(pastDate), + eq(0), + eq(10) + )).thenReturn(mockCampaigns); + + when(campaignRepository.countByKeywordSimpleLike(eq(keyword), eq(pastDate))) + .thenReturn(0L); + + when(bookmarkRepository.findBookmarkedCampaignIds(eq(userId), anyList())) + .thenReturn(mockBookmarks); + + // when + Page result = campaignService.getPersonalizedCampaignsByKeyword( + keyword, pastDate, userId, pageable + ); + + // then + verify(campaignRepository, times(1)).findByKeywordSimpleLike( + eq(keyword), + eq(pastDate), + eq(0), + eq(10) + ); + verify(campaignRepository, times(1)).countByKeywordSimpleLike(eq(keyword), eq(pastDate)); + verify(campaignRepository, never()).searchDailyCampaignsByFulltext(anyString(), anyInt(), anyInt()); + verify(campaignRepository, never()).countDailyCampaignsByFulltext(anyString()); + + assertThat(result).isNotNull(); + assertThat(result.getContent()).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/memory/MemorySizeTest.java b/src/test/java/com/example/cherrydan/memory/MemorySizeTest.java new file mode 100644 index 00000000..17db8301 --- /dev/null +++ b/src/test/java/com/example/cherrydan/memory/MemorySizeTest.java @@ -0,0 +1,89 @@ +package com.example.cherrydan.memory; + +import org.junit.jupiter.api.Test; +import org.openjdk.jol.info.ClassLayout; +import org.openjdk.jol.info.GraphLayout; +import org.openjdk.jol.vm.VM; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class MemorySizeTest { + + @Test + public void testPrimitiveSizes() { + System.out.println("=== JVM 정보 ==="); + System.out.println(VM.current().details()); + System.out.println(); + + + System.out.println("=== Primitive 타입 크기 ==="); + System.out.println("long 크기: " + ClassLayout.parseClass(long.class).instanceSize() + " bytes"); + System.out.println(); + + System.out.println("=== Wrapper 클래스 크기 ==="); + Long longObject = 123L; + System.out.println("Long 객체 레이아웃:"); + System.out.println(ClassLayout.parseInstance(longObject).toPrintable()); + System.out.println("Long 객체 총 크기: " + GraphLayout.parseInstance(longObject).totalSize() + " bytes"); + System.out.println(); + + Integer intObject = 123; + System.out.println("Integer 객체 레이아웃:"); + System.out.println(ClassLayout.parseInstance(intObject).toPrintable()); + System.out.println("Integer 객체 총 크기: " + GraphLayout.parseInstance(intObject).totalSize() + " bytes"); + System.out.println(); + + Boolean boolObject = true; + System.out.println("Boolean 객체 레이아웃:"); + System.out.println(ClassLayout.parseInstance(boolObject).toPrintable()); + System.out.println("Boolean 객체 총 크기: " + GraphLayout.parseInstance(boolObject).totalSize() + " bytes"); + System.out.println(); + + System.out.println("=== Date/Time 클래스 크기 ==="); + LocalDate localDate = LocalDate.now(); + System.out.println("LocalDate 레이아웃:"); + System.out.println(ClassLayout.parseInstance(localDate).toPrintable()); + System.out.println("LocalDate 총 크기: " + GraphLayout.parseInstance(localDate).totalSize() + " bytes"); + System.out.println(); + + LocalDateTime localDateTime = LocalDateTime.now(); + System.out.println("LocalDateTime 레이아웃:"); + System.out.println(ClassLayout.parseInstance(localDateTime).toPrintable()); + System.out.println("LocalDateTime 총 크기: " + GraphLayout.parseInstance(localDateTime).totalSize() + " bytes"); + System.out.println(); + + System.out.println("=== String 크기 예시 ==="); + String shortString = "Hello"; + System.out.println("짧은 String (\"Hello\") 크기: " + GraphLayout.parseInstance(shortString).totalSize() + " bytes"); + + String mediumString = "This is a medium length string for testing"; + System.out.println("중간 String 크기: " + GraphLayout.parseInstance(mediumString).totalSize() + " bytes"); + + String longString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."; + System.out.println("긴 String 크기: " + GraphLayout.parseInstance(longString).totalSize() + " bytes"); + System.out.println(); + + System.out.println("=== 샘플 엔티티 크기 측정 ==="); + SampleEntity entity = new SampleEntity(); + entity.id = 1L; + entity.name = "Test User"; + entity.email = "test@example.com"; + entity.active = true; + entity.createdAt = LocalDateTime.now(); + + System.out.println("SampleEntity 레이아웃:"); + System.out.println(ClassLayout.parseInstance(entity).toPrintable()); + System.out.println("SampleEntity 총 크기 (shallow): " + ClassLayout.parseInstance(entity).instanceSize() + " bytes"); + System.out.println("SampleEntity 총 크기 (deep): " + GraphLayout.parseInstance(entity).totalSize() + " bytes"); + } + + static class SampleEntity { + Long id; + String name; + String email; + Boolean active; + LocalDateTime createdAt; + } +} \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserServiceTest.java b/src/test/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserServiceTest.java index 72dde0fb..ce2b58ba 100644 --- a/src/test/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserServiceTest.java +++ b/src/test/java/com/example/cherrydan/oauth/security/oauth2/CustomOAuth2UserServiceTest.java @@ -26,8 +26,8 @@ class CustomOAuth2UserServiceTest { @Autowired private UserFCMTokenRepository tokenRepository; - @Autowired - private CustomOAuth2UserService customOAuth2UserService; +// @Autowired +// private CustomOAuth2UserService customOAuth2UserService; private AppleLoginRequest loginRequest; @@ -51,8 +51,8 @@ void registerFCMToken_WithValidToken_Success() { // given Long userId = 1L; - // when - customOAuth2UserService.registerFCMTokenIfPresent(userId, loginRequest); +// // when +// customOAuth2UserService.registerFCMTokenIfPresent(userId, loginRequest); // then Optional savedToken = tokenRepository.findByUserIdAndDeviceModelAndIsActiveTrue( @@ -77,8 +77,8 @@ void registerDeviceInfo_WithNullToken_Success() { nullTokenRequest.setAppVersion("2.0.0"); nullTokenRequest.setOsVersion("16"); - // when - customOAuth2UserService.registerFCMTokenIfPresent(userId, nullTokenRequest); +// // when +// customOAuth2UserService.registerFCMTokenIfPresent(userId, nullTokenRequest); // then Optional savedToken = tokenRepository.findByUserIdAndDeviceModelAndIsActiveTrue( @@ -104,8 +104,8 @@ void registerDeviceInfo_WithEmptyToken_Success() { emptyTokenRequest.setAppVersion("1.5.0"); emptyTokenRequest.setOsVersion("13"); - // when - customOAuth2UserService.registerFCMTokenIfPresent(userId, emptyTokenRequest); +// // when +// customOAuth2UserService.registerFCMTokenIfPresent(userId, emptyTokenRequest); // then Optional savedToken = tokenRepository.findByUserIdAndDeviceModelAndIsActiveTrue( @@ -131,8 +131,8 @@ void registerDeviceInfo_WithWhitespaceToken_Success() { whitespaceTokenRequest.setAppVersion("1.2.0"); whitespaceTokenRequest.setOsVersion("12"); - // when - customOAuth2UserService.registerFCMTokenIfPresent(userId, whitespaceTokenRequest); +// // when +// customOAuth2UserService.registerFCMTokenIfPresent(userId, whitespaceTokenRequest); // then Optional savedToken = tokenRepository.findByUserIdAndDeviceModelAndIsActiveTrue( diff --git a/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceIntegrationTest.java b/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceIntegrationTest.java index a94141d7..b0929101 100644 --- a/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceIntegrationTest.java +++ b/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceIntegrationTest.java @@ -1,8 +1,8 @@ package com.example.cherrydan.oauth.service; import com.example.cherrydan.common.exception.RefreshTokenException; -import com.example.cherrydan.oauth.model.AuthProvider; -import com.example.cherrydan.oauth.model.RefreshToken; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.domain.RefreshToken; import com.example.cherrydan.oauth.repository.RefreshTokenRepository; import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; import com.example.cherrydan.user.domain.Gender; diff --git a/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceTest.java b/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceTest.java index 0d61851c..9b11f871 100644 --- a/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceTest.java +++ b/src/test/java/com/example/cherrydan/oauth/service/RefreshTokenServiceTest.java @@ -2,13 +2,14 @@ import com.example.cherrydan.common.exception.ErrorMessage; import com.example.cherrydan.common.exception.RefreshTokenException; -import com.example.cherrydan.oauth.model.RefreshToken; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.domain.RefreshToken; import com.example.cherrydan.oauth.repository.RefreshTokenRepository; import com.example.cherrydan.oauth.security.jwt.JwtTokenProvider; import com.example.cherrydan.user.domain.User; import com.example.cherrydan.user.domain.Gender; import com.example.cherrydan.user.domain.Role; -import com.example.cherrydan.oauth.model.AuthProvider; + import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.security.SignatureException; @@ -81,7 +82,7 @@ class SaveOrUpdateRefreshTokenTest { void saveNewRefreshToken_Success() { // Given String newTokenValue = "new.refresh.token"; - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, newTokenValue)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -90,7 +91,7 @@ void saveNewRefreshToken_Success() { refreshTokenService.saveOrUpdateRefreshToken(testUser, newTokenValue); // Then - verify(refreshTokenRepository).findByUserIdAndRefreshToken(USER_ID, newTokenValue); + verify(refreshTokenRepository).findByUserId(USER_ID); verify(refreshTokenRepository).save(argThat(token -> token.getRefreshToken().equals(newTokenValue) && token.getUser().equals(testUser) @@ -108,18 +109,16 @@ void updateExistingRefreshToken_Success() { .user(testUser) .build(); - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, updatedTokenValue)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.of(existingToken)); - given(refreshTokenRepository.save(any(RefreshToken.class))) - .willReturn(existingToken); // When refreshTokenService.saveOrUpdateRefreshToken(testUser, updatedTokenValue); // Then - verify(refreshTokenRepository).findByUserIdAndRefreshToken(USER_ID, updatedTokenValue); + verify(refreshTokenRepository).findByUserId(USER_ID); assertThat(existingToken.getRefreshToken()).isEqualTo(updatedTokenValue); - verify(refreshTokenRepository).save(any(RefreshToken.class)); + verify(refreshTokenRepository, never()).save(any(RefreshToken.class)); } @Test @@ -137,7 +136,7 @@ void saveRefreshTokenWithNullUser_Failure() { @DisplayName("null 토큰 값으로 저장 시도 - 처리됨") void saveRefreshTokenWithNullValue_Handled() { // Given - given(refreshTokenRepository.findByUserIdAndRefreshToken(eq(USER_ID), isNull())) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -468,8 +467,9 @@ void multipleTokensForSameUser_Handled() { String firstToken = "first.token"; String secondToken = "second.token"; - given(refreshTokenRepository.findByUserIdAndRefreshToken(eq(USER_ID), anyString())) - .willReturn(Optional.empty()); + given(refreshTokenRepository.findByUserId(USER_ID)) + .willReturn(Optional.empty()) + .willReturn(Optional.of(testRefreshToken)); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -478,7 +478,8 @@ void multipleTokensForSameUser_Handled() { refreshTokenService.saveOrUpdateRefreshToken(testUser, secondToken); // Then - verify(refreshTokenRepository, times(2)).save(any(RefreshToken.class)); + verify(refreshTokenRepository, times(2)).findByUserId(USER_ID); + verify(refreshTokenRepository, times(1)).save(any(RefreshToken.class)); } } @@ -491,7 +492,7 @@ class EdgeCaseTest { void handleVeryLongTokenValue() { // Given String longToken = "a".repeat(1000); // 1000자 토큰 - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, longToken)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -510,7 +511,7 @@ void handleVeryLongTokenValue() { void handleTokenWithSpecialCharacters() { // Given String specialToken = "token.with-special_chars!@#$%^&*()"; - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, specialToken)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -525,7 +526,7 @@ void handleTokenWithSpecialCharacters() { void handleTokenWithUnicodeCharacters() { // Given String unicodeToken = "토큰.with.한글.and.emoji.😀"; - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, unicodeToken)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()); given(refreshTokenRepository.save(any(RefreshToken.class))) .willReturn(testRefreshToken); @@ -542,7 +543,7 @@ void concurrentTokenSave_Simulation() { String concurrentToken = "concurrent.token"; // 첫 번째 호출에서는 토큰이 없음 - given(refreshTokenRepository.findByUserIdAndRefreshToken(USER_ID, concurrentToken)) + given(refreshTokenRepository.findByUserId(USER_ID)) .willReturn(Optional.empty()) .willReturn(Optional.of(testRefreshToken)); // 두 번째 호출에서는 토큰 존재 @@ -554,7 +555,8 @@ void concurrentTokenSave_Simulation() { refreshTokenService.saveOrUpdateRefreshToken(testUser, concurrentToken); // Then - verify(refreshTokenRepository, times(2)).save(any(RefreshToken.class)); + verify(refreshTokenRepository, times(2)).findByUserId(USER_ID); + verify(refreshTokenRepository, times(1)).save(any(RefreshToken.class)); } } } \ No newline at end of file diff --git a/src/test/java/com/example/cherrydan/sns/service/SnsOAuthServiceTest.java b/src/test/java/com/example/cherrydan/sns/service/SnsOAuthServiceTest.java new file mode 100644 index 00000000..3225e474 --- /dev/null +++ b/src/test/java/com/example/cherrydan/sns/service/SnsOAuthServiceTest.java @@ -0,0 +1,206 @@ +package com.example.cherrydan.sns.service; + +import com.example.cherrydan.common.exception.ErrorMessage; +import com.example.cherrydan.common.exception.SnsException; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.sns.domain.SnsConnection; +import com.example.cherrydan.sns.domain.SnsPlatform; +import com.example.cherrydan.sns.dto.SnsConnectionResponse; +import com.example.cherrydan.sns.repository.SnsConnectionRepository; +import com.example.cherrydan.user.domain.Role; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("local") +@Transactional +class SnsOAuthServiceTest { + + @Autowired + private SnsOAuthService snsOAuthService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private SnsConnectionRepository snsConnectionRepository; + + @Autowired + private OAuthStateService oAuthStateService; + + private User activeUser; + private User inactiveUser; + private SnsConnection youtubeConnection; + private SnsConnection instagramConnection; + + @BeforeEach + void setUp() { + activeUser = User.builder() + .email("active@test.com") + .name("ActiveUser") + .role(Role.ROLE_USER) + .isActive(true) + .build(); + userRepository.save(activeUser); + + inactiveUser = User.builder() + .email("inactive@test.com") + .name("InactiveUser") + .role(Role.ROLE_USER) + .isActive(false) + .build(); + inactiveUser.setDeletedAt(LocalDateTime.now()); + userRepository.save(inactiveUser); + + youtubeConnection = SnsConnection.builder() + .user(activeUser) + .platform(SnsPlatform.YOUTUBE) + .snsUserId("youtube123") + .snsUrl("https://youtube.com/@test") + .isActive(true) + .build(); + snsConnectionRepository.save(youtubeConnection); + + instagramConnection = SnsConnection.builder() + .user(activeUser) + .platform(SnsPlatform.INSTAGRAM) + .snsUserId("instagram123") + .snsUrl("https://instagram.com/test") + .isActive(true) + .build(); + snsConnectionRepository.save(instagramConnection); + } + + @Test + @DisplayName("연동된 SNS 목록을 조회합니다") + void getUserSnsConnections_성공_연동된_SNS_목록_반환() { + List connections = snsOAuthService.getUserSnsConnections(activeUser); + + assertThat(connections).hasSize(2); + assertThat(connections) + .extracting(SnsConnectionResponse::getPlatform) + .containsExactlyInAnyOrder(SnsPlatform.YOUTUBE, SnsPlatform.INSTAGRAM); + } + + @Test + @DisplayName("비활성 사용자의 SNS 목록 조회 시 예외가 발생합니다") + void getUserSnsConnections_비활성_사용자_예외_발생() { + assertThatThrownBy(() -> snsOAuthService.getUserSnsConnections(inactiveUser)) + .isInstanceOf(SnsException.class) + .hasFieldOrPropertyWithValue("errorMessage", ErrorMessage.USER_NOT_FOUND); + } + + @Test + @DisplayName("SNS 연동을 해제합니다") + void disconnectSns_성공_연동_해제() { + snsOAuthService.disconnectSns(activeUser, SnsPlatform.YOUTUBE); + + SnsConnection connection = snsConnectionRepository.findByUserAndPlatformIgnoreActive(activeUser, SnsPlatform.YOUTUBE) + .orElseThrow(); + + assertThat(connection.getIsActive()).isFalse(); + } + + @Test + @DisplayName("연동되지 않은 플랫폼 해제 시 예외가 발생합니다") + void disconnectSns_연동되지_않은_플랫폼_예외_발생() { + assertThatThrownBy(() -> snsOAuthService.disconnectSns(activeUser, SnsPlatform.TIKTOK)) + .isInstanceOf(SnsException.class) + .hasFieldOrPropertyWithValue("errorMessage", ErrorMessage.SNS_CONNECTION_NOT_FOUND); + } + + @Test + @DisplayName("비활성 사용자의 SNS 연동 해제 시 예외가 발생합니다") + void disconnectSns_비활성_사용자_예외_발생() { + assertThatThrownBy(() -> snsOAuthService.disconnectSns(inactiveUser, SnsPlatform.YOUTUBE)) + .isInstanceOf(SnsException.class) + .hasFieldOrPropertyWithValue("errorMessage", ErrorMessage.USER_NOT_FOUND); + } + + @Test + @DisplayName("SNS 연동 해제 후 목록 조회 시 제외됩니다") + void disconnectSns_해제_후_목록에서_제외() { + snsOAuthService.disconnectSns(activeUser, SnsPlatform.YOUTUBE); + + List connections = snsOAuthService.getUserSnsConnections(activeUser); + + assertThat(connections).hasSize(1); + assertThat(connections.get(0).getPlatform()).isEqualTo(SnsPlatform.INSTAGRAM); + } + + @Test + @DisplayName("OAuth 인증 URL에 state가 포함되어 생성됩니다") + void getAuthUrlWithState_성공_state_포함_URL_생성() { + String authUrl = snsOAuthService.getAuthUrlWithState(SnsPlatform.YOUTUBE, activeUser.getId()); + + assertThat(authUrl).isNotNull(); + assertThat(authUrl).contains("state="); + assertThat(authUrl).contains("client_id="); + assertThat(authUrl).contains("redirect_uri="); + } + + @Test + @DisplayName("생성된 state를 파싱하면 원래 userId를 얻을 수 있습니다") + void oAuthState_생성_및_파싱_정상_동작() { + String state = oAuthStateService.createState(activeUser.getId()); + Long parsedUserId = oAuthStateService.parseState(state); + + assertThat(parsedUserId).isEqualTo(activeUser.getId()); + } + + @Test + @DisplayName("해제된 SNS를 다시 연동할 수 있습니다") + void disconnectSns_해제_후_재연동_가능() { + snsOAuthService.disconnectSns(activeUser, SnsPlatform.YOUTUBE); + + SnsConnection deactivated = snsConnectionRepository.findByUserAndPlatformIgnoreActive(activeUser, SnsPlatform.YOUTUBE) + .orElseThrow(); + assertThat(deactivated.getIsActive()).isFalse(); + + deactivated.updateSnsInfo("new_youtube_id", "https://youtube.com/@newtest"); + deactivated.setIsActive(true); + snsConnectionRepository.save(deactivated); + + SnsConnection reactivated = snsConnectionRepository.findByUserAndPlatform(activeUser, SnsPlatform.YOUTUBE) + .orElseThrow(); + assertThat(reactivated.getIsActive()).isTrue(); + assertThat(reactivated.getSnsUserId()).isEqualTo("new_youtube_id"); + } + + @Test + @DisplayName("Repository의 findByUser는 활성 연동만 반환합니다") + void repository_findByUser_활성_연동만_반환() { + youtubeConnection.deactivate(); + snsConnectionRepository.save(youtubeConnection); + + List connections = snsConnectionRepository.findByUser(activeUser); + + assertThat(connections).hasSize(1); + assertThat(connections.get(0).getPlatform()).isEqualTo(SnsPlatform.INSTAGRAM); + } + + @Test + @DisplayName("Repository의 findByUserAndPlatformIgnoreActive는 비활성 포함 조회합니다") + void repository_findByUserAndPlatformIgnoreActive_비활성_포함_조회() { + youtubeConnection.deactivate(); + snsConnectionRepository.save(youtubeConnection); + + SnsConnection connection = snsConnectionRepository.findByUserAndPlatformIgnoreActive(activeUser, SnsPlatform.YOUTUBE) + .orElseThrow(); + + assertThat(connection.getIsActive()).isFalse(); + } +} diff --git a/src/test/java/com/example/cherrydan/user/service/UserDataCleanupServiceTest.java b/src/test/java/com/example/cherrydan/user/service/UserDataCleanupServiceTest.java new file mode 100644 index 00000000..73604707 --- /dev/null +++ b/src/test/java/com/example/cherrydan/user/service/UserDataCleanupServiceTest.java @@ -0,0 +1,259 @@ +package com.example.cherrydan.user.service; + +import com.example.cherrydan.activity.domain.ActivityAlert; +import com.example.cherrydan.activity.domain.ActivityAlertType; +import com.example.cherrydan.activity.repository.ActivityAlertRepository; +import com.example.cherrydan.campaign.domain.Bookmark; +import com.example.cherrydan.campaign.domain.Campaign; +import com.example.cherrydan.campaign.domain.CampaignStatus; +import com.example.cherrydan.campaign.domain.CampaignStatusType; +import com.example.cherrydan.campaign.repository.BookmarkRepository; +import com.example.cherrydan.campaign.repository.CampaignRepository; +import com.example.cherrydan.campaign.repository.CampaignStatusRepository; +import com.example.cherrydan.fcm.domain.DeviceType; +import com.example.cherrydan.fcm.domain.UserFCMToken; +import com.example.cherrydan.fcm.repository.UserFCMTokenRepository; +import com.example.cherrydan.inquiry.domain.Inquiry; +import com.example.cherrydan.inquiry.domain.InquiryCategory; +import com.example.cherrydan.inquiry.repository.InquiryRepository; +import com.example.cherrydan.oauth.domain.AuthProvider; +import com.example.cherrydan.oauth.domain.RefreshToken; +import com.example.cherrydan.oauth.repository.RefreshTokenRepository; +import com.example.cherrydan.sns.domain.SnsConnection; +import com.example.cherrydan.sns.domain.SnsPlatform; +import com.example.cherrydan.sns.repository.SnsConnectionRepository; +import com.example.cherrydan.user.domain.KeywordCampaignAlert; +import com.example.cherrydan.user.domain.Role; +import com.example.cherrydan.user.domain.User; +import com.example.cherrydan.user.repository.KeywordCampaignAlertRepository; +import com.example.cherrydan.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("local") +@Transactional +class UserDataCleanupServiceTest { + + @Autowired + private UserDataCleanupService userDataCleanupService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private SnsConnectionRepository snsConnectionRepository; + + @Autowired + private CampaignStatusRepository campaignStatusRepository; + + @Autowired + private InquiryRepository inquiryRepository; + + @Autowired + private BookmarkRepository bookmarkRepository; + + @Autowired + private ActivityAlertRepository activityAlertRepository; + + @Autowired + private KeywordCampaignAlertRepository keywordCampaignAlertRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private UserFCMTokenRepository userFCMTokenRepository; + + @Autowired + private CampaignRepository campaignRepository; + + private User expiredUser; + private User recentUser; + private Campaign testCampaign; + + @BeforeEach + void setUp() { + testCampaign = Campaign.builder() + .title("테스트 캠페인") + .detailUrl("https://test.com/campaign/" + System.currentTimeMillis()) + .applyEnd(LocalDate.now().plusDays(7)) + .isActive(true) + .build(); + testCampaign = campaignRepository.save(testCampaign); + + expiredUser = User.builder() + .email("expired@test.com") + .name("만료된유저") + .provider(AuthProvider.KAKAO) + .socialId("expired123") + .role(Role.ROLE_USER) + .isActive(false) + .build(); + expiredUser.softDelete(); + expiredUser.setDeletedAt(LocalDateTime.now().minusDays(366)); + expiredUser = userRepository.save(expiredUser); + + recentUser = User.builder() + .email("recent@test.com") + .name("최근삭제유저") + .provider(AuthProvider.KAKAO) + .socialId("recent123") + .role(Role.ROLE_USER) + .isActive(false) + .build(); + recentUser.softDelete(); + recentUser.setDeletedAt(LocalDateTime.now().minusDays(180)); + recentUser = userRepository.save(recentUser); + + createUserRelatedData(expiredUser); + createUserRelatedData(recentUser); + } + + private void createUserRelatedData(User user) { + SnsConnection snsConnection = SnsConnection.builder() + .user(user) + .platform(SnsPlatform.INSTAGRAM) + .snsUserId("test_sns_id_" + user.getId()) + .isActive(true) + .build(); + snsConnectionRepository.save(snsConnection); + + CampaignStatus campaignStatus = CampaignStatus.builder() + .user(user) + .campaign(testCampaign) + .status(CampaignStatusType.APPLY) + .isActive(true) + .build(); + campaignStatusRepository.save(campaignStatus); + + Inquiry inquiry = Inquiry.builder() + .user(user) + .category(InquiryCategory.OTHER) + .title("문의 제목") + .content("문의 내용") + .build(); + inquiryRepository.save(inquiry); + + Bookmark bookmark = Bookmark.builder() + .user(user) + .campaign(testCampaign) + .isActive(true) + .build(); + bookmarkRepository.save(bookmark); + + ActivityAlert activityAlert = ActivityAlert.builder() + .user(user) + .campaign(testCampaign) + .alertDate(LocalDate.now()) + .alertType(ActivityAlertType.APPLY_RESULT_DDAY) + .build(); + activityAlertRepository.save(activityAlert); + + KeywordCampaignAlert keywordAlert = KeywordCampaignAlert.builder() + .user(user) + .keyword("테스트") + .campaignCount(10) + .alertDate(LocalDate.now()) + .build(); + keywordCampaignAlertRepository.save(keywordAlert); + + RefreshToken refreshToken = RefreshToken.builder() + .user(user) + .refreshToken("test_refresh_token_" + user.getId()) + .build(); + refreshTokenRepository.save(refreshToken); + + UserFCMToken fcmToken = UserFCMToken.builder() + .userId(user.getId()) + .fcmToken("test_fcm_token_" + user.getId()) + .deviceModel("iPhone 14") + .deviceType(DeviceType.IOS) + .isActive(true) + .isAllowed(true) + .build(); + userFCMTokenRepository.save(fcmToken); + } + + @Test + @DisplayName("1년 경과한 유저의 연관 데이터가 모두 삭제된다") + void cleanupExpiredUserData_deletesAllRelatedData() { + Long expiredUserId = expiredUser.getId(); + + assertThat(snsConnectionRepository.findByUserId(expiredUserId)).isNotEmpty(); + assertThat(campaignStatusRepository.findByUserAndIsActiveTrue(expiredUser)).isNotEmpty(); + assertThat(refreshTokenRepository.findByUserId(expiredUserId)).isPresent(); + assertThat(userFCMTokenRepository.findByUserId(expiredUserId)).isNotEmpty(); + + userDataCleanupService.cleanupExpiredUserData(); + + assertThat(snsConnectionRepository.findByUserId(expiredUserId)).isEmpty(); + assertThat(campaignStatusRepository.findByUserAndIsActiveTrue(expiredUser)).isEmpty(); + assertThat(refreshTokenRepository.findByUserId(expiredUserId)).isEmpty(); + assertThat(userFCMTokenRepository.findByUserId(expiredUserId)).isEmpty(); + assertThat(activityAlertRepository.countByUserIdAndIsVisibleToUserTrue(expiredUserId)).isZero(); + assertThat(keywordCampaignAlertRepository.countUnreadByUserId(expiredUserId)).isZero(); + } + + @Test + @DisplayName("1년 이내에 삭제된 유저의 데이터는 삭제되지 않는다") + void cleanupExpiredUserData_doesNotDeleteRecentUsers() { + Long recentUserId = recentUser.getId(); + + long beforeSnsCount = snsConnectionRepository.findByUserId(recentUserId).size(); + long beforeBookmarkCount = bookmarkRepository.findAllByUserIdAndIsActiveTrue(recentUserId).size(); + + userDataCleanupService.cleanupExpiredUserData(); + + long afterSnsCount = snsConnectionRepository.findByUserId(recentUserId).size(); + long afterBookmarkCount = bookmarkRepository.findAllByUserIdAndIsActiveTrue(recentUserId).size(); + + assertThat(afterSnsCount).isEqualTo(beforeSnsCount); + assertThat(afterBookmarkCount).isEqualTo(beforeBookmarkCount); + } + + @Test + @DisplayName("User 엔티티는 소프트 딜리트 상태로 유지된다") + void cleanupExpiredUserData_keepsUserInSoftDeletedState() { + Long expiredUserId = expiredUser.getId(); + + userDataCleanupService.cleanupExpiredUserData(); + + User user = userRepository.findById(expiredUserId).orElseThrow(); + assertThat(user.getIsActive()).isFalse(); + assertThat(user.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("1년 이내 유저는 복구 가능하다") + void isRestorableWithin1Year_returnsTrueForRecentUsers() { + assertThat(recentUser.isRestorableWithin1Year()).isTrue(); + } + + @Test + @DisplayName("1년 경과 유저는 복구 불가능하다") + void isRestorableWithin1Year_returnsFalseForExpiredUsers() { + assertThat(expiredUser.isRestorableWithin1Year()).isFalse(); + } + + @Test + @DisplayName("복구 기능이 정상 동작한다") + void restore_restoresUserSuccessfully() { + recentUser.restore(); + userRepository.save(recentUser); + + User restored = userRepository.findActiveById(recentUser.getId()).orElseThrow(); + assertThat(restored.getIsActive()).isTrue(); + assertThat(restored.getDeletedAt()).isNull(); + } +}