diff --git a/.claude/settings.local.json b/.claude/settings.local.json index db821b3..a547f01 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,10 @@ "Bash(dir:*)", "Bash(del /f \"C:\\Users\\haeun\\OneDrive\\Desktop\\BEProject\\konnect-back\\src\\main\\java\\com\\example\\konnect_backend\\domain\\auth\\dto\\request\\SignInRequest.java.bak\")", "Bash(del /f \"C:\\Users\\haeun\\OneDrive\\Desktop\\BEProject\\konnect-back\\src\\main\\java\\com\\example\\konnect_backend\\domain\\ai\\service\\FileTranslationService.java\")", - "Bash(test:*)" + "Bash(test:*)", + "Bash(./gradlew bootRun:*)", + "Bash(curl:*)", + "Bash(./gradlew test:*)" ], "deny": [], "ask": [] diff --git a/.gitignore b/.gitignore index b4383b7..189247f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ application-*.properties # Google Vision API key src/main/resources/google-key.json google-key.json + +src/main/resources/firebase/firebase-service-account.json +firebase-service-account.json +src/main/resources/firebase/*.json diff --git a/build.gradle b/build.gradle index 934e2f3..0107e47 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,9 @@ dependencies { // 캐싱 implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' implementation 'org.springframework.boot:spring-boot-starter-cache' + + // Firebase Admin SDK (FCM 푸시 알림) + implementation 'com.google.firebase:firebase-admin:9.2.0' } tasks.named('test') { diff --git a/src/main/java/com/example/konnect_backend/KonnectBackendApplication.java b/src/main/java/com/example/konnect_backend/KonnectBackendApplication.java index 47108e1..654d9f1 100644 --- a/src/main/java/com/example/konnect_backend/KonnectBackendApplication.java +++ b/src/main/java/com/example/konnect_backend/KonnectBackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class KonnectBackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/DifficultExpressionExtractorModule.java b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/DifficultExpressionExtractorModule.java index 1b412f6..445dba4 100644 --- a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/DifficultExpressionExtractorModule.java +++ b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/DifficultExpressionExtractorModule.java @@ -53,10 +53,14 @@ public class DifficultExpressionExtractorModule implements PromptModule { - 문서의 전체 내용과 구조는 유지 - 의미가 달라지지 않도록 주의 + ## 출력 형식 규칙 (필수) + - 마크다운 문법 사용 금지 (###, **, *, -, |, 표 등 사용하지 않기) + - 순수 텍스트로만 작성 + - 줄바꿈은 허용하되, 특수 기호나 서식 없이 일반 문장으로 작성 + ## 원본 텍스트 %s diff --git a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/SummarizerModule.java b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/SummarizerModule.java index dbc1388..e77f811 100644 --- a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/SummarizerModule.java +++ b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/SummarizerModule.java @@ -46,6 +46,11 @@ public class SummarizerModule implements PromptModule { - 학부모가 바로 이해할 수 있게 명확하게 작성 - %s로 요약문만 출력하고 다른 설명은 하지 마세요 + ## 출력 형식 규칙 (필수) + - 마크다운 문법 사용 금지 (###, **, *, -, |, 표 등 사용하지 않기) + - 순수 텍스트로만 작성 + - 줄바꿈은 허용하되, 특수 기호나 서식 없이 일반 문장으로 작성 + ## 번역문 %s diff --git a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/TranslatorModule.java b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/TranslatorModule.java index 350cbf6..38453ec 100644 --- a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/TranslatorModule.java +++ b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/TranslatorModule.java @@ -48,6 +48,11 @@ public class TranslatorModule implements PromptModule { - 문단 구조 유지 - 번역문만 출력하고 다른 설명은 하지 마세요 + ## 출력 형식 규칙 (필수) + - 마크다운 문법 사용 금지 (###, **, *, -, |, 표 등 사용하지 않기) + - 순수 텍스트로만 작성 + - 줄바꿈은 허용하되, 특수 기호나 서식 없이 일반 문장으로 작성 + ## 원문 %s diff --git a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/UnifiedExtractorModule.java b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/UnifiedExtractorModule.java index 13a4e96..e1e2fdf 100644 --- a/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/UnifiedExtractorModule.java +++ b/src/main/java/com/example/konnect_backend/domain/ai/service/prompt/UnifiedExtractorModule.java @@ -85,6 +85,10 @@ public class UnifiedExtractorModule implements PromptModule registerFcmToken(@Valid @RequestBody FcmTokenRequest fcmTokenRequest) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + notificationService.registerFcmToken(userId, fcmTokenRequest); + return ApiResponse.onSuccess("FCM 토큰이 등록되었습니다."); + } + + @DeleteMapping("/fcm-token") + @Operation(summary = "FCM 토큰 삭제", description = "로그아웃 시 FCM 토큰을 삭제합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "토큰 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse removeFcmToken( + @Parameter(description = "삭제할 FCM 토큰", required = true) + @RequestParam String token) { + notificationService.removeFcmToken(token); + return ApiResponse.onSuccess("FCM 토큰이 삭제되었습니다."); + } + + @GetMapping + @Operation(summary = "알림 목록 조회", description = "사용자의 알림 목록을 페이징하여 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse getNotifications( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") int size) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + return ApiResponse.onSuccess(notificationService.getNotifications(userId, page, size)); + } + + @GetMapping("/unread-count") + @Operation(summary = "읽지 않은 알림 개수 조회", description = "읽지 않은 알림의 개수를 조회합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse getUnreadCount() { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + return ApiResponse.onSuccess(notificationService.getUnreadCount(userId)); + } + + @PatchMapping("/{notificationId}/read") + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 처리합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽음 처리 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "알림을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse markAsRead( + @Parameter(description = "알림 ID", required = true, example = "1") + @PathVariable Long notificationId) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + notificationService.markAsRead(userId, notificationId); + return ApiResponse.onSuccess("알림이 읽음 처리되었습니다."); + } + + @PatchMapping("/read-all") + @Operation(summary = "모든 알림 읽음 처리", description = "사용자의 모든 알림을 읽음 처리합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽음 처리 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse markAllAsRead() { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + notificationService.markAllAsRead(userId); + return ApiResponse.onSuccess("모든 알림이 읽음 처리되었습니다."); + } + + @DeleteMapping("/{notificationId}") + @Operation(summary = "알림 삭제", description = "특정 알림을 삭제합니다.") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "알림을 찾을 수 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class))) + }) + public ApiResponse deleteNotification( + @Parameter(description = "알림 ID", required = true, example = "1") + @PathVariable Long notificationId) { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + notificationService.deleteNotification(userId, notificationId); + return ApiResponse.onSuccess("알림이 삭제되었습니다."); + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/dto/request/FcmTokenRequest.java b/src/main/java/com/example/konnect_backend/domain/notification/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..356ad39 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/dto/request/FcmTokenRequest.java @@ -0,0 +1,21 @@ +package com.example.konnect_backend.domain.notification.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmTokenRequest { + + @NotBlank(message = "FCM 토큰은 필수입니다") + private String token; + + private String deviceId; + + private String deviceType; // iOS, Android 등 +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationListResponse.java new file mode 100644 index 0000000..759181f --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationListResponse.java @@ -0,0 +1,23 @@ +package com.example.konnect_backend.domain.notification.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationListResponse { + + private List notifications; + private long unreadCount; + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..b7deff9 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,40 @@ +package com.example.konnect_backend.domain.notification.dto.response; + +import com.example.konnect_backend.domain.notification.entity.Notification; +import com.example.konnect_backend.domain.notification.entity.NotificationType; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationResponse { + + private Long id; + private String title; + private String body; + private NotificationType type; + private Long referenceId; + private Boolean isRead; + + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime createdAt; + + public static NotificationResponse from(Notification notification) { + return NotificationResponse.builder() + .id(notification.getId()) + .title(notification.getTitle()) + .body(notification.getBody()) + .type(notification.getType()) + .referenceId(notification.getReferenceId()) + .isRead(notification.getIsRead()) + .createdAt(notification.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/dto/response/UnreadCountResponse.java b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/UnreadCountResponse.java new file mode 100644 index 0000000..903dbbb --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/dto/response/UnreadCountResponse.java @@ -0,0 +1,14 @@ +package com.example.konnect_backend.domain.notification.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UnreadCountResponse { + private long unreadCount; +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/entity/FcmToken.java b/src/main/java/com/example/konnect_backend/domain/notification/entity/FcmToken.java new file mode 100644 index 0000000..ebf6a0c --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/entity/FcmToken.java @@ -0,0 +1,53 @@ +package com.example.konnect_backend.domain.notification.entity; + +import com.example.konnect_backend.domain.user.entity.User; +import com.example.konnect_backend.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "fcm_token", indexes = { + @Index(name = "idx_fcm_token_user", columnList = "user_id") +}) +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true) + private String token; + + // 디바이스 식별자 (동일 디바이스에서 토큰 갱신 시 사용) + private String deviceId; + + // 디바이스 타입 (iOS, Android 등) + private String deviceType; + + @Builder.Default + @Column(nullable = false) + private Boolean isActive = true; + + public void updateToken(String newToken) { + this.token = newToken; + } + + public void deactivate() { + this.isActive = false; + } + + public void activate() { + this.isActive = true; + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/entity/Notification.java b/src/main/java/com/example/konnect_backend/domain/notification/entity/Notification.java new file mode 100644 index 0000000..372f3cb --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/entity/Notification.java @@ -0,0 +1,58 @@ +package com.example.konnect_backend.domain.notification.entity; + +import com.example.konnect_backend.domain.user.entity.User; +import com.example.konnect_backend.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "notification", indexes = { + @Index(name = "idx_notification_user_read", columnList = "user_id, is_read"), + @Index(name = "idx_notification_user_created", columnList = "user_id, created_at DESC") +}) +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, length = 500) + private String body; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; + + // 관련 엔티티 ID (일정 ID, 문서 ID 등) + private Long referenceId; + + @Builder.Default + @Column(nullable = false) + private Boolean isRead = false; + + @Builder.Default + @Column(nullable = false) + private Boolean isSent = false; + + public void markAsRead() { + this.isRead = true; + } + + public void markAsSent() { + this.isSent = true; + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/entity/NotificationType.java b/src/main/java/com/example/konnect_backend/domain/notification/entity/NotificationType.java new file mode 100644 index 0000000..ad2c2e6 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/entity/NotificationType.java @@ -0,0 +1,7 @@ +package com.example.konnect_backend.domain.notification.entity; + +public enum NotificationType { + SCHEDULE, // 일정 알림 + DOCUMENT, // 문서 분석 완료 알림 + SYSTEM // 시스템 알림 +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/repository/FcmTokenRepository.java b/src/main/java/com/example/konnect_backend/domain/notification/repository/FcmTokenRepository.java new file mode 100644 index 0000000..3fea760 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/repository/FcmTokenRepository.java @@ -0,0 +1,39 @@ +package com.example.konnect_backend.domain.notification.repository; + +import com.example.konnect_backend.domain.notification.entity.FcmToken; +import com.example.konnect_backend.domain.user.entity.User; +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.util.List; +import java.util.Optional; + +@Repository +public interface FcmTokenRepository extends JpaRepository { + + // 사용자의 활성화된 토큰 목록 조회 + List findByUserAndIsActiveTrue(User user); + + // 토큰으로 조회 + Optional findByToken(String token); + + // 디바이스 ID로 조회 + Optional findByUserAndDeviceId(User user, String deviceId); + + // 사용자의 모든 토큰 조회 + List findByUser(User user); + + // 토큰 존재 여부 확인 + boolean existsByToken(String token); + + // 사용자의 모든 토큰 비활성화 + @Modifying + @Query("UPDATE FcmToken t SET t.isActive = false WHERE t.user = :user") + int deactivateAllByUser(@Param("user") User user); + + // 특정 토큰 삭제 + void deleteByToken(String token); +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/repository/NotificationRepository.java b/src/main/java/com/example/konnect_backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..cacdf02 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,40 @@ +package com.example.konnect_backend.domain.notification.repository; + +import com.example.konnect_backend.domain.notification.entity.Notification; +import com.example.konnect_backend.domain.user.entity.User; +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.LocalDateTime; +import java.util.List; + +@Repository +public interface NotificationRepository extends JpaRepository { + + // 사용자의 모든 알림 조회 (최신순) + Page findByUserOrderByCreatedAtDesc(User user, Pageable pageable); + + // 사용자의 읽지 않은 알림 조회 + List findByUserAndIsReadFalseOrderByCreatedAtDesc(User user); + + // 사용자의 읽지 않은 알림 개수 + long countByUserAndIsReadFalse(User user); + + // 사용자의 모든 알림을 읽음 처리 + @Modifying + @Query("UPDATE Notification n SET n.isRead = true WHERE n.user = :user AND n.isRead = false") + int markAllAsReadByUser(@Param("user") User user); + + // 특정 기간 이전의 알림 삭제 (정리용) + @Modifying + @Query("DELETE FROM Notification n WHERE n.createdAt < :before") + int deleteOldNotifications(@Param("before") LocalDateTime before); + + // 발송되지 않은 알림 조회 (재시도용) + List findByIsSentFalseAndCreatedAtAfter(LocalDateTime after); +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/scheduler/NotificationScheduler.java b/src/main/java/com/example/konnect_backend/domain/notification/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..f682fc4 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/scheduler/NotificationScheduler.java @@ -0,0 +1,132 @@ +package com.example.konnect_backend.domain.notification.scheduler; + +import com.example.konnect_backend.domain.notification.entity.NotificationType; +import com.example.konnect_backend.domain.notification.service.NotificationService; +import com.example.konnect_backend.domain.schedule.entity.Schedule; +import com.example.konnect_backend.domain.schedule.entity.ScheduleAlarm; +import com.example.konnect_backend.domain.schedule.entity.status.AlarmTimeType; +import com.example.konnect_backend.domain.schedule.repository.ScheduleAlarmRepository; +import com.example.konnect_backend.domain.schedule.repository.ScheduleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduler { + + private final ScheduleRepository scheduleRepository; + private final ScheduleAlarmRepository scheduleAlarmRepository; + private final NotificationService notificationService; + + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("M월 d일"); + + /** + * 1분마다 실행하여 발송해야 할 알림 확인 + */ + @Scheduled(fixedRate = 60000) // 1분마다 + @Transactional(readOnly = true) + public void checkAndSendScheduleAlarms() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime checkStart = now.minusSeconds(30); + LocalDateTime checkEnd = now.plusSeconds(30); + + log.debug("스케줄 알림 확인 시작: {} ~ {}", checkStart, checkEnd); + + // 10분 전 알림 체크 + checkAlarms(AlarmTimeType.BEFORE_10M, 10, checkStart, checkEnd); + + // 1시간 전 알림 체크 + checkAlarms(AlarmTimeType.BEFORE_1H, 60, checkStart, checkEnd); + + // 1일 전 알림 체크 + checkAlarms(AlarmTimeType.BEFORE_1D, 1440, checkStart, checkEnd); + + // Custom 알림 체크 + checkCustomAlarms(checkStart, checkEnd); + } + + private void checkAlarms(AlarmTimeType type, int minutesBefore, LocalDateTime checkStart, LocalDateTime checkEnd) { + // 알림을 발송해야 할 일정 시작 시간 범위 계산 + LocalDateTime scheduleStart = checkStart.plusMinutes(minutesBefore); + LocalDateTime scheduleEnd = checkEnd.plusMinutes(minutesBefore); + + List schedules = scheduleRepository.findByStartDateBetween(scheduleStart, scheduleEnd); + + for (Schedule schedule : schedules) { + List alarms = scheduleAlarmRepository.findBySchedule(schedule); + for (ScheduleAlarm alarm : alarms) { + if (alarm.getAlarmTimeType() == type) { + sendScheduleNotification(schedule, alarm, getAlarmMessage(type)); + } + } + } + } + + private void checkCustomAlarms(LocalDateTime checkStart, LocalDateTime checkEnd) { + List customAlarms = scheduleAlarmRepository.findCustomAlarmsInTimeRange(checkStart, checkEnd); + + for (ScheduleAlarm alarm : customAlarms) { + sendScheduleNotification(alarm.getSchedule(), alarm, "예정된 알림"); + } + } + + private void sendScheduleNotification(Schedule schedule, ScheduleAlarm alarm, String timeLabel) { + String title = "일정 알림"; + String body = buildNotificationBody(schedule, timeLabel); + + try { + notificationService.createAndSendNotification( + alarm.getUser().getId(), + title, + body, + NotificationType.SCHEDULE, + schedule.getScheduleId() + ); + log.info("스케줄 알림 발송 완료 - scheduleId: {}, userId: {}", + schedule.getScheduleId(), alarm.getUser().getId()); + } catch (Exception e) { + log.error("스케줄 알림 발송 실패 - scheduleId: {}, userId: {}, error: {}", + schedule.getScheduleId(), alarm.getUser().getId(), e.getMessage()); + } + } + + private String buildNotificationBody(Schedule schedule, String timeLabel) { + String time = schedule.getStartDate().format(TIME_FORMATTER); + String date = schedule.getStartDate().format(DATE_FORMATTER); + + if (schedule.getIsAllDay()) { + return String.format("[%s] %s\n%s 예정", timeLabel, schedule.getTitle(), date); + } + return String.format("[%s] %s\n%s %s 예정", timeLabel, schedule.getTitle(), date, time); + } + + private String getAlarmMessage(AlarmTimeType type) { + return switch (type) { + case BEFORE_10M -> "10분 전"; + case BEFORE_1H -> "1시간 전"; + case BEFORE_1D -> "1일 전"; + case CUSTOM -> "예정된 알림"; + }; + } + + /** + * 매일 자정에 30일 이전의 오래된 알림 삭제 + */ + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void cleanupOldNotifications() { + LocalDateTime thirtyDaysAgo = LocalDateTime.now().minusDays(30); + log.info("오래된 알림 정리 시작: {} 이전 알림 삭제", thirtyDaysAgo); + // NotificationRepository에서 정리 메서드 호출 + // 이 작업은 NotificationService에서 처리하도록 위임 가능 + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/service/FcmService.java b/src/main/java/com/example/konnect_backend/domain/notification/service/FcmService.java new file mode 100644 index 0000000..dfb008c --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/service/FcmService.java @@ -0,0 +1,186 @@ +package com.example.konnect_backend.domain.notification.service; + +import com.example.konnect_backend.domain.notification.entity.FcmToken; +import com.example.konnect_backend.domain.notification.entity.Notification; +import com.example.konnect_backend.domain.notification.repository.FcmTokenRepository; +import com.example.konnect_backend.domain.user.entity.User; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmService { + + private final FirebaseMessaging firebaseMessaging; + private final FcmTokenRepository fcmTokenRepository; + + /** + * 단일 사용자에게 푸시 알림 발송 + */ + public boolean sendNotification(User user, Notification notification) { + if (firebaseMessaging == null) { + log.warn("FirebaseMessaging이 초기화되지 않아 알림을 발송할 수 없습니다."); + return false; + } + + List tokens = fcmTokenRepository.findByUserAndIsActiveTrue(user); + if (tokens.isEmpty()) { + log.debug("사용자 {}에게 활성화된 FCM 토큰이 없습니다.", user.getId()); + return false; + } + + List tokenStrings = tokens.stream() + .map(FcmToken::getToken) + .toList(); + + return sendMulticast(tokenStrings, notification); + } + + /** + * 단일 토큰으로 알림 발송 + */ + public boolean sendToToken(String token, String title, String body, Map data) { + if (firebaseMessaging == null) { + log.warn("FirebaseMessaging이 초기화되지 않아 알림을 발송할 수 없습니다."); + return false; + } + + try { + Message message = Message.builder() + .setToken(token) + .setNotification(com.google.firebase.messaging.Notification.builder() + .setTitle(title) + .setBody(body) + .build()) + .putAllData(data != null ? data : new HashMap<>()) + .setApnsConfig(ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(1) + .build()) + .build()) + .setAndroidConfig(AndroidConfig.builder() + .setNotification(AndroidNotification.builder() + .setSound("default") + .setPriority(AndroidNotification.Priority.HIGH) + .build()) + .build()) + .build(); + + String response = firebaseMessaging.send(message); + log.debug("FCM 알림 발송 성공: {}", response); + return true; + + } catch (FirebaseMessagingException e) { + handleFcmException(token, e); + return false; + } + } + + /** + * 여러 토큰에 멀티캐스트 발송 + */ + private boolean sendMulticast(List tokens, Notification notification) { + if (tokens.isEmpty()) { + return false; + } + + Map data = new HashMap<>(); + data.put("type", notification.getType().name()); + if (notification.getReferenceId() != null) { + data.put("referenceId", notification.getReferenceId().toString()); + } + data.put("notificationId", notification.getId().toString()); + + try { + MulticastMessage message = MulticastMessage.builder() + .addAllTokens(tokens) + .setNotification(com.google.firebase.messaging.Notification.builder() + .setTitle(notification.getTitle()) + .setBody(notification.getBody()) + .build()) + .putAllData(data) + .setApnsConfig(ApnsConfig.builder() + .setAps(Aps.builder() + .setSound("default") + .setBadge(1) + .build()) + .build()) + .setAndroidConfig(AndroidConfig.builder() + .setNotification(AndroidNotification.builder() + .setSound("default") + .setPriority(AndroidNotification.Priority.HIGH) + .build()) + .build()) + .build(); + + BatchResponse response = firebaseMessaging.sendEachForMulticast(message); + log.info("FCM 멀티캐스트 발송 결과: 성공 {}, 실패 {}", + response.getSuccessCount(), response.getFailureCount()); + + // 실패한 토큰 처리 + handleFailedTokens(tokens, response); + + return response.getSuccessCount() > 0; + + } catch (FirebaseMessagingException e) { + log.error("FCM 멀티캐스트 발송 실패: {}", e.getMessage()); + return false; + } + } + + /** + * 실패한 토큰 처리 (비활성화 또는 삭제) + */ + private void handleFailedTokens(List tokens, BatchResponse response) { + List responses = response.getResponses(); + List tokensToDeactivate = new ArrayList<>(); + + for (int i = 0; i < responses.size(); i++) { + if (!responses.get(i).isSuccessful()) { + FirebaseMessagingException exception = responses.get(i).getException(); + if (exception != null) { + MessagingErrorCode errorCode = exception.getMessagingErrorCode(); + if (errorCode == MessagingErrorCode.UNREGISTERED || + errorCode == MessagingErrorCode.INVALID_ARGUMENT) { + tokensToDeactivate.add(tokens.get(i)); + } + } + } + } + + // 유효하지 않은 토큰 비활성화 + for (String token : tokensToDeactivate) { + fcmTokenRepository.findByToken(token).ifPresent(fcmToken -> { + fcmToken.deactivate(); + fcmTokenRepository.save(fcmToken); + log.info("유효하지 않은 FCM 토큰 비활성화: {}", token.substring(0, 20) + "..."); + }); + } + } + + /** + * FCM 예외 처리 + */ + private void handleFcmException(String token, FirebaseMessagingException e) { + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + log.error("FCM 발송 실패 - ErrorCode: {}, Message: {}", errorCode, e.getMessage()); + + if (errorCode == MessagingErrorCode.UNREGISTERED || + errorCode == MessagingErrorCode.INVALID_ARGUMENT) { + fcmTokenRepository.findByToken(token).ifPresent(fcmToken -> { + fcmToken.deactivate(); + fcmTokenRepository.save(fcmToken); + log.info("유효하지 않은 FCM 토큰 비활성화: {}", token.substring(0, 20) + "..."); + }); + } + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/notification/service/NotificationService.java b/src/main/java/com/example/konnect_backend/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..e140543 --- /dev/null +++ b/src/main/java/com/example/konnect_backend/domain/notification/service/NotificationService.java @@ -0,0 +1,215 @@ +package com.example.konnect_backend.domain.notification.service; + +import com.example.konnect_backend.domain.notification.dto.request.FcmTokenRequest; +import com.example.konnect_backend.domain.notification.dto.response.NotificationListResponse; +import com.example.konnect_backend.domain.notification.dto.response.NotificationResponse; +import com.example.konnect_backend.domain.notification.dto.response.UnreadCountResponse; +import com.example.konnect_backend.domain.notification.entity.FcmToken; +import com.example.konnect_backend.domain.notification.entity.Notification; +import com.example.konnect_backend.domain.notification.entity.NotificationType; +import com.example.konnect_backend.domain.notification.repository.FcmTokenRepository; +import com.example.konnect_backend.domain.notification.repository.NotificationRepository; +import com.example.konnect_backend.domain.user.entity.User; +import com.example.konnect_backend.domain.user.repository.UserRepository; +import com.example.konnect_backend.global.exception.GeneralException; +import com.example.konnect_backend.global.code.status.ErrorStatus; +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; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final FcmTokenRepository fcmTokenRepository; + private final UserRepository userRepository; + private final FcmService fcmService; + + /** + * FCM 토큰 등록/갱신 + */ + @Transactional + public void registerFcmToken(Long userId, FcmTokenRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + // 동일한 토큰이 이미 존재하는지 확인 + if (fcmTokenRepository.existsByToken(request.getToken())) { + fcmTokenRepository.findByToken(request.getToken()).ifPresent(existingToken -> { + // 다른 사용자의 토큰이면 이전 사용자에서 삭제 + if (!existingToken.getUser().getId().equals(userId)) { + fcmTokenRepository.delete(existingToken); + createNewToken(user, request); + } else { + // 같은 사용자면 활성화만 + existingToken.activate(); + fcmTokenRepository.save(existingToken); + } + }); + return; + } + + // deviceId가 있으면 기존 토큰 업데이트 + if (request.getDeviceId() != null) { + fcmTokenRepository.findByUserAndDeviceId(user, request.getDeviceId()) + .ifPresentOrElse( + existingToken -> { + existingToken.updateToken(request.getToken()); + existingToken.activate(); + fcmTokenRepository.save(existingToken); + }, + () -> createNewToken(user, request) + ); + } else { + createNewToken(user, request); + } + + log.info("FCM 토큰 등록 완료 - userId: {}", userId); + } + + private void createNewToken(User user, FcmTokenRequest request) { + FcmToken token = FcmToken.builder() + .user(user) + .token(request.getToken()) + .deviceId(request.getDeviceId()) + .deviceType(request.getDeviceType()) + .isActive(true) + .build(); + fcmTokenRepository.save(token); + } + + /** + * FCM 토큰 삭제 (로그아웃 시) + */ + @Transactional + public void removeFcmToken(String token) { + fcmTokenRepository.deleteByToken(token); + log.info("FCM 토큰 삭제 완료"); + } + + /** + * 알림 생성 및 발송 + */ + @Transactional + public NotificationResponse createAndSendNotification( + Long userId, + String title, + String body, + NotificationType type, + Long referenceId + ) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + Notification notification = Notification.builder() + .user(user) + .title(title) + .body(body) + .type(type) + .referenceId(referenceId) + .isRead(false) + .isSent(false) + .build(); + + notification = notificationRepository.save(notification); + + // FCM 푸시 알림 발송 + boolean sent = fcmService.sendNotification(user, notification); + if (sent) { + notification.markAsSent(); + notificationRepository.save(notification); + } + + return NotificationResponse.from(notification); + } + + /** + * 알림 목록 조회 + */ + public NotificationListResponse getNotifications(Long userId, int page, int size) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + Pageable pageable = PageRequest.of(page, size); + Page notificationPage = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable); + long unreadCount = notificationRepository.countByUserAndIsReadFalse(user); + + List notifications = notificationPage.getContent().stream() + .map(NotificationResponse::from) + .toList(); + + return NotificationListResponse.builder() + .notifications(notifications) + .unreadCount(unreadCount) + .page(page) + .size(size) + .totalElements(notificationPage.getTotalElements()) + .totalPages(notificationPage.getTotalPages()) + .hasNext(notificationPage.hasNext()) + .build(); + } + + /** + * 읽지 않은 알림 개수 조회 + */ + public UnreadCountResponse getUnreadCount(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + long count = notificationRepository.countByUserAndIsReadFalse(user); + return UnreadCountResponse.builder() + .unreadCount(count) + .build(); + } + + /** + * 단일 알림 읽음 처리 + */ + @Transactional + public void markAsRead(Long userId, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NOTIFICATION_NOT_FOUND)); + + if (!notification.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.FORBIDDEN); + } + + notification.markAsRead(); + notificationRepository.save(notification); + } + + /** + * 모든 알림 읽음 처리 + */ + @Transactional + public void markAllAsRead(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + notificationRepository.markAllAsReadByUser(user); + } + + /** + * 알림 삭제 + */ + @Transactional + public void deleteNotification(Long userId, Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.NOTIFICATION_NOT_FOUND)); + + if (!notification.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.FORBIDDEN); + } + + notificationRepository.delete(notification); + } +} diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/entity/Schedule.java b/src/main/java/com/example/konnect_backend/domain/schedule/entity/Schedule.java index 1ed56d7..ca3cfb0 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/entity/Schedule.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/entity/Schedule.java @@ -24,11 +24,11 @@ public class Schedule extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long scheduleId; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "child_id") private Child child; diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/entity/ScheduleRepeat.java b/src/main/java/com/example/konnect_backend/domain/schedule/entity/ScheduleRepeat.java index 8e2f6dd..2459218 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/entity/ScheduleRepeat.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/entity/ScheduleRepeat.java @@ -1,6 +1,5 @@ package com.example.konnect_backend.domain.schedule.entity; -import com.example.konnect_backend.domain.user.entity.User; import com.example.konnect_backend.domain.schedule.entity.status.RepeatEndType; import com.example.konnect_backend.domain.schedule.entity.status.RepeatType; import com.example.konnect_backend.global.common.BaseEntity; @@ -23,18 +22,16 @@ public class ScheduleRepeat extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "schedule_id", nullable = false) private Schedule schedule; - @ManyToOne - @JoinColumn(name = "user_id", nullable = false) - private User user; - @Enumerated(EnumType.STRING) + @Column(nullable = false) private RepeatType repeatType; // DAILY, WEEKLY, MONTHLY, YEARLY @Enumerated(EnumType.STRING) + @Column(nullable = false) private RepeatEndType repeatEndType; // FOREVER, UNTIL_DATE, COUNT private LocalDateTime repeatEndDate; diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleAlarmRepository.java b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleAlarmRepository.java index bcf5659..1a40d16 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleAlarmRepository.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleAlarmRepository.java @@ -22,7 +22,14 @@ public interface ScheduleAlarmRepository extends JpaRepository findAlarmsInTimeRange(@Param("user") User user, - @Param("startTime") LocalDateTime startTime, + List findAlarmsInTimeRange(@Param("user") User user, + @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime); + + @Query("SELECT sa FROM ScheduleAlarm sa " + + "WHERE sa.alarmTimeType = 'CUSTOM' " + + "AND sa.customMinutesBefore IS NOT NULL " + + "AND sa.customMinutesBefore BETWEEN :startTime AND :endTime") + List findCustomAlarmsInTimeRange(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepeatRepository.java b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepeatRepository.java index b349e4e..b63d345 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepeatRepository.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepeatRepository.java @@ -2,19 +2,15 @@ import com.example.konnect_backend.domain.schedule.entity.Schedule; import com.example.konnect_backend.domain.schedule.entity.ScheduleRepeat; -import com.example.konnect_backend.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; import java.util.Optional; @Repository public interface ScheduleRepeatRepository extends JpaRepository { - + Optional findBySchedule(Schedule schedule); - - List findByUser(User user); - + void deleteBySchedule(Schedule schedule); } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepository.java b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepository.java index 634073a..ebd2f3e 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepository.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/repository/ScheduleRepository.java @@ -12,47 +12,22 @@ @Repository public interface ScheduleRepository extends JpaRepository { - - @Query("SELECT s FROM Schedule s WHERE s.user = :user " + - "AND ((s.startDate >= :startDate AND s.startDate < :endDate) " + - "OR (s.endDate > :startDate AND s.endDate <= :endDate) " + - "OR (s.startDate <= :startDate AND s.endDate >= :endDate)) " + - "ORDER BY s.startDate ASC") - List findByUserAndDateRange(@Param("user") User user, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - @Query("SELECT s FROM Schedule s WHERE s.user = :user " + - "AND s.startDate >= :now " + - "ORDER BY s.startDate ASC") - List findUpcomingSchedules(@Param("user") User user, - @Param("now") LocalDateTime now); - - List findByUserOrderByStartDateDesc(User user); - - @Query("SELECT s FROM Schedule s WHERE s.user = :user " + - "AND DATE(s.startDate) = :date " + - "ORDER BY s.startDate ASC") - List findByUserAndDate(@Param("user") User user, - @Param("date") LocalDateTime date); - - @Query("SELECT s FROM Schedule s WHERE s.user = :user " + - "AND s.startDate >= :startOfWeek AND s.startDate < :endOfWeek " + - "ORDER BY s.startDate ASC") - List findByUserAndWeek(@Param("user") User user, - @Param("startOfWeek") LocalDateTime startOfWeek, - @Param("endOfWeek") LocalDateTime endOfWeek); - - @Query("SELECT DATE(s.startDate) as date, COUNT(s) as count " + - "FROM Schedule s WHERE s.user = :user " + - "AND s.startDate >= :startDate AND s.startDate < :endDate " + - "GROUP BY DATE(s.startDate)") - List findScheduleCountsByDate(@Param("user") User user, - @Param("startDate") LocalDateTime startDate, - @Param("endDate") LocalDateTime endDate); - - @Query("SELECT s FROM Schedule s WHERE s.user = :user " + - "AND DATE(s.startDate) = CURRENT_DATE " + + + /** + * 사용자의 모든 일정을 반복 설정과 함께 조회 (Fetch Join으로 N+1 방지) + */ + @Query("SELECT DISTINCT s FROM Schedule s " + + "LEFT JOIN FETCH s.scheduleRepeat " + + "WHERE s.user = :user " + "ORDER BY s.startDate ASC") - List findTodaySchedules(@Param("user") User user); + List findAllByUserWithRepeat(@Param("user") User user); + + + /** + * 특정 시간 범위 내에 시작하는 일정 조회 (알림 스케줄러용) + */ + @Query("SELECT s FROM Schedule s " + + "WHERE s.startDate >= :startTime AND s.startDate <= :endTime") + List findByStartDateBetween(@Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime); } \ No newline at end of file diff --git a/src/main/java/com/example/konnect_backend/domain/schedule/service/ScheduleService.java b/src/main/java/com/example/konnect_backend/domain/schedule/service/ScheduleService.java index 0caaef0..b29c062 100644 --- a/src/main/java/com/example/konnect_backend/domain/schedule/service/ScheduleService.java +++ b/src/main/java/com/example/konnect_backend/domain/schedule/service/ScheduleService.java @@ -1,6 +1,8 @@ package com.example.konnect_backend.domain.schedule.service; +import com.example.konnect_backend.domain.schedule.dto.request.ScheduleAlarmRequest; import com.example.konnect_backend.domain.schedule.dto.request.ScheduleCreateRequest; +import com.example.konnect_backend.domain.schedule.dto.request.ScheduleRepeatRequest; import com.example.konnect_backend.domain.schedule.dto.request.ScheduleUpdateRequest; import com.example.konnect_backend.domain.schedule.dto.response.CalendarDateResponse; import com.example.konnect_backend.domain.schedule.dto.response.ScheduleAlarmResponse; @@ -9,6 +11,8 @@ import com.example.konnect_backend.domain.schedule.entity.Schedule; import com.example.konnect_backend.domain.schedule.entity.ScheduleAlarm; import com.example.konnect_backend.domain.schedule.entity.ScheduleRepeat; +import com.example.konnect_backend.domain.schedule.entity.status.RepeatEndType; +import com.example.konnect_backend.domain.schedule.entity.status.RepeatType; import com.example.konnect_backend.domain.schedule.repository.ScheduleAlarmRepository; import com.example.konnect_backend.domain.schedule.repository.ScheduleRepeatRepository; import com.example.konnect_backend.domain.schedule.repository.ScheduleRepository; @@ -21,50 +25,43 @@ import com.example.konnect_backend.global.security.SecurityUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.sql.Date; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.YearMonth; +import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; -import java.util.stream.IntStream; - @Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ScheduleService { - + + private static final int MAX_REPEAT_ITERATIONS = 1000; + private final ScheduleRepository scheduleRepository; private final ScheduleRepeatRepository scheduleRepeatRepository; private final ScheduleAlarmRepository scheduleAlarmRepository; private final UserRepository userRepository; private final ChildRepository childRepository; + // ==================== 생성/수정/삭제 ==================== + @Transactional public ScheduleResponse createSchedule(ScheduleCreateRequest request) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User currentUser = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - Child child = null; - if (request.getChildId() != null) { - child = childRepository.findById(request.getChildId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.CHILD_NOT_FOUND)); - - if (!child.getUser().getId().equals(userId)) { - throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); - } - } - + User currentUser = getCurrentUser(); + Child child = validateAndGetChild(request.getChildId(), currentUser.getId()); + Schedule schedule = Schedule.builder() .user(currentUser) .child(child) @@ -75,61 +72,21 @@ public ScheduleResponse createSchedule(ScheduleCreateRequest request) { .isAllDay(request.getIsAllDay()) .createdFromNotice(request.getCreatedFromNotice()) .build(); - + Schedule saved = scheduleRepository.save(schedule); - - // 반복 설정 저장 - if (request.getRepeat() != null) { - ScheduleRepeat repeat = ScheduleRepeat.builder() - .schedule(saved) - .user(currentUser) - .repeatType(request.getRepeat().getRepeatType()) - .repeatEndType(request.getRepeat().getRepeatEndType()) - .repeatEndDate(request.getRepeat().getRepeatEndDate()) - .repeatCount(request.getRepeat().getRepeatCount()) - .build(); - scheduleRepeatRepository.save(repeat); - } - - // 알림 설정 저장 - if (request.getAlarms() != null && !request.getAlarms().isEmpty()) { - List alarms = request.getAlarms().stream() - .map(alarmReq -> ScheduleAlarm.builder() - .schedule(saved) - .user(currentUser) - .alarmTimeType(alarmReq.getAlarmTimeType()) - .customMinutesBefore(alarmReq.getCustomMinutesBefore()) - .build()) - .collect(Collectors.toList()); - scheduleAlarmRepository.saveAll(alarms); - } - + + saveRepeatSetting(saved, request.getRepeat()); + saveAlarmSettings(saved, currentUser, request.getAlarms()); + return getScheduleWithDetails(saved.getScheduleId()); } - + @Transactional public ScheduleResponse updateSchedule(Long scheduleId, ScheduleUpdateRequest request) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User currentUser = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new GeneralException(ErrorStatus.SCHEDULE_NOT_FOUND)); - - if (!schedule.getUser().getId().equals(userId)) { - throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); - } - - Child child = null; - if (request.getChildId() != null) { - child = childRepository.findById(request.getChildId()) - .orElseThrow(() -> new GeneralException(ErrorStatus.CHILD_NOT_FOUND)); - - if (!child.getUser().getId().equals(userId)) { - throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); - } - } - + User currentUser = getCurrentUser(); + Schedule schedule = getScheduleWithOwnerCheck(scheduleId, currentUser.getId()); + Child child = validateAndGetChild(request.getChildId(), currentUser.getId()); + schedule.update( request.getTitle(), request.getMemo(), @@ -138,180 +95,402 @@ public ScheduleResponse updateSchedule(Long scheduleId, ScheduleUpdateRequest re request.getIsAllDay(), child ); - - // 기존 반복 설정 삭제 + + // 기존 설정 삭제 후 새로 저장 scheduleRepeatRepository.deleteBySchedule(schedule); - - // 새 반복 설정 저장 - if (request.getRepeat() != null) { - ScheduleRepeat repeat = ScheduleRepeat.builder() - .schedule(schedule) - .user(currentUser) - .repeatType(request.getRepeat().getRepeatType()) - .repeatEndType(request.getRepeat().getRepeatEndType()) - .repeatEndDate(request.getRepeat().getRepeatEndDate()) - .repeatCount(request.getRepeat().getRepeatCount()) - .build(); - scheduleRepeatRepository.save(repeat); - } - - // 기존 알림 설정 삭제 scheduleAlarmRepository.deleteBySchedule(schedule); - - // 새 알림 설정 저장 - if (request.getAlarms() != null && !request.getAlarms().isEmpty()) { - List alarms = request.getAlarms().stream() - .map(alarmReq -> ScheduleAlarm.builder() - .schedule(schedule) - .user(currentUser) - .alarmTimeType(alarmReq.getAlarmTimeType()) - .customMinutesBefore(alarmReq.getCustomMinutesBefore()) - .build()) - .collect(Collectors.toList()); - scheduleAlarmRepository.saveAll(alarms); - } - + + saveRepeatSetting(schedule, request.getRepeat()); + saveAlarmSettings(schedule, currentUser, request.getAlarms()); + return getScheduleWithDetails(schedule.getScheduleId()); } - + @Transactional public void deleteSchedule(Long scheduleId) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new GeneralException(ErrorStatus.SCHEDULE_NOT_FOUND)); - - if (!schedule.getUser().getId().equals(userId)) { - throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); - } - + User currentUser = getCurrentUser(); + Schedule schedule = getScheduleWithOwnerCheck(scheduleId, currentUser.getId()); scheduleRepository.delete(schedule); } - - public List getMonthlySchedules(int year, int month) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - YearMonth yearMonth = YearMonth.of(year, month); - LocalDateTime startDate = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59); - - List schedules = scheduleRepository.findByUserAndDateRange(user, startDate, endDate); - - return schedules.stream() - .map(ScheduleResponse::from) - .collect(Collectors.toList()); - } - - public List getRecentSchedules(int limit) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - LocalDateTime now = LocalDateTime.now(); - List schedules = scheduleRepository.findUpcomingSchedules(user, now); - - return schedules.stream() - .limit(limit) - .map(ScheduleResponse::from) - .collect(Collectors.toList()); - } - + + // ==================== 조회 ==================== + public ScheduleResponse getScheduleWithDetails(Long scheduleId) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - - Schedule schedule = scheduleRepository.findById(scheduleId) - .orElseThrow(() -> new GeneralException(ErrorStatus.SCHEDULE_NOT_FOUND)); - - if (!schedule.getUser().getId().equals(userId)) { - throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); - } - - ScheduleRepeatResponse repeat = scheduleRepeatRepository.findBySchedule(schedule) - .map(ScheduleRepeatResponse::from) - .orElse(null); - + User currentUser = getCurrentUser(); + Schedule schedule = getScheduleWithOwnerCheck(scheduleId, currentUser.getId()); + + ScheduleRepeatResponse repeat = schedule.getScheduleRepeat() != null + ? ScheduleRepeatResponse.from(schedule.getScheduleRepeat()) + : null; + List alarms = scheduleAlarmRepository.findBySchedule(schedule) .stream() .map(ScheduleAlarmResponse::from) .collect(Collectors.toList()); - + return ScheduleResponse.fromWithDetails(schedule, repeat, alarms); } - - public List getDailySchedules(LocalDate date) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - LocalDateTime dateTime = date.atStartOfDay(); - List schedules = scheduleRepository.findByUserAndDate(user, dateTime); - - return schedules.stream() - .map(ScheduleResponse::from) - .collect(Collectors.toList()); + + /** + * 월별 일정 조회 (반복 일정 확장 포함) + */ + public List getMonthlySchedules(int year, int month) { + User user = getCurrentUser(); + YearMonth yearMonth = YearMonth.of(year, month); + LocalDate rangeStart = yearMonth.atDay(1); + LocalDate rangeEnd = yearMonth.atEndOfMonth(); + + return getExpandedSchedulesForRange(user, rangeStart, rangeEnd); } - + + /** + * 주별 일정 조회 (반복 일정 확장 포함) + */ public List getWeeklySchedules(LocalDate startDate) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - + User user = getCurrentUser(); LocalDate weekStart = startDate.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate weekEnd = weekStart.plusDays(7); - - LocalDateTime startDateTime = weekStart.atStartOfDay(); - LocalDateTime endDateTime = weekEnd.atStartOfDay(); - - List schedules = scheduleRepository.findByUserAndWeek(user, startDateTime, endDateTime); - - return schedules.stream() - .map(ScheduleResponse::from) - .collect(Collectors.toList()); + LocalDate weekEnd = weekStart.plusDays(6); + + return getExpandedSchedulesForRange(user, weekStart, weekEnd); } - + + /** + * 일별 일정 조회 (반복 일정 확장 포함) + */ + public List getDailySchedules(LocalDate date) { + User user = getCurrentUser(); + return getExpandedSchedulesForRange(user, date, date); + } + + /** + * 오늘 일정 조회 (반복 일정 확장 포함) + */ public List getTodaySchedules() { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - List schedules = scheduleRepository.findTodaySchedules(user); - - return schedules.stream() - .map(ScheduleResponse::from) + User user = getCurrentUser(); + LocalDate today = LocalDate.now(); + return getExpandedSchedulesForRange(user, today, today); + } + + /** + * 최근 예정 일정 조회 (반복 일정 확장 포함) + */ + public List getRecentSchedules(int limit) { + User user = getCurrentUser(); + LocalDate today = LocalDate.now(); + LocalDate futureEnd = today.plusMonths(3); // 향후 3개월 + + List expanded = getExpandedSchedulesForRange(user, today, futureEnd); + + return expanded.stream() + .filter(s -> !s.getStartDate().toLocalDate().isBefore(today)) + .limit(limit) .collect(Collectors.toList()); } - + + /** + * 달력용 날짜별 일정 존재 여부 조회 (반복 일정 확장 포함) + */ public List getCalendarDates(int year, int month) { - Long userId = SecurityUtil.getCurrentUserIdOrNull(); - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - + User user = getCurrentUser(); YearMonth yearMonth = YearMonth.of(year, month); - LocalDateTime startDate = yearMonth.atDay(1).atStartOfDay(); - LocalDateTime endDate = yearMonth.atEndOfMonth().atTime(23, 59, 59); - - List scheduleCounts = scheduleRepository.findScheduleCountsByDate(user, startDate, endDate); - + LocalDate rangeStart = yearMonth.atDay(1); + LocalDate rangeEnd = yearMonth.atEndOfMonth(); + + Map dateCountMap = calculateScheduleCountByDate(user, rangeStart, rangeEnd); + List result = new ArrayList<>(); - for (int day = 1; day <= yearMonth.lengthOfMonth(); day++) { LocalDate date = yearMonth.atDay(day); - Integer count = scheduleCounts.stream() - .filter(obj -> { - if (obj[0] instanceof Date) { - return ((Date) obj[0]).toLocalDate().equals(date); - } - return false; - }) - .map(obj -> ((Number) obj[1]).intValue()) - .findFirst() - .orElse(0); - + Integer count = dateCountMap.getOrDefault(date, 0); result.add(CalendarDateResponse.of(date, count)); } - + + return result; + } + + // ==================== 반복 일정 확장 핵심 로직 ==================== + + /** + * 주어진 기간 내 모든 일정을 조회하고 반복 일정을 확장합니다. + * 단일 쿼리로 모든 데이터를 가져온 후 메모리에서 처리합니다. + */ + private List getExpandedSchedulesForRange(User user, LocalDate rangeStart, LocalDate rangeEnd) { + // Fetch Join으로 한 번에 조회 (N+1 방지) + List allSchedules = scheduleRepository.findAllByUserWithRepeat(user); + + List result = new ArrayList<>(); + + for (Schedule schedule : allSchedules) { + ScheduleRepeat repeat = schedule.getScheduleRepeat(); + + if (repeat == null) { + // 반복 없는 일정: 범위 내에 있으면 추가 + LocalDate scheduleDate = schedule.getStartDate().toLocalDate(); + if (isDateInRange(scheduleDate, rangeStart, rangeEnd)) { + result.add(ScheduleResponse.from(schedule)); + } + } else { + // 반복 일정: 범위 내 모든 날짜로 확장 + List expandedDates = expandRepeatSchedule(schedule, repeat, rangeStart, rangeEnd); + for (LocalDate date : expandedDates) { + result.add(createExpandedScheduleResponse(schedule, date)); + } + } + } + + // 시작 날짜 기준 정렬 + result.sort((a, b) -> a.getStartDate().compareTo(b.getStartDate())); return result; } -} \ No newline at end of file + + /** + * 날짜별 일정 개수를 계산합니다 (달력 표시용) + */ + private Map calculateScheduleCountByDate(User user, LocalDate rangeStart, LocalDate rangeEnd) { + List allSchedules = scheduleRepository.findAllByUserWithRepeat(user); + Map dateCountMap = new HashMap<>(); + + for (Schedule schedule : allSchedules) { + ScheduleRepeat repeat = schedule.getScheduleRepeat(); + + if (repeat == null) { + // 반복 없는 일정 + LocalDate scheduleDate = schedule.getStartDate().toLocalDate(); + if (isDateInRange(scheduleDate, rangeStart, rangeEnd)) { + dateCountMap.merge(scheduleDate, 1, Integer::sum); + } + } else { + // 반복 일정 확장 + List expandedDates = expandRepeatSchedule(schedule, repeat, rangeStart, rangeEnd); + for (LocalDate date : expandedDates) { + dateCountMap.merge(date, 1, Integer::sum); + } + } + } + + return dateCountMap; + } + + /** + * 반복 일정을 주어진 범위 내의 모든 날짜로 확장합니다. + */ + private List expandRepeatSchedule(Schedule schedule, ScheduleRepeat repeat, + LocalDate rangeStart, LocalDate rangeEnd) { + List expandedDates = new ArrayList<>(); + LocalDate scheduleStartDate = schedule.getStartDate().toLocalDate(); + RepeatType repeatType = repeat.getRepeatType(); + + // 반복 종료일 계산 + LocalDate effectiveEndDate = calculateEffectiveEndDate(repeat, rangeEnd); + + // rangeStart 이전의 날짜는 스킵하고 첫 번째 유효한 날짜부터 시작 + LocalDate currentDate = findFirstDateInRange(scheduleStartDate, rangeStart, repeatType); + + int iterationCount = 0; + + while (currentDate != null && !currentDate.isAfter(effectiveEndDate) && iterationCount < MAX_REPEAT_ITERATIONS) { + // COUNT 제한 확인 + if (repeat.getRepeatEndType() == RepeatEndType.COUNT && repeat.getRepeatCount() != null) { + long currentCount = calculateRepeatCount(scheduleStartDate, currentDate, repeatType); + if (currentCount >= repeat.getRepeatCount()) { + break; + } + } + + // 범위 내 날짜만 추가 + if (isDateInRange(currentDate, rangeStart, rangeEnd)) { + expandedDates.add(currentDate); + } + + currentDate = getNextRepeatDate(currentDate, repeatType, scheduleStartDate); + iterationCount++; + } + + return expandedDates; + } + + /** + * 반복 타입에 따라 범위 내 첫 번째 유효한 날짜를 찾습니다. + */ + private LocalDate findFirstDateInRange(LocalDate scheduleStart, LocalDate rangeStart, RepeatType repeatType) { + if (!scheduleStart.isBefore(rangeStart)) { + return scheduleStart; + } + + // rangeStart 이전에 시작한 반복 일정의 경우, rangeStart 이후 첫 번째 날짜 계산 + switch (repeatType) { + case DAILY: + return rangeStart; + case WEEKLY: + // 같은 요일 중 rangeStart 이후 첫 번째 날짜 + DayOfWeek targetDayOfWeek = scheduleStart.getDayOfWeek(); + return rangeStart.with(TemporalAdjusters.nextOrSame(targetDayOfWeek)); + case MONTHLY: + // 같은 일(day) 중 rangeStart 이후 첫 번째 날짜 + int targetDay = scheduleStart.getDayOfMonth(); + LocalDate candidate = rangeStart.withDayOfMonth(Math.min(targetDay, rangeStart.lengthOfMonth())); + if (candidate.isBefore(rangeStart)) { + candidate = candidate.plusMonths(1); + candidate = candidate.withDayOfMonth(Math.min(targetDay, candidate.lengthOfMonth())); + } + return candidate; + case YEARLY: + // 같은 월/일 중 rangeStart 이후 첫 번째 날짜 (윤년 처리 포함) + LocalDate yearlyCandidate = getValidYearlyDate(rangeStart.getYear(), scheduleStart); + if (yearlyCandidate.isBefore(rangeStart)) { + yearlyCandidate = getValidYearlyDate(rangeStart.getYear() + 1, scheduleStart); + } + return yearlyCandidate; + default: + return rangeStart; + } + } + + /** + * 반복 종료일을 계산합니다. + */ + private LocalDate calculateEffectiveEndDate(ScheduleRepeat repeat, LocalDate rangeEnd) { + if (repeat.getRepeatEndType() == RepeatEndType.UNTIL_DATE && repeat.getRepeatEndDate() != null) { + LocalDate repeatEndDate = repeat.getRepeatEndDate().toLocalDate(); + return repeatEndDate.isBefore(rangeEnd) ? repeatEndDate : rangeEnd; + } + return rangeEnd; + } + + /** + * 다음 반복 날짜를 계산합니다. + */ + private LocalDate getNextRepeatDate(LocalDate current, RepeatType repeatType, LocalDate originalStart) { + switch (repeatType) { + case DAILY: + return current.plusDays(1); + case WEEKLY: + return current.plusWeeks(1); + case MONTHLY: + int targetDay = originalStart.getDayOfMonth(); + LocalDate nextMonth = current.plusMonths(1); + int lastDayOfMonth = nextMonth.lengthOfMonth(); + return nextMonth.withDayOfMonth(Math.min(targetDay, lastDayOfMonth)); + case YEARLY: + // 윤년 처리: 2월 29일인 경우 다음 해 유효한 날짜로 조정 + return getValidYearlyDate(current.getYear() + 1, originalStart); + default: + return current.plusDays(1); + } + } + + /** + * 특정 연도에서 유효한 연간 반복 날짜를 반환합니다. + * 2월 29일이 윤년이 아닌 해에 해당하면 2월 28일로 조정합니다. + */ + private LocalDate getValidYearlyDate(int year, LocalDate originalDate) { + int targetMonth = originalDate.getMonthValue(); + int targetDay = originalDate.getDayOfMonth(); + + YearMonth yearMonth = YearMonth.of(year, targetMonth); + int lastDayOfMonth = yearMonth.lengthOfMonth(); + int validDay = Math.min(targetDay, lastDayOfMonth); + + return LocalDate.of(year, targetMonth, validDay); + } + + /** + * 시작일부터 대상일까지의 반복 횟수를 계산합니다 (1부터 시작). + * 첫 번째 발생(startDate)은 1로 계산됩니다. + */ + private long calculateRepeatCount(LocalDate startDate, LocalDate targetDate, RepeatType repeatType) { + long count = switch (repeatType) { + case DAILY -> ChronoUnit.DAYS.between(startDate, targetDate); + case WEEKLY -> ChronoUnit.WEEKS.between(startDate, targetDate); + case MONTHLY -> ChronoUnit.MONTHS.between(startDate, targetDate); + case YEARLY -> ChronoUnit.YEARS.between(startDate, targetDate); + }; + return count + 1; // 첫 번째 발생을 1로 계산 + } + + /** + * 반복 일정의 특정 날짜 인스턴스에 대한 응답을 생성합니다. + */ + private ScheduleResponse createExpandedScheduleResponse(Schedule schedule, LocalDate targetDate) { + LocalTime startTime = schedule.getStartDate().toLocalTime(); + LocalTime endTime = schedule.getEndDate() != null + ? schedule.getEndDate().toLocalTime() + : startTime; + + return ScheduleResponse.from(schedule).toBuilder() + .startDate(targetDate.atTime(startTime)) + .endDate(targetDate.atTime(endTime)) + .build(); + } + + // ==================== 헬퍼 메서드 ==================== + + private User getCurrentUser() { + Long userId = SecurityUtil.getCurrentUserIdOrNull(); + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + } + + private Schedule getScheduleWithOwnerCheck(Long scheduleId, Long userId) { + Schedule schedule = scheduleRepository.findById(scheduleId) + .orElseThrow(() -> new GeneralException(ErrorStatus.SCHEDULE_NOT_FOUND)); + + if (!schedule.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); + } + + return schedule; + } + + private Child validateAndGetChild(Long childId, Long userId) { + if (childId == null) { + return null; + } + + Child child = childRepository.findById(childId) + .orElseThrow(() -> new GeneralException(ErrorStatus.CHILD_NOT_FOUND)); + + if (!child.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.FORBIDDEN_ACCESS); + } + + return child; + } + + private void saveRepeatSetting(Schedule schedule, ScheduleRepeatRequest repeatRequest) { + if (repeatRequest == null) { + return; + } + + ScheduleRepeat repeat = ScheduleRepeat.builder() + .schedule(schedule) + .repeatType(repeatRequest.getRepeatType()) + .repeatEndType(repeatRequest.getRepeatEndType()) + .repeatEndDate(repeatRequest.getRepeatEndDate()) + .repeatCount(repeatRequest.getRepeatCount()) + .build(); + + scheduleRepeatRepository.save(repeat); + } + + private void saveAlarmSettings(Schedule schedule, User user, List alarmRequests) { + if (alarmRequests == null || alarmRequests.isEmpty()) { + return; + } + + List alarms = alarmRequests.stream() + .map(req -> ScheduleAlarm.builder() + .schedule(schedule) + .user(user) + .alarmTimeType(req.getAlarmTimeType()) + .customMinutesBefore(req.getCustomMinutesBefore()) + .build()) + .collect(Collectors.toList()); + + scheduleAlarmRepository.saveAll(alarms); + } + + private boolean isDateInRange(LocalDate date, LocalDate rangeStart, LocalDate rangeEnd) { + return !date.isBefore(rangeStart) && !date.isAfter(rangeEnd); + } +} diff --git a/src/main/java/com/example/konnect_backend/global/config/FcmConfig.java b/src/main/java/com/example/konnect_backend/global/config/FcmConfig.java new file mode 100644 index 0000000..8053ebc --- /dev/null +++ b/src/main/java/com/example/konnect_backend/global/config/FcmConfig.java @@ -0,0 +1,57 @@ +package com.example.konnect_backend.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +public class FcmConfig { + + @Value("${firebase.config-path:firebase/firebase-service-account.json}") + private String firebaseConfigPath; + + @PostConstruct + public void initialize() { + try { + if (FirebaseApp.getApps().isEmpty()) { + ClassPathResource resource = new ClassPathResource(firebaseConfigPath); + + if (!resource.exists()) { + log.warn("Firebase 설정 파일이 존재하지 않습니다: {}. FCM 기능이 비활성화됩니다.", firebaseConfigPath); + return; + } + + try (InputStream serviceAccount = resource.getInputStream()) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + log.info("Firebase 초기화 완료"); + } + } + } catch (IOException e) { + log.error("Firebase 초기화 실패: {}", e.getMessage()); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + if (FirebaseApp.getApps().isEmpty()) { + log.warn("FirebaseApp이 초기화되지 않아 FirebaseMessaging Bean을 생성할 수 없습니다."); + return null; + } + return FirebaseMessaging.getInstance(); + } +} diff --git a/src/main/resources/db/migration/V13__Add_notification_and_fcm_token.sql b/src/main/resources/db/migration/V13__Add_notification_and_fcm_token.sql new file mode 100644 index 0000000..dfc982a --- /dev/null +++ b/src/main/resources/db/migration/V13__Add_notification_and_fcm_token.sql @@ -0,0 +1,33 @@ +-- Notification 테이블 생성 +CREATE TABLE IF NOT EXISTS notification ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + body VARCHAR(500) NOT NULL, + type VARCHAR(50) NOT NULL, + reference_id BIGINT, + is_read BOOLEAN NOT NULL DEFAULT FALSE, + is_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME(6), + updated_at DATETIME(6), + + CONSTRAINT fk_notification_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, + INDEX idx_notification_user_read (user_id, is_read), + INDEX idx_notification_user_created (user_id, created_at DESC) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- FCM Token 테이블 생성 +CREATE TABLE IF NOT EXISTS fcm_token ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + token VARCHAR(500) NOT NULL, + device_id VARCHAR(255), + device_type VARCHAR(50), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME(6), + updated_at DATETIME(6), + + CONSTRAINT fk_fcm_token_user FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, + CONSTRAINT uk_fcm_token UNIQUE (token), + INDEX idx_fcm_token_user (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/test/java/com/example/konnect_backend/domain/schedule/service/ScheduleServiceTest.java b/src/test/java/com/example/konnect_backend/domain/schedule/service/ScheduleServiceTest.java new file mode 100644 index 0000000..aa9812e --- /dev/null +++ b/src/test/java/com/example/konnect_backend/domain/schedule/service/ScheduleServiceTest.java @@ -0,0 +1,600 @@ +package com.example.konnect_backend.domain.schedule.service; + +import com.example.konnect_backend.domain.schedule.dto.request.ScheduleCreateRequest; +import com.example.konnect_backend.domain.schedule.dto.request.ScheduleRepeatRequest; +import com.example.konnect_backend.domain.schedule.dto.response.CalendarDateResponse; +import com.example.konnect_backend.domain.schedule.dto.response.ScheduleResponse; +import com.example.konnect_backend.domain.schedule.entity.Schedule; +import com.example.konnect_backend.domain.schedule.entity.ScheduleRepeat; +import com.example.konnect_backend.domain.schedule.entity.status.RepeatEndType; +import com.example.konnect_backend.domain.schedule.entity.status.RepeatType; +import com.example.konnect_backend.domain.schedule.repository.ScheduleAlarmRepository; +import com.example.konnect_backend.domain.schedule.repository.ScheduleRepeatRepository; +import com.example.konnect_backend.domain.schedule.repository.ScheduleRepository; +import com.example.konnect_backend.domain.user.entity.User; +import com.example.konnect_backend.domain.user.repository.ChildRepository; +import com.example.konnect_backend.domain.user.repository.UserRepository; +import com.example.konnect_backend.global.security.SecurityUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ScheduleService 테스트") +class ScheduleServiceTest { + + @Mock + private ScheduleRepository scheduleRepository; + + @Mock + private ScheduleRepeatRepository scheduleRepeatRepository; + + @Mock + private ScheduleAlarmRepository scheduleAlarmRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ChildRepository childRepository; + + @InjectMocks + private ScheduleService scheduleService; + + private User testUser; + private Schedule testSchedule; + + @BeforeEach + void setUp() { + testUser = User.builder() + .id(1L) + .name("테스트유저") + .build(); + + // 반복 없는 일반 일정 (12월 5일) + testSchedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("일반 일정") + .startDate(LocalDateTime.of(2025, 12, 5, 10, 0)) + .endDate(LocalDateTime.of(2025, 12, 5, 11, 0)) + .isAllDay(false) + .build(); + } + + @Nested + @DisplayName("달력 조회 (getCalendarDates)") + class GetCalendarDatesTest { + + @Test + @DisplayName("반복 없는 일정 - 해당 날짜에만 표시") + void noRepeatSchedule_showsOnlyOnStartDate() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("일반 일정") + .startDate(LocalDateTime.of(2025, 12, 15, 10, 0)) + .endDate(LocalDateTime.of(2025, 12, 15, 11, 0)) + .scheduleRepeat(null) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getCalendarDates(2025, 12); + + // then + assertThat(result).hasSize(31); // 12월은 31일 + + // 15일만 일정 있음 + CalendarDateResponse day15 = result.stream() + .filter(r -> r.getDate().getDayOfMonth() == 15) + .findFirst().orElseThrow(); + assertThat(day15.getHasSchedule()).isTrue(); + assertThat(day15.getScheduleCount()).isEqualTo(1); + + // 다른 날짜는 일정 없음 + CalendarDateResponse day10 = result.stream() + .filter(r -> r.getDate().getDayOfMonth() == 10) + .findFirst().orElseThrow(); + assertThat(day10.getHasSchedule()).isFalse(); + assertThat(day10.getScheduleCount()).isEqualTo(0); + } + } + + @Test + @DisplayName("매주 반복 일정 - 같은 요일에 모두 표시 (수요일)") + void weeklyRepeat_showsOnSameDayOfWeek() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 3일 수요일 시작, 매주 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.WEEKLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매주 수요일 회의") + .startDate(LocalDateTime.of(2025, 12, 3, 10, 0)) // 수요일 + .endDate(LocalDateTime.of(2025, 12, 3, 11, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getCalendarDates(2025, 12); + + // then + // 12월의 수요일: 3, 10, 17, 24, 31일 + List wednesdaysInDec = List.of(3, 10, 17, 24, 31); + + for (CalendarDateResponse response : result) { + int day = response.getDate().getDayOfMonth(); + if (wednesdaysInDec.contains(day)) { + assertThat(response.getHasSchedule()) + .as("12월 %d일(수요일)에 일정이 있어야 함", day) + .isTrue(); + } else { + assertThat(response.getHasSchedule()) + .as("12월 %d일에는 일정이 없어야 함", day) + .isFalse(); + } + } + } + } + + @Test + @DisplayName("매달 반복 일정 - 같은 날짜에 표시 (매달 15일)") + void monthlyRepeat_showsOnSameDayOfMonth() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 11월 15일 시작, 매달 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.MONTHLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매달 15일 급여일") + .startDate(LocalDateTime.of(2025, 11, 15, 10, 0)) // 11월 15일 시작 + .endDate(LocalDateTime.of(2025, 11, 15, 11, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getCalendarDates(2025, 12); + + // then + // 12월 15일에만 일정이 있어야 함 + CalendarDateResponse day15 = result.stream() + .filter(r -> r.getDate().getDayOfMonth() == 15) + .findFirst().orElseThrow(); + assertThat(day15.getHasSchedule()).isTrue(); + assertThat(day15.getScheduleCount()).isEqualTo(1); + + // 다른 날짜는 일정 없음 + long daysWithSchedule = result.stream() + .filter(CalendarDateResponse::getHasSchedule) + .count(); + assertThat(daysWithSchedule).isEqualTo(1); + } + } + + @Test + @DisplayName("매일 반복 일정 - 기간 내 모든 날짜에 표시") + void dailyRepeat_showsOnEveryDay() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 1일 시작, 매일 반복, 12월 10일까지 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.DAILY) + .repeatEndType(RepeatEndType.UNTIL_DATE) + .repeatEndDate(LocalDateTime.of(2025, 12, 10, 23, 59)) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매일 운동") + .startDate(LocalDateTime.of(2025, 12, 1, 7, 0)) + .endDate(LocalDateTime.of(2025, 12, 1, 8, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getCalendarDates(2025, 12); + + // then + // 12월 1일~10일에만 일정이 있어야 함 + for (CalendarDateResponse response : result) { + int day = response.getDate().getDayOfMonth(); + if (day >= 1 && day <= 10) { + assertThat(response.getHasSchedule()) + .as("12월 %d일에 일정이 있어야 함", day) + .isTrue(); + } else { + assertThat(response.getHasSchedule()) + .as("12월 %d일에는 일정이 없어야 함", day) + .isFalse(); + } + } + } + } + + @Test + @DisplayName("반복 횟수 제한 - COUNT 만큼만 표시") + void repeatWithCount_showsLimitedTimes() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 3일 수요일 시작, 매주 반복, 3회만 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.WEEKLY) + .repeatEndType(RepeatEndType.COUNT) + .repeatCount(3L) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매주 수요일 회의 (3회)") + .startDate(LocalDateTime.of(2025, 12, 3, 10, 0)) + .endDate(LocalDateTime.of(2025, 12, 3, 11, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getCalendarDates(2025, 12); + + // then + // 12월 3, 10, 17일에만 일정이 있어야 함 (3회) + List expectedDays = List.of(3, 10, 17); + long daysWithSchedule = result.stream() + .filter(CalendarDateResponse::getHasSchedule) + .count(); + assertThat(daysWithSchedule).isEqualTo(3); + + for (CalendarDateResponse response : result) { + int day = response.getDate().getDayOfMonth(); + if (expectedDays.contains(day)) { + assertThat(response.getHasSchedule()).isTrue(); + } + } + } + } + } + + @Nested + @DisplayName("일별 일정 조회 (getDailySchedules)") + class GetDailySchedulesTest { + + @Test + @DisplayName("반복 일정이 해당 날짜에 확장되어 조회됨") + void repeatSchedule_expandedToTargetDate() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 3일 수요일 시작, 매주 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.WEEKLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매주 수요일 회의") + .startDate(LocalDateTime.of(2025, 12, 3, 14, 0)) // 오후 2시 + .endDate(LocalDateTime.of(2025, 12, 3, 15, 0)) // 오후 3시 + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when - 12월 10일 (수요일) 조회 + List result = scheduleService.getDailySchedules(LocalDate.of(2025, 12, 10)); + + // then + assertThat(result).hasSize(1); + + ScheduleResponse response = result.get(0); + assertThat(response.getTitle()).isEqualTo("매주 수요일 회의"); + // 날짜는 조회한 날짜(12월 10일)로 변경되어야 함 + assertThat(response.getStartDate().toLocalDate()) + .isEqualTo(LocalDate.of(2025, 12, 10)); + // 시간은 원래 일정 시간 유지 + assertThat(response.getStartDate().getHour()).isEqualTo(14); + assertThat(response.getEndDate().getHour()).isEqualTo(15); + } + } + + @Test + @DisplayName("해당 요일이 아닌 날짜 조회 시 빈 결과") + void wrongDayOfWeek_returnsEmpty() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 3일 수요일 시작, 매주 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.WEEKLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매주 수요일 회의") + .startDate(LocalDateTime.of(2025, 12, 3, 14, 0)) + .endDate(LocalDateTime.of(2025, 12, 3, 15, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when - 12월 11일 (목요일) 조회 + List result = scheduleService.getDailySchedules(LocalDate.of(2025, 12, 11)); + + // then + assertThat(result).isEmpty(); + } + } + } + + @Nested + @DisplayName("월별 일정 조회 (getMonthlySchedules)") + class GetMonthlySchedulesTest { + + @Test + @DisplayName("매주 반복 일정이 해당 월의 모든 요일에 확장됨") + void weeklyRepeat_expandedToAllWeeksInMonth() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 12월 3일 수요일 시작, 매주 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.WEEKLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("매주 수요일 회의") + .startDate(LocalDateTime.of(2025, 12, 3, 14, 0)) + .endDate(LocalDateTime.of(2025, 12, 3, 15, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when + List result = scheduleService.getMonthlySchedules(2025, 12); + + // then + // 12월의 수요일: 3, 10, 17, 24, 31일 = 5개 + assertThat(result).hasSize(5); + + List expectedDays = List.of(3, 10, 17, 24, 31); + List actualDays = result.stream() + .map(r -> r.getStartDate().getDayOfMonth()) + .toList(); + + assertThat(actualDays).containsExactlyElementsOf(expectedDays); + } + } + } + + @Nested + @DisplayName("매년 반복 일정 (YEARLY)") + class YearlyRepeatTest { + + @Test + @DisplayName("매년 반복 일정 - 같은 월/일에 표시") + void yearlyRepeat_showsOnSameDateEveryYear() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 2024년 12월 25일 시작, 매년 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.YEARLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("크리스마스") + .startDate(LocalDateTime.of(2024, 12, 25, 0, 0)) + .endDate(LocalDateTime.of(2024, 12, 25, 23, 59)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when - 2025년 12월 조회 + List result = scheduleService.getCalendarDates(2025, 12); + + // then + // 12월 25일에만 일정이 있어야 함 + CalendarDateResponse day25 = result.stream() + .filter(r -> r.getDate().getDayOfMonth() == 25) + .findFirst().orElseThrow(); + assertThat(day25.getHasSchedule()).isTrue(); + assertThat(day25.getScheduleCount()).isEqualTo(1); + + // 다른 날짜는 일정 없음 + long daysWithSchedule = result.stream() + .filter(CalendarDateResponse::getHasSchedule) + .count(); + assertThat(daysWithSchedule).isEqualTo(1); + } + } + + @Test + @DisplayName("윤년 2월 29일 - 평년에는 2월 28일로 표시") + void leapYearDate_adjustedToValidDate() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 2024년 2월 29일 시작 (윤년), 매년 반복 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.YEARLY) + .repeatEndType(RepeatEndType.FOREVER) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("윤년 생일") + .startDate(LocalDateTime.of(2024, 2, 29, 10, 0)) + .endDate(LocalDateTime.of(2024, 2, 29, 11, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when - 2025년 2월 조회 (평년) + List result = scheduleService.getCalendarDates(2025, 2); + + // then + // 2월 28일에 일정이 있어야 함 (29일이 없으므로) + CalendarDateResponse day28 = result.stream() + .filter(r -> r.getDate().getDayOfMonth() == 28) + .findFirst().orElseThrow(); + assertThat(day28.getHasSchedule()).isTrue(); + + // 2025년 2월은 28일까지만 있음 + assertThat(result).hasSize(28); + } + } + + @Test + @DisplayName("매년 반복 - 횟수 제한 적용") + void yearlyRepeat_withCountLimit() { + try (MockedStatic securityUtil = mockStatic(SecurityUtil.class)) { + // given + securityUtil.when(SecurityUtil::getCurrentUserIdOrNull).thenReturn(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(testUser)); + + // 2024년 12월 25일 시작, 매년 반복, 2회만 + ScheduleRepeat repeat = ScheduleRepeat.builder() + .id(1L) + .repeatType(RepeatType.YEARLY) + .repeatEndType(RepeatEndType.COUNT) + .repeatCount(2L) + .build(); + + Schedule schedule = Schedule.builder() + .scheduleId(1L) + .user(testUser) + .title("한정 이벤트") + .startDate(LocalDateTime.of(2024, 12, 25, 10, 0)) + .endDate(LocalDateTime.of(2024, 12, 25, 11, 0)) + .scheduleRepeat(repeat) + .build(); + + when(scheduleRepository.findAllByUserWithRepeat(testUser)) + .thenReturn(List.of(schedule)); + + // when - 2024년 12월 조회 (첫 번째) + List result2024 = scheduleService.getCalendarDates(2024, 12); + + // then - 2024년에는 있어야 함 + CalendarDateResponse day25_2024 = result2024.stream() + .filter(r -> r.getDate().getDayOfMonth() == 25) + .findFirst().orElseThrow(); + assertThat(day25_2024.getHasSchedule()).isTrue(); + + // when - 2025년 12월 조회 (두 번째) + List result2025 = scheduleService.getCalendarDates(2025, 12); + + // then - 2025년에도 있어야 함 (2회째) + CalendarDateResponse day25_2025 = result2025.stream() + .filter(r -> r.getDate().getDayOfMonth() == 25) + .findFirst().orElseThrow(); + assertThat(day25_2025.getHasSchedule()).isTrue(); + + // when - 2026년 12월 조회 (세 번째 - 초과) + List result2026 = scheduleService.getCalendarDates(2026, 12); + + // then - 2026년에는 없어야 함 (2회 초과) + CalendarDateResponse day25_2026 = result2026.stream() + .filter(r -> r.getDate().getDayOfMonth() == 25) + .findFirst().orElseThrow(); + assertThat(day25_2026.getHasSchedule()).isFalse(); + } + } + } +}