diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 2b58856f0..3fe1279f7 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -36,6 +36,11 @@ jobs: run: | cd ./backend/src/main/resources echo "${{ secrets.APPLICATION_PR_TEST }}" > ./application.properties + + - name: make firebase.json + run: | + cd ./backend/src/main/resources + echo "${{ secrets.FIREBASE_PR_TEST }}" | base64 -d > ./firebase.json - name: create-json id: create-json diff --git a/backend/.gitignore b/backend/.gitignore index 639bf54f5..398e4f826 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -39,3 +39,4 @@ out/ application.properties moadong.json +firebase.json \ No newline at end of file diff --git a/backend/build.gradle b/backend/build.gradle index 09c7239f9..f0927d29f 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.retry:spring-retry' implementation 'com.google.cloud:spring-cloud-gcp-storage:5.8.0' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' @@ -56,7 +58,7 @@ dependencies { implementation 'net.coobird:thumbnailator:0.4.14' implementation 'org.springframework:spring-test' - + implementation 'com.google.firebase:firebase-admin:9.7.0' } diff --git a/backend/src/main/java/moadong/MoadongApplication.java b/backend/src/main/java/moadong/MoadongApplication.java index 21e4067a2..09c1e9afe 100644 --- a/backend/src/main/java/moadong/MoadongApplication.java +++ b/backend/src/main/java/moadong/MoadongApplication.java @@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) @RequiredArgsConstructor @EnableScheduling +@EnableRetry public class MoadongApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index b527279dd..dea593e3c 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -1,10 +1,18 @@ package moadong.club.entity; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Locale; import java.util.Map; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; import moadong.club.payload.request.ClubInfoRequest; @@ -17,6 +25,7 @@ import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; +@Slf4j @Document("clubs") @AllArgsConstructor @Getter @@ -119,6 +128,14 @@ public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus) this.clubRecruitmentInformation.updateRecruitmentStatus(clubRecruitmentStatus); } + public void sendPushNotification(Message message) { + try { + FirebaseMessaging.getInstance().send(message); + } catch (FirebaseMessagingException e) { + log.error("FirebaseSendNotificationError: {}", e.getMessage()); + } + } + @Override public boolean isNew() { return this.version == null; diff --git a/backend/src/main/java/moadong/club/repository/ClubRepository.java b/backend/src/main/java/moadong/club/repository/ClubRepository.java index 721fcad1d..ab40067bf 100644 --- a/backend/src/main/java/moadong/club/repository/ClubRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubRepository.java @@ -14,4 +14,6 @@ public interface ClubRepository extends MongoRepository { Optional findClubByUserId(String userId); List findAllByName(List clubs); + + Long countByIdIn(List id); } diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java index 99f93b0d6..06baca736 100644 --- a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -1,8 +1,16 @@ package moadong.club.service; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Locale; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import moadong.club.entity.Club; import moadong.club.entity.ClubRecruitmentInformation; import moadong.club.enums.ClubRecruitmentStatus; @@ -12,6 +20,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor @ConditionalOnProperty(name = "scheduling.enabled", havingValue = "true", matchIfMissing = true) @@ -19,7 +28,7 @@ public class RecruitmentStateChecker { private final ClubRepository clubRepository; - @Scheduled(fixedRate = 60 * 60 * 1000) // 5분마다 실행 + @Scheduled(fixedRate = 60 * 60 * 1000) // 1시간마다 실행 public void performTask() { List clubs = clubRepository.findAll(); for (Club club : clubs) { @@ -30,6 +39,7 @@ public void performTask() { continue; } RecruitmentStateCalculator.calculate(club, recruitmentStartDate, recruitmentEndDate); + clubRepository.save(club); } } diff --git a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java index 4d2743dad..8e2be5f56 100644 --- a/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java +++ b/backend/src/main/java/moadong/club/util/RecruitmentStateCalculator.java @@ -2,36 +2,75 @@ import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; +import java.util.Locale; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; import moadong.club.enums.ClubRecruitmentStatus; public class RecruitmentStateCalculator { public static final int ALWAYS_RECRUIT_YEAR = 2999; public static void calculate(Club club, ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) { + ClubRecruitmentStatus newStatus = calculateRecruitmentStatus(recruitmentStartDate, recruitmentEndDate); + club.updateRecruitmentStatus(newStatus); + + Message message = buildRecruitmentMessage(club, newStatus); + club.sendPushNotification(message); + } + + public static ClubRecruitmentStatus calculateRecruitmentStatus(ZonedDateTime recruitmentStartDate, ZonedDateTime recruitmentEndDate) { if (recruitmentStartDate == null || recruitmentEndDate == null) { - club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); - return; + return ClubRecruitmentStatus.CLOSED; } + if (recruitmentEndDate.getYear() == ALWAYS_RECRUIT_YEAR) { - club.updateRecruitmentStatus(ClubRecruitmentStatus.ALWAYS); - return; + return ClubRecruitmentStatus.ALWAYS; } - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); if (now.isBefore(recruitmentStartDate)) { - long between = ChronoUnit.DAYS.between(recruitmentStartDate, now); - if (between <= 14) { - club.updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); - } else { - club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); - } - } else if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { - club.updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); - } else if (now.isAfter(recruitmentEndDate)) { - club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + long daysUntilStart = ChronoUnit.DAYS.between(now, recruitmentStartDate); + return (daysUntilStart <= 14) + ? ClubRecruitmentStatus.UPCOMING + : ClubRecruitmentStatus.CLOSED; } + + if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { + return ClubRecruitmentStatus.OPEN; + } + + return ClubRecruitmentStatus.CLOSED; + } + + public static Message buildRecruitmentMessage(Club club, ClubRecruitmentStatus status) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M월 d일 a h시 m분", Locale.KOREAN); + ClubRecruitmentInformation info = club.getClubRecruitmentInformation(); + + String bodyMessage = switch (status) { + case ALWAYS -> "상시 모집 중입니다. 언제든지 지원해주세요!"; + case OPEN -> { + String formattedEndTime = info.getRecruitmentEnd().format(formatter); + yield formattedEndTime + "까지 모집 중이니 서둘러 지원하세요!"; + } + case UPCOMING -> { + String formattedStartTime = info.getRecruitmentStart().format(formatter); + yield formattedStartTime + "부터 모집이 시작될 예정이에요. 조금만 기다려주세요!"; + } + case CLOSED -> "모집이 마감되었습니다. 다음 모집을 기대해주세요."; + }; + + return Message.builder() + .setNotification(Notification.builder() + .setTitle(club.getName()) + .setBody(bodyMessage) + .build()) + .setTopic(club.getId()) + .build(); } } diff --git a/backend/src/main/java/moadong/fcm/controller/FcmController.java b/backend/src/main/java/moadong/fcm/controller/FcmController.java new file mode 100644 index 000000000..d01ac5b4d --- /dev/null +++ b/backend/src/main/java/moadong/fcm/controller/FcmController.java @@ -0,0 +1,42 @@ +package moadong.fcm.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import moadong.fcm.payload.request.ClubSubscribeRequest; +import moadong.fcm.payload.request.FcmSaveRequest; +import moadong.fcm.service.FcmService; +import moadong.global.payload.Response; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/fcm") +@AllArgsConstructor +@Tag(name = "FCM", description = "FCM 토큰 관리 및 알림 전송 기능 API") +public class FcmController { + private final FcmService fcmService; + + @Operation(summary = "FCM 토큰 저장", description = "FCM 토큰을 서버에 저장합니다.") + @PostMapping + public ResponseEntity saveFcmToken(@RequestBody @Validated FcmSaveRequest request) { + fcmService.saveFcmToken(request.fcmToken()); + return Response.ok("success save fcm token"); + } + + @Operation(summary = "동아리 모집정보 알림 구독", description = "특정 동아리들의 모집 정보가 변경되면저거 하면 알림을 받도록 구독합니다.") + @PutMapping("/subscribe") + public ResponseEntity subscribeRecruitment(@RequestBody @Validated ClubSubscribeRequest request) { + fcmService.subscribeClubs(request.fcmToken(), request.clubIds()); + return Response.ok("success subscribe club"); + } + + @Operation(summary = "구독한 동아리 목록 조회", description = "FCM 토큰을 기준으로 구독중인 동아리 목록을 조회합니다.") + @GetMapping("/subscribe") + public ResponseEntity getSubscribedClubs(@RequestParam("fcmToken") + @Validated @NotNull String fcmToken) { + return Response.ok(fcmService.getSubscribeClubs(fcmToken)); + } +} diff --git a/backend/src/main/java/moadong/fcm/entity/FcmToken.java b/backend/src/main/java/moadong/fcm/entity/FcmToken.java new file mode 100644 index 000000000..8d612102f --- /dev/null +++ b/backend/src/main/java/moadong/fcm/entity/FcmToken.java @@ -0,0 +1,40 @@ +package moadong.fcm.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Document("fcm_tokens") +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Builder +public class FcmToken { + + @Id + private String id; + + private String token; + + @Builder.Default + private List clubIds = new ArrayList<>(); + + @Builder.Default + private LocalDateTime timestamp = LocalDateTime.now(); + + public void updateTimestamp() { + this.timestamp = LocalDateTime.now(); + } + + public void updateClubIds(List clubIds) { + this.clubIds.clear(); + this.clubIds.addAll(clubIds); + } +} diff --git a/backend/src/main/java/moadong/fcm/payload/request/ClubSubscribeRequest.java b/backend/src/main/java/moadong/fcm/payload/request/ClubSubscribeRequest.java new file mode 100644 index 000000000..e28482373 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/payload/request/ClubSubscribeRequest.java @@ -0,0 +1,13 @@ +package moadong.fcm.payload.request; + +import jakarta.validation.constraints.NotNull; + +import java.util.ArrayList; + +public record ClubSubscribeRequest( + @NotNull + String fcmToken, + @NotNull + ArrayList clubIds +) { +} diff --git a/backend/src/main/java/moadong/fcm/payload/request/FcmSaveRequest.java b/backend/src/main/java/moadong/fcm/payload/request/FcmSaveRequest.java new file mode 100644 index 000000000..d7284bea2 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/payload/request/FcmSaveRequest.java @@ -0,0 +1,9 @@ +package moadong.fcm.payload.request; + +import jakarta.validation.constraints.NotNull; + +public record FcmSaveRequest( + @NotNull + String fcmToken +) { +} diff --git a/backend/src/main/java/moadong/fcm/payload/response/ClubSubscribeListResponse.java b/backend/src/main/java/moadong/fcm/payload/response/ClubSubscribeListResponse.java new file mode 100644 index 000000000..fcc2c9f92 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/payload/response/ClubSubscribeListResponse.java @@ -0,0 +1,8 @@ +package moadong.fcm.payload.response; + +import java.util.List; + +public record ClubSubscribeListResponse( + List clubIds +) { +} diff --git a/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java new file mode 100644 index 000000000..3a10ff83c --- /dev/null +++ b/backend/src/main/java/moadong/fcm/repository/FcmTokenRepository.java @@ -0,0 +1,10 @@ +package moadong.fcm.repository; + +import moadong.fcm.entity.FcmToken; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.Optional; + +public interface FcmTokenRepository extends MongoRepository { + Optional findFcmTokenByToken(String fcmToken); +} diff --git a/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java new file mode 100644 index 000000000..064fa51c7 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/service/FcmAsyncService.java @@ -0,0 +1,86 @@ +package moadong.fcm.service; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.TopicManagementResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmAsyncService { + + private final FcmTxService fcmTxService; + + private final FirebaseMessaging firebaseMessaging; + + @Value("${fcm.topic.timeout-seconds:5}") + private int timeoutSeconds; + + @Async("fcmAsync") + public CompletableFuture updateSubscriptions(String token, Set newClubIds, Set clubsToSubscribe, Set clubsToUnsubscribe) { + List> futures = new ArrayList<>(); + + // 새로운 동아리 구독 + if (!clubsToSubscribe.isEmpty()) { + for (String clubId : clubsToSubscribe) { + futures.add(firebaseMessaging.subscribeToTopicAsync(Collections.singletonList(token), clubId)); + } + } + + // 더 이상 구독하지 않는 동아리 구독 해제 + if (!clubsToUnsubscribe.isEmpty()) { + for (String clubId : clubsToUnsubscribe) { + futures.add(firebaseMessaging.unsubscribeFromTopicAsync(Collections.singletonList(token), clubId)); + } + } + + try { + if (futures.isEmpty()) return CompletableFuture.completedFuture(null); + + List responses = ApiFutures.allAsList(futures).get(timeoutSeconds, TimeUnit.SECONDS); + + for (TopicManagementResponse response : responses) { + if (response.getFailureCount() > 0) { + boolean notRegistered = response.getErrors().stream() + .anyMatch(e -> "registration-token-not-registered".equals(e.getReason())); + + if (notRegistered) { + fcmTxService.deleteUnregisteredFcmToken(token); + return CompletableFuture.completedFuture(null); + } + + log.error("FCM topic sub failed for {}. errors={}", token, response.getErrors()); + throw new RestApiException(ErrorCode.FCMTOKEN_SUBSCRIBE_ERROR); + } + } + + fcmTxService.updateFcmToken(token, newClubIds); + + } catch (ExecutionException | TimeoutException e) { + log.error("error: {}", e.getMessage()); + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + + return CompletableFuture.completedFuture(null); + } +} diff --git a/backend/src/main/java/moadong/fcm/service/FcmService.java b/backend/src/main/java/moadong/fcm/service/FcmService.java new file mode 100644 index 000000000..62b913d04 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/service/FcmService.java @@ -0,0 +1,72 @@ +package moadong.fcm.service; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.club.repository.ClubRepository; +import moadong.fcm.entity.FcmToken; +import moadong.fcm.payload.response.ClubSubscribeListResponse; +import moadong.fcm.repository.FcmTokenRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@AllArgsConstructor +public class FcmService { + private final FcmTokenRepository fcmTokenRepository; + private final FcmAsyncService fcmAsyncService; + private final ClubRepository clubRepository; + + public void saveFcmToken(String token) { + FcmToken existToken = fcmTokenRepository.findFcmTokenByToken(token).orElse(null); + + if (existToken == null) { + FcmToken fcmToken = FcmToken.builder() + .token(token) + .build(); + + fcmTokenRepository.save(fcmToken); + return; + } + + existToken.updateTimestamp(); + fcmTokenRepository.save(existToken); + } + + public void subscribeClubs(String token, ArrayList newClubIds) { + FcmToken existToken = fcmTokenRepository.findFcmTokenByToken(token) + .orElseThrow(() -> new RestApiException(ErrorCode.FCMTOKEN_NOT_FOUND)); + + Set newClubIdSet = Set.copyOf(newClubIds); + Set oldClubIdSet = Set.copyOf(existToken.getClubIds()); + + Set clubsToSubscribe = new HashSet<>(newClubIdSet); + clubsToSubscribe.removeAll(oldClubIdSet); + + Set clubsToUnsubscribe = new HashSet<>(oldClubIdSet); + clubsToUnsubscribe.removeAll(newClubIdSet); + + if (!clubsToSubscribe.isEmpty()) { + Long countClub = clubRepository.countByIdIn(clubsToSubscribe.stream().toList()); + + if (countClub != clubsToSubscribe.size()) { + throw new RestApiException(ErrorCode.CLUB_NOT_FOUND); + } + } + + fcmAsyncService.updateSubscriptions(token, newClubIdSet, clubsToSubscribe, clubsToUnsubscribe) + .exceptionally(ex -> { + log.error("FCM Token subscription error: {}", ex.getMessage()); + return null; + }); + } + + public ClubSubscribeListResponse getSubscribeClubs(String token) { + FcmToken existToken = fcmTokenRepository.findFcmTokenByToken(token).orElseThrow(() -> new RestApiException(ErrorCode.FCMTOKEN_NOT_FOUND)); + + return new ClubSubscribeListResponse(existToken.getClubIds()); + } +} diff --git a/backend/src/main/java/moadong/fcm/service/FcmTxService.java b/backend/src/main/java/moadong/fcm/service/FcmTxService.java new file mode 100644 index 000000000..1adc1d1d6 --- /dev/null +++ b/backend/src/main/java/moadong/fcm/service/FcmTxService.java @@ -0,0 +1,44 @@ +package moadong.fcm.service; + +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import moadong.fcm.entity.FcmToken; +import moadong.fcm.repository.FcmTokenRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import org.springframework.dao.DataAccessException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.util.Set; + +@Slf4j +@Service +@Retryable( + retryFor = DataAccessException.class, + maxAttempts = 2, + backoff = @Backoff(delay = 100) +) +@AllArgsConstructor +public class FcmTxService { + + private final FcmTokenRepository fcmTokenRepository; + + @Transactional + public void deleteUnregisteredFcmToken(String token) { + fcmTokenRepository.findFcmTokenByToken(token).ifPresent(t -> { + fcmTokenRepository.delete(t); + log.info("Deleted unregistered FCM token {}", token); + }); + } + + @Transactional + public void updateFcmToken(String token, Set newClubIds) { + FcmToken fcmToken = fcmTokenRepository.findFcmTokenByToken(token).orElseThrow(() -> new RestApiException(ErrorCode.FCMTOKEN_NOT_FOUND)); + fcmToken.updateClubIds(newClubIds.stream().toList()); + + fcmTokenRepository.save(fcmToken); + } +} diff --git a/backend/src/main/java/moadong/global/config/AsyncConfig.java b/backend/src/main/java/moadong/global/config/AsyncConfig.java new file mode 100644 index 000000000..228d8b549 --- /dev/null +++ b/backend/src/main/java/moadong/global/config/AsyncConfig.java @@ -0,0 +1,27 @@ +package moadong.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "fcmAsync") + public TaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("moadong-async-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + +} diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index 47ef18d7a..7f9303175 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -46,6 +46,9 @@ public enum ErrorCode { AES_CIPHER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "900-1", "암호화 중 오류가 발생했습니다."), APPLICANT_NOT_FOUND(HttpStatus.NOT_FOUND, "900-2", "지원서가 존재하지 않습니다."), + + FCMTOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "901-1", "존재하지 않는 토큰입니다."), + FCMTOKEN_SUBSCRIBE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "901-2", "동아리 구독중에 오류가 발생 하였습니다."); ; private final HttpStatus httpStatus; diff --git a/backend/src/main/java/moadong/global/util/FcmInitializer.java b/backend/src/main/java/moadong/global/util/FcmInitializer.java new file mode 100644 index 000000000..423179d1c --- /dev/null +++ b/backend/src/main/java/moadong/global/util/FcmInitializer.java @@ -0,0 +1,51 @@ +package moadong.global.util; + +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.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Component +public class FcmInitializer { + + @PostConstruct + public void init() throws IOException { + try { + ClassPathResource serviceAccount = + new ClassPathResource("firebase.json"); + + if (!serviceAccount.exists()) { + throw new IOException("Firebase service account file not found"); + } + + InputStream in = serviceAccount.getInputStream(); + + FirebaseOptions.Builder options = FirebaseOptions.builder(); + options.setCredentials(GoogleCredentials.fromStream(in)); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options.build()); + log.info("Firebase app has been initialized"); + } + + in.close(); + } catch (Exception e) { + log.error("Firebase app initialization failed", e); + throw e; + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(); + } +} diff --git a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java index fdb54f69f..6af038e24 100644 --- a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java +++ b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java @@ -54,6 +54,7 @@ public class RecruitmentStateCheckerTest { ZonedDateTime start = NOW.plusDays(10); ZonedDateTime end = NOW.plusDays(20); + when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); when(info.getRecruitmentStart()).thenReturn(start); @@ -74,6 +75,7 @@ public class RecruitmentStateCheckerTest { ZonedDateTime start = NOW.minusDays(1); ZonedDateTime end = NOW.plusDays(5); + when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); when(info.getRecruitmentStart()).thenReturn(start); @@ -94,6 +96,7 @@ public class RecruitmentStateCheckerTest { ZonedDateTime start = NOW.minusDays(10); ZonedDateTime end = NOW.minusDays(1); + when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); when(info.getRecruitmentStart()).thenReturn(start); @@ -111,6 +114,7 @@ public class RecruitmentStateCheckerTest { Club club = mock(Club.class); ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + when(club.getId()).thenReturn("1"); when(club.getClubRecruitmentInformation()).thenReturn(info); when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); when(info.getRecruitmentStart()).thenReturn(null); diff --git a/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java new file mode 100644 index 000000000..d92d380e8 --- /dev/null +++ b/backend/src/test/java/moadong/fcm/service/FcmServiceTest.java @@ -0,0 +1,191 @@ +package moadong.fcm.service; + +import com.google.api.core.ApiFutures; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.TopicManagementResponse; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.fcm.entity.FcmToken; +import moadong.fcm.payload.response.ClubSubscribeListResponse; +import moadong.fcm.repository.FcmTokenRepository; +import moadong.global.exception.RestApiException; +import moadong.util.annotations.IntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@IntegrationTest +@Transactional +class FcmServiceTest { + + @TestConfiguration + static class TestAsyncConfig { + @Bean(name = "fcmAsync") + @Primary + public TaskExecutor taskExecutor() { + return new SyncTaskExecutor(); + } + } + + @Autowired + private FcmService fcmService; + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private ClubRepository clubRepository; + + private Club club1, club2, club3; + @Autowired + private FcmAsyncService fcmAsyncService; + + @MockBean + private FirebaseMessaging firebaseMessaging; + + @BeforeEach + void setUp() { + club1 = clubRepository.save(Club.builder().name("club1").build()); + club2 = clubRepository.save(Club.builder().name("club2").build()); + club3 = clubRepository.save(Club.builder().name("club3").build()); + + TopicManagementResponse ok = Mockito.mock(TopicManagementResponse.class); + when(ok.getFailureCount()).thenReturn(0); + + // subscribe/unsubscribe 모두 성공으로 반환 + when(firebaseMessaging.subscribeToTopicAsync(anyList(), anyString())) + .thenReturn(ApiFutures.immediateFuture(ok)); + when(firebaseMessaging.unsubscribeFromTopicAsync(anyList(), anyString())) + .thenReturn(ApiFutures.immediateFuture(ok)); + } + + @Test + @DisplayName("새로운 Fcm 토큰을 저장한다.") + void saveFcmToken_whenNewToken_thenSaveNewToken() { + // given + String token = "new_token"; + + // when + fcmService.saveFcmToken(token); + + // then + FcmToken savedToken = fcmTokenRepository.findFcmTokenByToken(token).orElseThrow(); + assertThat(savedToken.getToken()).isEqualTo(token); + } + + @Test + @DisplayName("기존 Fcm 토큰의 타임스탬프를 업데이트한다.") + void saveFcmToken_whenExistingToken_thenUpdateTimestamp() { + // given + String token = "existing_token"; + FcmToken existingToken = fcmTokenRepository.save(FcmToken.builder().token(token).build()); + LocalDateTime initialUpdatedAt = existingToken.getTimestamp(); + + // when + fcmService.saveFcmToken(token); + + // then + FcmToken updatedToken = fcmTokenRepository.findFcmTokenByToken(token).orElseThrow(); + assertThat(updatedToken.getTimestamp()).isAfter(initialUpdatedAt); + } + + @Test + @DisplayName("구독 동아리 목록 업데이트 시 Fcm 토큰이 없으면 예외가 발생한다.") + void subscribeClubs_whenTokenNotFound_thenThrowException() { + // given + String token = "non_existing_token"; + ArrayList clubIds = new ArrayList<>(); + + // when, then + assertThatThrownBy(() -> fcmService.subscribeClubs(token, clubIds)) + .isInstanceOf(RestApiException.class); + } + + @Test + @DisplayName("구독 동아리 목록 업데이트 시 동아리가 없으면 예외가 발생한다.") + void subscribeClubs_whenClubNotFound_thenThrowException() { + // given + String token = "existing_token"; + fcmTokenRepository.save(FcmToken.builder() + .token(token) + .clubIds(List.of(club1.getId(), club2.getId())) + .build()); + ArrayList newClubIds = new ArrayList<>(List.of(club1.getId(), "non_existing_club")); + + // when, then + assertThatThrownBy(() -> fcmService.subscribeClubs(token, newClubIds)) + .isInstanceOf(RestApiException.class); + } + + @Test + @DisplayName("구독 동아리 목록을 성공적으로 업데이트한다.") + void subscribeClubs_success() { + // given + String token = "existing_token"; + List oldClubIds = List.of(club1.getId(), club2.getId()); + ArrayList newClubIds = new ArrayList<>(List.of(club2.getId(), club3.getId())); + + fcmTokenRepository.save(FcmToken.builder() + .token(token) + .clubIds(oldClubIds) + .build()); + + // when + CompletableFuture future = fcmAsyncService.updateSubscriptions(token, Set.copyOf(newClubIds), Set.of(club3.getId()), Set.of(club1.getId())); + future.join(); + + // then + FcmToken updatedToken = fcmTokenRepository.findFcmTokenByToken(token).orElseThrow(); + assertThat(updatedToken.getClubIds()).containsExactlyInAnyOrderElementsOf(newClubIds); + } + + @Test + @DisplayName("구독 동아리 목록 조회 시 Fcm 토큰이 없으면 예외가 발생한다.") + void getSubscribeClubs_whenTokenNotFound_thenThrowException() { + // given + String token = "non_existing_token"; + + // when, then + assertThatThrownBy(() -> fcmService.getSubscribeClubs(token)) + .isInstanceOf(RestApiException.class); + } + + @Test + @DisplayName("구독 동아리 목록을 성공적으로 조회한다.") + void getSubscribeClubs_success() { + // given + String token = "existing_token"; + List clubIds = List.of(club1.getId(), club2.getId()); + fcmTokenRepository.save(FcmToken.builder() + .token(token) + .clubIds(clubIds) + .build()); + + // when + ClubSubscribeListResponse response = fcmService.getSubscribeClubs(token); + + // then + assertThat(response.clubIds()).isEqualTo(clubIds); + } +} diff --git a/backend/src/test/java/moadong/fcm/service/FcmTxServiceTest.java b/backend/src/test/java/moadong/fcm/service/FcmTxServiceTest.java new file mode 100644 index 000000000..0e3baf918 --- /dev/null +++ b/backend/src/test/java/moadong/fcm/service/FcmTxServiceTest.java @@ -0,0 +1,43 @@ +package moadong.fcm.service; + +import moadong.fcm.entity.FcmToken; +import moadong.fcm.repository.FcmTokenRepository; +import moadong.util.annotations.IntegrationTest; +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.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; + +@IntegrationTest +@Transactional +class FcmTxServiceTest { + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private FcmTxService fcmTxService; + + FcmToken token; + + @BeforeEach + void setUp() { + token = FcmToken.builder() + .token("token1") + .build(); + fcmTokenRepository.save(token); + } + + @Test + @DisplayName("등록되지않은 토큰을 삭제한다.") + void deleteUnregisteredToken_success() { + // when + fcmTxService.deleteUnregisteredFcmToken(token.getToken()); + + // then + assertThat(fcmTokenRepository.findFcmTokenByToken(token.getToken())).isEmpty(); + } +} \ No newline at end of file