diff --git a/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java b/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java index 69c04fa..de77adc 100644 --- a/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java +++ b/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java @@ -1,9 +1,14 @@ package com.cmc.suppin.fcm.controller; +import com.cmc.suppin.fcm.controller.dto.DeviceTokenRequestDTO; import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; import com.cmc.suppin.fcm.service.FcmService; import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.security.reslover.Account; +import com.cmc.suppin.global.security.reslover.CurrentAccount; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -16,22 +21,26 @@ @Slf4j @RestController +@RequiredArgsConstructor @Tag(name = "FCM", description = "FCM 푸시알림 관련 API") @RequestMapping("/api/v1/fcm") public class FcmController { private final FcmService fcmService; - public FcmController(FcmService fcmService) { - this.fcmService = fcmService; - } - - // 모바일로부터 사용자 FCM 토큰, 메시지 제목, 내용을 받아서 서비스를 처리 @PostMapping("/send") public ResponseEntity> pushMessage(@RequestBody @Validated FcmSendDTO fcmSendDto) throws IOException { - log.debug("[+] 푸시 메시지를 전송합니다. "); + log.debug("[+] 푸시 메시지를 전송합니다."); int result = fcmService.sendMessageTo(fcmSendDto); return ResponseEntity.ok(ApiResponse.of(result)); } + + @PostMapping("/register") + @Operation(summary = "FCM 디바이스 토큰 등록 API", description = "앱을 시작할 때, 디바이스 토큰을 저장합니다.

DeviceType : ANDROID, IOS, OTHER") + public ResponseEntity registerDeviceToken(@RequestBody @Validated DeviceTokenRequestDTO request, @CurrentAccount Account account) { + fcmService.registerDeviceToken(account.userId(), request.getToken(), request.getDeviceType()); + return ResponseEntity.ok().build(); + } } + diff --git a/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java b/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java new file mode 100644 index 0000000..5083873 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java @@ -0,0 +1,14 @@ +package com.cmc.suppin.fcm.controller.dto; + +import com.cmc.suppin.fcm.domain.DeviceType; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class DeviceTokenRequestDTO { + private String token; + private DeviceType deviceType; // ANDROID, IOS, OTHER +} diff --git a/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java b/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java new file mode 100644 index 0000000..c69c297 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java @@ -0,0 +1,5 @@ +package com.cmc.suppin.fcm.domain; + +public enum DeviceType { + ANDROID, IOS, OTHER +} diff --git a/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java b/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java new file mode 100644 index 0000000..0bbaab8 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java @@ -0,0 +1,17 @@ +package com.cmc.suppin.fcm.domain.respository; + +import com.cmc.suppin.fcm.domain.DeviceToken; +import com.cmc.suppin.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeviceTokenRepository extends JpaRepository { + Optional findByDeviceToken(String deviceToken); + + List findAllByMember(Member member); +} + diff --git a/src/main/java/com/cmc/suppin/fcm/service/FcmService.java b/src/main/java/com/cmc/suppin/fcm/service/FcmService.java index 8790906..512ba8e 100644 --- a/src/main/java/com/cmc/suppin/fcm/service/FcmService.java +++ b/src/main/java/com/cmc/suppin/fcm/service/FcmService.java @@ -1,12 +1,128 @@ package com.cmc.suppin.fcm.service; +import com.cmc.suppin.fcm.controller.dto.FcmMessageDTO; import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; +import com.cmc.suppin.fcm.domain.DeviceToken; +import com.cmc.suppin.fcm.domain.DeviceType; +import com.cmc.suppin.fcm.domain.respository.DeviceTokenRepository; +import com.cmc.suppin.global.enums.UserStatus; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.*; +import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; @Service -public interface FcmService { +@Slf4j +@RequiredArgsConstructor +public class FcmService { - int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException; + private final DeviceTokenRepository deviceTokenRepository; + private final MemberRepository memberRepository; + + /** + * 푸시 메시지 처리를 수행하는 비즈니스 로직 + * + * @param fcmSendDTO 모바일에서 전달받은 Object + * @return 성공(1), 실패(0) + */ + public int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException { + + try { + String message = makeMessage(fcmSendDTO); + RestTemplate restTemplate = new RestTemplate(); + + restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + getAccessToken()); + + HttpEntity entity = new HttpEntity<>(message, headers); + + String API_URL = "https://fcm.googleapis.com/v1/projects/suppin-a5657/messages:send"; + ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return 1; + } else { + log.error("FCM 메시지 전송 실패: {}", response.getStatusCode()); + return 0; + } + } catch (Exception e) { + log.error("FCM 메시지 전송 중 예외 발생", e); + return 0; + } + } + + /** + * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. + * + * @return Bearer token, String + */ + private String getAccessToken() throws IOException { + String firebaseConfigPath = "firebase/suppin-a5657-firebase-adminsdk.json"; + + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .createScoped(List.of("")); + + googleCredentials.refreshIfExpired(); + return googleCredentials.getAccessToken().getTokenValue(); + } + + /** + * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String) + * + * @param fcmSendDTO, 모바일에서 전달받은 Object + * @return String + */ + private String makeMessage(FcmSendDTO fcmSendDTO) throws JsonProcessingException { + + ObjectMapper om = new ObjectMapper(); + FcmMessageDTO fcmMessageDto = FcmMessageDTO.builder() + .message(FcmMessageDTO.Message.builder() + .token(fcmSendDTO.getToken()) + .notification(FcmMessageDTO.Notification.builder() + .title(fcmSendDTO.getTitle()) + .body(fcmSendDTO.getBody()) + .image(null) + .build() + ).build()).validateOnly(false).build(); + + return om.writeValueAsString(fcmMessageDto); + } + + /** + * 클라이언트로부터 Device Token을 수신하여 저장합니다. + */ + @Transactional + public void registerDeviceToken(String userId, String token, DeviceType deviceType) { + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + deviceTokenRepository.findByDeviceToken(token) + .orElseGet(() -> { + DeviceToken deviceToken = DeviceToken.builder() + .member(member) + .deviceToken(token) + .deviceType(deviceType) + .createdAt(LocalDateTime.now()) + .build(); + return deviceTokenRepository.save(deviceToken); + }); + } } diff --git a/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java b/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java deleted file mode 100644 index 0edde51..0000000 --- a/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.cmc.suppin.fcm.service; - -import com.cmc.suppin.fcm.controller.dto.FcmMessageDTO; -import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.auth.oauth2.GoogleCredentials; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.*; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; - -@Service -@Slf4j -public class FcmServiceImpl implements FcmService { - - /** - * 푸시 메시지 처리를 수행하는 비즈니스 로직 - * - * @param fcmSendDTO 모바일에서 전달받은 Object - * @return 성공(1), 실패(0) - */ - @Override - public int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException { - - try { - String message = makeMessage(fcmSendDTO); - RestTemplate restTemplate = new RestTemplate(); - - restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + getAccessToken()); - - HttpEntity entity = new HttpEntity<>(message, headers); - - String API_URL = "https://fcm.googleapis.com/v1/projects/suppin-a5657/messages:send"; - ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return 1; - } else { - log.error("FCM 메시지 전송 실패: {}", response.getStatusCode()); - return 0; - } - } catch (Exception e) { - log.error("FCM 메시지 전송 중 예외 발생", e); - return 0; - } - } - - /** - * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. - * - * @return Bearer token, String - */ - private String getAccessToken() throws IOException { - String firebaseConfigPath = "firebase/suppin-a5657-firebase-adminsdk.json"; - - GoogleCredentials googleCredentials = GoogleCredentials - .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) - .createScoped(List.of("")); - - googleCredentials.refreshIfExpired(); - return googleCredentials.getAccessToken().getTokenValue(); - } - - /** - * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String) - * - * @param fcmSendDTO, 모바일에서 전달받은 Object - * @return String - */ - private String makeMessage(FcmSendDTO fcmSendDTO) throws JsonProcessingException { - - ObjectMapper om = new ObjectMapper(); - FcmMessageDTO fcmMessageDto = FcmMessageDTO.builder() - .message(FcmMessageDTO.Message.builder() - .token(fcmSendDTO.getToken()) - .notification(FcmMessageDTO.Notification.builder() - .title(fcmSendDTO.getTitle()) - .body(fcmSendDTO.getBody()) - .image(null) - .build() - ).build()).validateOnly(false).build(); - - return om.writeValueAsString(fcmMessageDto); - } -}