diff --git a/build.gradle b/build.gradle index 73dfec2..241912c 100644 --- a/build.gradle +++ b/build.gradle @@ -71,7 +71,13 @@ dependencies { // Apple id_token signature verify implementation 'com.nimbusds:nimbus-jose-jwt:9.31' +<<<<<<< HEAD implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' +======= + + // fcm + implementation 'com.google.firebase:firebase-admin:9.2.0' +>>>>>>> 7451396 (feat: fcm 연결) } tasks.named('test') { diff --git a/src/main/java/org/jullaene/walkmong_back/api/apply/dto/res/MatchingResponseDto.java b/src/main/java/org/jullaene/walkmong_back/api/apply/dto/res/MatchingResponseDto.java index f8042c7..4e7b1a5 100644 --- a/src/main/java/org/jullaene/walkmong_back/api/apply/dto/res/MatchingResponseDto.java +++ b/src/main/java/org/jullaene/walkmong_back/api/apply/dto/res/MatchingResponseDto.java @@ -21,6 +21,7 @@ public class MatchingResponseDto { private final String walkerProfile; private final String walkMatchingStatus; private final Long boardId; + private final String content; public MatchingResponseDto() { @@ -36,5 +37,6 @@ public MatchingResponseDto() { this.walkerProfile = null; this.walkMatchingStatus = null; this.boardId = null; + this.content = null; } } diff --git a/src/main/java/org/jullaene/walkmong_back/api/apply/repository/impl/ApplyRepositoryImpl.java b/src/main/java/org/jullaene/walkmong_back/api/apply/repository/impl/ApplyRepositoryImpl.java index 60c64f6..bf7f8d1 100644 --- a/src/main/java/org/jullaene/walkmong_back/api/apply/repository/impl/ApplyRepositoryImpl.java +++ b/src/main/java/org/jullaene/walkmong_back/api/apply/repository/impl/ApplyRepositoryImpl.java @@ -82,7 +82,6 @@ public Optional getApplyInfoResponse(Long boardId, Long memberId, @Override public List getApplyInfoResponses(Long memberId, WalkMatchingStatus status, String delYn) { QDog dog = QDog.dog; - QMember member = QMember.member; QBoard board = QBoard.board; QApply apply = QApply.apply; QAddress address = QAddress.address; @@ -97,13 +96,11 @@ public List getApplyInfoResponses(Long memberId, WalkMatchi } // 매칭 확정 : 지원 상태가 CONFIRMED이고 날짜 안 지남 else if (status.equals(WalkMatchingStatus.BEFORE)) { - System.out.println("hello"); builder.and(apply.matchingStatus.eq(MatchingStatus.CONFIRMED)) .and(board.startTime.after(now)); } // 산책 완료 : 지원 상태가 CONFIRMED이고 날짜 지남 else if (status.equals(WalkMatchingStatus.AFTER)) { - System.out.println("hello??"); builder.and(apply.matchingStatus.eq(MatchingStatus.CONFIRMED)) .and(board.startTime.before(now)); } @@ -146,14 +143,15 @@ else if (status.equals(WalkMatchingStatus.REJECT)) { Expressions.nullExpression(String.class), Expressions.nullExpression(String.class), Expressions.asString(status.name()).as("walkMatchingStatus"), - apply.boardId.as("boardId") + board.boardId.as("boardId"), + board.content.as("content") )) .from(apply) - .leftJoin(board) + .join(board) .on(board.boardId.eq(apply.boardId) .and(board.delYn.eq(delYn)) ) - .leftJoin(dog) + .join(dog) .on(dog.dogId.eq(board.dogId) .and(dog.delYn.eq(delYn))) .where(apply.memberId.eq(memberId) diff --git a/src/main/java/org/jullaene/walkmong_back/api/board/repository/impl/BoardRepositoryImpl.java b/src/main/java/org/jullaene/walkmong_back/api/board/repository/impl/BoardRepositoryImpl.java index 8ce9967..847ea98 100644 --- a/src/main/java/org/jullaene/walkmong_back/api/board/repository/impl/BoardRepositoryImpl.java +++ b/src/main/java/org/jullaene/walkmong_back/api/board/repository/impl/BoardRepositoryImpl.java @@ -399,7 +399,8 @@ else if (status.equals(WalkMatchingStatus.REJECT)) { member.name.as("walkerName"), // member와의 조인을 통해 walkerName 설정 member.profile.as("walkerProfile"), // member와의 조인을 통해 walkerProfile 설정 Expressions.asString(status.name()).as("walkMatchingStatus"), - board.boardId.as("boardId") + board.boardId.as("boardId"), + board.content.as("content") )) .from(board) .join(dog) diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/domain/FcmToken.java b/src/main/java/org/jullaene/walkmong_back/api/common/domain/FcmToken.java new file mode 100644 index 0000000..255ae3b --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/domain/FcmToken.java @@ -0,0 +1,38 @@ +package org.jullaene.walkmong_back.api.common.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.jullaene.walkmong_back.common.BaseEntity; + +@Entity +@Table(name = "fcm_token") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FcmToken extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long fcmTokenId; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false, unique = true) + private String token; + + public FcmToken(Long memberId, String token) { + this.memberId = memberId; + this.token = token; + } + + public Long getFcmTokenId () { + return this.fcmTokenId; + } + + public String getToken () { + return this.token; + } + + public void updateToken(String token) { + this.token = token; + } +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/FcmTokenReq.java b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/FcmTokenReq.java new file mode 100644 index 0000000..903fc8b --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/FcmTokenReq.java @@ -0,0 +1,11 @@ +package org.jullaene.walkmong_back.api.common.dto.req; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FcmTokenReq { + private final Long memberId; + private final String token; +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/MultiNotificationReq.java b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/MultiNotificationReq.java new file mode 100644 index 0000000..022a2ec --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/MultiNotificationReq.java @@ -0,0 +1,15 @@ +package org.jullaene.walkmong_back.api.common.dto.req; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class MultiNotificationReq { + private final List memberIds; + private final String title; + private final String body; + +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/NotificationReq.java b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/NotificationReq.java new file mode 100644 index 0000000..88afd67 --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/dto/req/NotificationReq.java @@ -0,0 +1,12 @@ +package org.jullaene.walkmong_back.api.common.dto.req; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class NotificationReq { + private final Long memberId; + private final String title; + private final String body; +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/repository/FcmTokenRepository.java b/src/main/java/org/jullaene/walkmong_back/api/common/repository/FcmTokenRepository.java new file mode 100644 index 0000000..2e62ea9 --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/repository/FcmTokenRepository.java @@ -0,0 +1,15 @@ +package org.jullaene.walkmong_back.api.common.repository; + +import org.jullaene.walkmong_back.api.common.domain.FcmToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FcmTokenRepository extends JpaRepository { + Optional findByMemberIdAndDelYn(Long memberId, String delYn); + + List findAllByMemberIdInAndDelYn(List memberIds, String delYn); +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmController.java b/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmController.java new file mode 100644 index 0000000..2614144 --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmController.java @@ -0,0 +1,64 @@ +package org.jullaene.walkmong_back.api.common.rest; + +import com.google.firebase.messaging.BatchResponse; +import lombok.RequiredArgsConstructor; +import org.jullaene.walkmong_back.api.common.dto.req.MultiNotificationReq; +import org.jullaene.walkmong_back.api.common.dto.req.NotificationReq; +import org.jullaene.walkmong_back.api.common.service.FcmService; +import org.jullaene.walkmong_back.api.common.service.FcmTokenService; +import org.jullaene.walkmong_back.common.BasicResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notification") +public class FcmController { + private final FcmService fcmService; + private final FcmTokenService fcmTokenService; + + /** + * FCM 토큰 등록/업데이트 + * */ + @PostMapping("/token") + public ResponseEntity> registerToken(@RequestParam(value = "token") String token) { + return ResponseEntity.ok(BasicResponse.ofSuccess(fcmTokenService.saveOrUpdateToken(token))); + } + + /** + * 단일 사용자에게 알림 전송 + * */ + @PostMapping("/send") + public ResponseEntity sendNotification(@RequestBody NotificationReq request) { + String messageId = fcmService.sendNotification( + request.getMemberId(), + request.getTitle(), + request.getBody() + ); + + return ResponseEntity.ok(messageId); + } + + /** + * 다중 사용자에게 알림 전송 + * */ + @PostMapping("/send/users") + public ResponseEntity sendToMultipleUsers(@RequestBody MultiNotificationReq request) { + BatchResponse response = fcmService.sendNotificationToMultipleUsers( + request.getMemberIds(), + request.getTitle(), + request.getBody() + ); + return ResponseEntity.ok(response); + } + + /** + * FCM Token 삭제 (soft delete) + * */ + @DeleteMapping("/token") + public ResponseEntity> markDeletedFcmToken () { + return ResponseEntity.ok(BasicResponse.ofSuccess(fcmTokenService.removeToken())); + } + + +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmPageController.java b/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmPageController.java new file mode 100644 index 0000000..b5dc13d --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/rest/FcmPageController.java @@ -0,0 +1,95 @@ +package org.jullaene.walkmong_back.api.common.rest; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +@RequiredArgsConstructor +public class FcmPageController { + + @Value("${firebase.config.apiKey}") + private String apiKey; + + @Value("${firebase.config.authDomain}") + private String authDomain; + + @Value("${firebase.config.projectId}") + private String projectId; + + @Value("${firebase.config.storageBucket}") + private String storageBucket; + + @Value("${firebase.config.messagingSenderId}") + private String messagingSenderId; + + @Value("${firebase.config.appId}") + private String appId; + + @Value("${firebase.config.vapidKey}") + private String vapidKey; + /** + * fcmToken 테스트 전 + * fcmToken 발급을 위한 웹 + * */ + @GetMapping("/fcm") + public String fcmPage(Model model) { + model.addAttribute("apiKey", apiKey); + model.addAttribute("authDomain", authDomain); + model.addAttribute("projectId", projectId); + model.addAttribute("storageBucket", storageBucket); + model.addAttribute("messagingSenderId", messagingSenderId); + model.addAttribute("appId", appId); + model.addAttribute("vapidKey", vapidKey); + return "fcm"; + } + + + /** + * Service Worker Script 반환 + */ + @GetMapping(value = "/firebase-messaging-sw.js", produces = "application/javascript") + @ResponseBody + public String firebaseMessagingSw() { + return String.format(""" + importScripts('https://cdnjs.cloudflare.com/ajax/libs/firebase/9.22.0/firebase-app-compat.min.js'); + importScripts('https://cdnjs.cloudflare.com/ajax/libs/firebase/9.22.0/firebase-messaging-compat.min.js'); + + firebase.initializeApp({ + apiKey: "%s", + authDomain: "%s", + projectId: "%s", + storageBucket: "%s", + messagingSenderId: "%s", + appId: "%s" + }); + + const messaging = firebase.messaging(); + + messaging.onBackgroundMessage((payload) => { + console.log('[firebase-messaging-sw.js] Received background message:', payload); + + const notificationTitle = payload.data.title; + const notificationBody = payload.data.body; + + const notificationOptions = { + body: notificationBody, + // icon: '/firebase-logo.png' // 원하는 아이콘 경로 + }; + + return self.registration.showNotification(notificationTitle, notificationOptions); + }); + """, + apiKey, + authDomain, + projectId, + storageBucket, + messagingSenderId, + appId + ); + } + +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmService.java b/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmService.java new file mode 100644 index 0000000..e8e87ba --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmService.java @@ -0,0 +1,83 @@ +package org.jullaene.walkmong_back.api.common.service; + +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jullaene.walkmong_back.api.common.domain.FcmToken; +import org.jullaene.walkmong_back.api.common.repository.FcmTokenRepository; +import org.jullaene.walkmong_back.common.exception.CustomException; +import org.jullaene.walkmong_back.common.exception.ErrorType; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmService { + private final FirebaseMessaging firebaseMessaging; + private final FcmTokenRepository fcmTokenRepository; + + // 단일 사용자에게 알림 전송 + @Transactional(readOnly = true) + public String sendNotification(Long accountId, String title, String body) { + FcmToken fcmToken = fcmTokenRepository.findByMemberIdAndDelYn(accountId, "N") + .orElseThrow(() -> new CustomException(HttpStatus.NOT_FOUND, ErrorType.INVALID_FCM_TOKEN)); + + return sendNotificationToToken(fcmToken.getToken(), title, body); + } + + // 다중 사용자에게 알림 전송 + @Transactional(readOnly = true) + public BatchResponse sendNotificationToMultipleUsers(List accountIds, String title, String body) { + List tokens = fcmTokenRepository.findAllByMemberIdInAndDelYn(accountIds, "N"); + + String requestId = UUID.randomUUID().toString(); + log.info("사용자 " + tokens.size() + "명에게 알림 전송"); + log.info("제목: " + title); + log.info("내용: " + body); + + + List tokenValues = tokens.stream() + .map(FcmToken::getToken) + .toList(); + log.info("토큰: " + tokenValues.get(0)); + + MulticastMessage message = MulticastMessage.builder() + .putData("title", title) + .putData("body", body) + .addAllTokens(tokenValues) + .build(); + + try { + BatchResponse response = firebaseMessaging.sendEachForMulticast(message); + log.info("RequestID: {} - 전송 성공 수: {}, 실패 수: {}", + requestId, + response.getSuccessCount(), + response.getFailureCount()); + + return response; + } catch (FirebaseMessagingException e) { + log.error("FCM 다중 발송 실패: " + e.getMessage()); + throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.FAIL_FCM_SEND); + } + } + + private String sendNotificationToToken(String token, String title, String body) { + try { + Message message = Message.builder() + .setToken(token) + .putData("title", title) + .putData("body", body) + .build(); + + return firebaseMessaging.send(message); + } catch (FirebaseMessagingException e) { + log.error("FCM 발송 실패: " + e.getMessage()); + throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, ErrorType.FAIL_FCM_SEND); + } + } +} diff --git a/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmTokenService.java b/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmTokenService.java new file mode 100644 index 0000000..eadf795 --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/api/common/service/FcmTokenService.java @@ -0,0 +1,42 @@ +package org.jullaene.walkmong_back.api.common.service; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jullaene.walkmong_back.api.common.domain.FcmToken; +import org.jullaene.walkmong_back.api.common.repository.FcmTokenRepository; +import org.jullaene.walkmong_back.api.member.domain.Member; +import org.jullaene.walkmong_back.api.member.service.MemberService; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmTokenService { + private final FcmTokenRepository fcmTokenRepository; + private final MemberService memberService; + + @Transactional + public Long saveOrUpdateToken(String token) { + Member member = memberService.getMemberFromUserDetail(); + + FcmToken savedFcmToken = fcmTokenRepository.findByMemberIdAndDelYn(member.getMemberId(), "N") + .map(fcmToken -> { + fcmToken.updateToken(token); + return fcmToken; + }) + .orElseGet(() -> fcmTokenRepository.save(new FcmToken(member.getMemberId(), token))); + + return savedFcmToken.getFcmTokenId(); + } + + @Transactional + public String removeToken() { + Member member = memberService.getMemberFromUserDetail(); + + fcmTokenRepository.findByMemberIdAndDelYn(member.getMemberId(), "N") + .ifPresent(FcmToken::delete); + + return "SUCCESS"; + } +} diff --git a/src/main/java/org/jullaene/walkmong_back/common/exception/ErrorType.java b/src/main/java/org/jullaene/walkmong_back/common/exception/ErrorType.java index 76253c7..87ab712 100644 --- a/src/main/java/org/jullaene/walkmong_back/common/exception/ErrorType.java +++ b/src/main/java/org/jullaene/walkmong_back/common/exception/ErrorType.java @@ -40,7 +40,10 @@ public enum ErrorType { EMAIL_ALREADY_REGISTERED("이미 다른 소셜 계정으로 가입된 이메일입니다."), INVALID_BOARD("존재하지 않는 게시글입니다."), INVALID_APPLY("존재하지 않는 지원서입니다."), - INVALID_GEO("현재 위치가 존재하지 않습니다."); + INVALID_GEO("현재 위치가 존재하지 않습니다."), + INVALID_FCM_TOKEN("존재하지 않는 FCM 토큰입니다."), + FAIL_FCM_SEND("FCM 발송에 실패했습니다.") + ; private String message; diff --git a/src/main/java/org/jullaene/walkmong_back/config/FCMConfig.java b/src/main/java/org/jullaene/walkmong_back/config/FCMConfig.java new file mode 100644 index 0000000..c32cc47 --- /dev/null +++ b/src/main/java/org/jullaene/walkmong_back/config/FCMConfig.java @@ -0,0 +1,37 @@ +package org.jullaene.walkmong_back.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +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; + +@Configuration +public class FCMConfig { + @Value("${app.firebase-configuration-file}") + private String firebaseConfigPath; + + @Bean + public FirebaseApp firebaseApp() throws IOException { + GoogleCredentials credentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(credentials) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + return FirebaseApp.getInstance(); + } + + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + return FirebaseMessaging.getInstance(firebaseApp()); + } +} diff --git a/src/main/java/org/jullaene/walkmong_back/config/SecurityConfig.java b/src/main/java/org/jullaene/walkmong_back/config/SecurityConfig.java index 242d3c5..20da19e 100644 --- a/src/main/java/org/jullaene/walkmong_back/config/SecurityConfig.java +++ b/src/main/java/org/jullaene/walkmong_back/config/SecurityConfig.java @@ -29,7 +29,8 @@ public class SecurityConfig { "/swagger-ui/**", "/ws/**", "/test/**", - "/css/**", "/js/**", "/images/**" + "/css/**", "/js/**", "/images/**", + "/fcm", "/firebase-messaging-sw.js" }; @Bean diff --git a/src/main/resources/static/firebase-messaging-sw.js b/src/main/resources/static/firebase-messaging-sw.js new file mode 100644 index 0000000..00c375f --- /dev/null +++ b/src/main/resources/static/firebase-messaging-sw.js @@ -0,0 +1,35 @@ +// Firebase 스크립트 로드 +importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js'); + +// Firebase 초기화 +firebase.initializeApp({ + apiKey: "[[${apiKey}]]", + authDomain: "[[${authDomain}]]", + projectId: "[[${projectId}]]", + storageBucket: "[[${storageBucket}]]", + messagingSenderId: "[[${messagingSenderId}]]", + appId: "[[${appId}]]" +}); + +// Firebase Messaging 초기화 +const messaging = firebase.messaging(); + +// 백그라운드 메시지 처리 +messaging.onBackgroundMessage((payload) => { + console.log('[firebase-messaging-sw.js] Received background message: ', payload); + + const notificationTitle = payload.data.title; + const notificationBody = payload.data.body; + + // 로그로 데이터 페이로드 확인 + console.log('[firebase-messaging-sw.js] Notification Title:', notificationTitle); + console.log('[firebase-messaging-sw.js] Notification Body:', notificationBody); + + const notificationOptions = { + body: notificationBody, + // icon: '/firebase-logo.png' // 원하는 아이콘 경로 + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); diff --git a/src/main/resources/templates/fcm.html b/src/main/resources/templates/fcm.html new file mode 100644 index 0000000..a3b221e --- /dev/null +++ b/src/main/resources/templates/fcm.html @@ -0,0 +1,69 @@ + + + + + + FCM 토큰 발급 + + +

FCM 토큰 발급

+
FCM 토큰이 여기에 표시됩니다.
+ + + + + + + + \ No newline at end of file