From bcf8a43a95963e6a2a4cc521267959c615b11387 Mon Sep 17 00:00:00 2001 From: Beomgoo Kim Date: Sat, 22 Apr 2023 19:45:58 +0900 Subject: [PATCH] =?UTF-8?q?[Resolves=20#24]=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EC=97=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=99=84=EB=A3=8C=20Notification=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: DB에서 사용자의 FCM Token 목록 가져와 send Message #24 - FCMToken Entity 추가 - FCMTokenService.getTokenListByUser: User의 이메일 주소로 FCMToken 목록을 조회하여 토큰 문자열의 목록을 반환 - ImageService.notifyImageComplete * [Resolves #34] 대표 이미지 선택 API (#36) #34 * fix: Diary에서 대표 이외 나머지 후보 삭제 NullPointError 수정 #34 * chore: log.debug() 임시 적용 (#39) #38 * fix: Diary의 태그 (shortcontent)가 null 일 때 Response DTO의 태그에 Empty List 반환하게 수정 * fix: 일기 작성 후 ~ 이미지 생성 전 상태를 확인하도록 수정 - 후보 이미지 목록 조회 - 대표 이미지 선택 * fix: 일기 작성 후 ~ 이미지 생성 전 상태를 확인하도록 수정 - 후보 이미지 목록 조회 - 대표 이미지 선택 * [Resolves #33] 일기 수정 및 삭제 API (#42) * feat: 일기 삭제 API #33 * feat: 일기 수정 API #33 * Docs: 일기 수정 API Swagger #33 * fix: PathVariable 오탈자 수정 * feat: FCM Token 등록 API #24 * feat: 이미지 생성 완료 시 클라이언트에 Notify #24 * feat: Notification 테스트용 API #24 * fix: FCM Token이 하나도 없을 경우 체크 #24 --- .../backend/GrimUriBackendApplication.java | 2 + .../grimuri/backend/domain/fcm/FCMToken.java | 40 +++++++++ .../domain/fcm/FCMTokenController.java | 31 +++++++ .../domain/fcm/FCMTokenControllerImpl.java | 43 ++++++++++ .../domain/fcm/FCMTokenRepository.java | 15 ++++ .../backend/domain/fcm/FCMTokenService.java | 86 +++++++++++++++++++ .../backend/domain/image/ImageService.java | 62 +++++++++++++ .../user/controller/UserController.java | 20 +++++ .../user/controller/UserControllerImpl.java | 12 +++ .../domain/user/dto/UserRequestDto.java | 13 +++ .../global/util/SchemaDescriptionUtils.java | 4 + 11 files changed, 328 insertions(+) create mode 100644 src/main/java/grimuri/backend/domain/fcm/FCMToken.java create mode 100644 src/main/java/grimuri/backend/domain/fcm/FCMTokenController.java create mode 100644 src/main/java/grimuri/backend/domain/fcm/FCMTokenControllerImpl.java create mode 100644 src/main/java/grimuri/backend/domain/fcm/FCMTokenRepository.java create mode 100644 src/main/java/grimuri/backend/domain/fcm/FCMTokenService.java diff --git a/src/main/java/grimuri/backend/GrimUriBackendApplication.java b/src/main/java/grimuri/backend/GrimUriBackendApplication.java index 99faa35..cbd74f9 100644 --- a/src/main/java/grimuri/backend/GrimUriBackendApplication.java +++ b/src/main/java/grimuri/backend/GrimUriBackendApplication.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.EnableAsync; @SpringBootApplication @EnableJpaAuditing +@EnableAsync public class GrimUriBackendApplication { public static void main(String[] args) { diff --git a/src/main/java/grimuri/backend/domain/fcm/FCMToken.java b/src/main/java/grimuri/backend/domain/fcm/FCMToken.java new file mode 100644 index 0000000..b7280f0 --- /dev/null +++ b/src/main/java/grimuri/backend/domain/fcm/FCMToken.java @@ -0,0 +1,40 @@ +package grimuri.backend.domain.fcm; + +import grimuri.backend.domain.BaseTimeEntity; +import grimuri.backend.domain.user.User; +import lombok.*; + +import javax.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class FCMToken extends BaseTimeEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long fcmTokenId; + + @Column(nullable = false) + private String token; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_pk", nullable = false) + private User user; + + @Column + private LocalDateTime lastLogin; + + @Column + private Long failCount; + + public void setLastLogin(LocalDateTime localDateTime) { + this.lastLogin = localDateTime; + } + + public void setFailCount(Long count) { + this.failCount = count; + } +} diff --git a/src/main/java/grimuri/backend/domain/fcm/FCMTokenController.java b/src/main/java/grimuri/backend/domain/fcm/FCMTokenController.java new file mode 100644 index 0000000..c687fb8 --- /dev/null +++ b/src/main/java/grimuri/backend/domain/fcm/FCMTokenController.java @@ -0,0 +1,31 @@ +package grimuri.backend.domain.fcm; + +import grimuri.backend.domain.diary.dto.DiaryResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; + +public interface FCMTokenController { + + @Operation( + summary = "Notification 테스트", + description = "로그인된 사용자에게 Notification을 테스트한다. Diary는 존재하지 않아도 된다.", + tags = { "FCMTokenController" } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok.", + content = @Content(schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "401", description = "Unauthorized, 권한 없음.", + content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "500", description = "Internal Server Error", + content = @Content(schema = @Schema(hidden = true))) + }) + @ResponseStatus(HttpStatus.OK) + ResponseEntity testNotification(@RequestParam Long diaryId, @RequestParam String diaryTitle); +} diff --git a/src/main/java/grimuri/backend/domain/fcm/FCMTokenControllerImpl.java b/src/main/java/grimuri/backend/domain/fcm/FCMTokenControllerImpl.java new file mode 100644 index 0000000..c392ec1 --- /dev/null +++ b/src/main/java/grimuri/backend/domain/fcm/FCMTokenControllerImpl.java @@ -0,0 +1,43 @@ +package grimuri.backend.domain.fcm; + +import com.google.firebase.messaging.FirebaseMessagingException; +import grimuri.backend.domain.image.ImageService; +import grimuri.backend.domain.user.User; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/api/v1/fcm") +public class FCMTokenControllerImpl implements FCMTokenController { + + private final ImageService imageService; + private final FCMTokenRepository fcmTokenRepository; + + @GetMapping("/notification-test") + @Override + public ResponseEntity testNotification(@RequestParam Long diaryId, @RequestParam String diaryTitle) { + User loginUser = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + List tokenList = fcmTokenRepository.findAllByUser_Email(loginUser.getEmail()); + try { + imageService.notifyImageComplete(tokenList, diaryId, diaryTitle); + } catch (FirebaseMessagingException e) { + e.printStackTrace(); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage()); + } + + return ResponseEntity.status(HttpStatus.OK).body("success"); + } +} diff --git a/src/main/java/grimuri/backend/domain/fcm/FCMTokenRepository.java b/src/main/java/grimuri/backend/domain/fcm/FCMTokenRepository.java new file mode 100644 index 0000000..ad0f14e --- /dev/null +++ b/src/main/java/grimuri/backend/domain/fcm/FCMTokenRepository.java @@ -0,0 +1,15 @@ +package grimuri.backend.domain.fcm; + +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 { + + List findAllByUser_Email(String email); + + Optional findByToken(String tokenStr); +} diff --git a/src/main/java/grimuri/backend/domain/fcm/FCMTokenService.java b/src/main/java/grimuri/backend/domain/fcm/FCMTokenService.java new file mode 100644 index 0000000..b99e2ea --- /dev/null +++ b/src/main/java/grimuri/backend/domain/fcm/FCMTokenService.java @@ -0,0 +1,86 @@ +package grimuri.backend.domain.fcm; + +import grimuri.backend.domain.user.User; +import grimuri.backend.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import javax.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class FCMTokenService { + + private final FCMTokenRepository fcmTokenRepository; + private final UserRepository userRepository; + + /** + * FCM Token - 사용자 정보를 DB에 저장한다. 기존에 존재하는 Token-사용자 정보라면 마지막 로그인 시간을 갱신한다. + * Token은 존재하지만 사용자 정보가 다르다면 해당 정보를 삭제하고 새로운 사용자 정보로 FCM Token을 재생성한다. + * 아예 존재하지 않는 FCM Token - 사용자 정보라면 새로 생성하여 저장한다. + * @param email FCM Token 정보를 저장할 사용자의 email (PK) + * @param fcmTokenStr FCM Token 문자열 + */ + public void registerFCMToken(String email, String fcmTokenStr) { + // email로 사용자 엔티티 조회 + User user = userRepository.findById(email).orElseThrow(() -> { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "user email not found"); + }); + + // 해당 토큰이 DB에 존재하는지 확인 + fcmTokenRepository.findByToken(fcmTokenStr).ifPresentOrElse( + // 존재하면, 사용자의 것인지 확인 + fcmToken -> { + if (fcmToken.getUser().getEmail().equals(email)) { + // 사용자의 것이라면 마지막 로그인 시간 갱신 + fcmToken.setLastLogin(LocalDateTime.now()); + } else { + // 사용자의 것이 아니라면 기존 토큰정보 삭제 후 재생성 + fcmTokenRepository.delete(fcmToken); + createNewTokenAndSave(fcmTokenStr, user); + } + }, + // 없으면 새로 등록 + () -> { + createNewTokenAndSave(fcmTokenStr, user); + } + ); + } + + /** + * 토큰 문자열과 사용자 엔티티로 FCMToken 엔티티를 새로 생성하여 저장한다. + * @param fcmTokenStr FCM Token 문자열 + * @param user 사용자 엔티티 + */ + private void createNewTokenAndSave(String fcmTokenStr, User user) { + FCMToken newToken = FCMToken.builder() + .token(fcmTokenStr) + .user(user) + .lastLogin(LocalDateTime.now()) + .failCount(0L) + .build(); + + fcmTokenRepository.save(newToken); + } + + /** + * 사용자의 이메일 주소를 통해 FCM Token의 목록을 조회하여 반환한다. + * @param email FCM Token 목록을 조회할 사용자의 이메일 + * @return FCM 토큰(String)의 목록 + */ + public List getTokenListByUser(String email) { + List tokenList = fcmTokenRepository.findAllByUser_Email(email); + + return tokenList.stream() + .map(FCMToken::getToken) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/grimuri/backend/domain/image/ImageService.java b/src/main/java/grimuri/backend/domain/image/ImageService.java index bce0e1e..7b9d199 100644 --- a/src/main/java/grimuri/backend/domain/image/ImageService.java +++ b/src/main/java/grimuri/backend/domain/image/ImageService.java @@ -1,17 +1,23 @@ package grimuri.backend.domain.image; +import com.google.firebase.messaging.*; import grimuri.backend.domain.diary.Diary; import grimuri.backend.domain.diary.DiaryRepository; +import grimuri.backend.domain.fcm.FCMToken; +import grimuri.backend.domain.fcm.FCMTokenRepository; +import grimuri.backend.domain.fcm.FCMTokenService; import grimuri.backend.domain.image.dto.ImageRequestDto; import grimuri.backend.domain.user.User; import grimuri.backend.domain.user.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import javax.transaction.Transactional; +import java.util.List; import java.util.stream.Collectors; @Service @@ -23,7 +29,11 @@ public class ImageService { private final DiaryRepository diaryRepository; private final ImageRepository imageRepository; private final UserRepository userRepository; + private final FCMTokenRepository fcmTokenRepository; + private final FCMTokenService fcmTokenService; + + @Async public void saveImageWithDiary(ImageRequestDto.Complete request) { Diary findDiary = diaryRepository.findById(request.getDiaryId()).orElseThrow(() -> { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Diary가 존재하지 않습니다."); @@ -47,5 +57,57 @@ public void saveImageWithDiary(ImageRequestDto.Complete request) { imageRepository.save(eachImage); }); + + List tokenList = fcmTokenRepository.findAllByUser_Email(writerUser.getEmail()); + log.debug("\tToken List Size: {}", tokenList.size()); + + // TODO: 예외 처리 + try { + notifyImageComplete(tokenList, findDiary.getId(), findDiary.getTitle()); + } catch (FirebaseMessagingException e) { + e.printStackTrace(); + } + } + + // TODO: Platform 별 Configuration + /** + * Token List에 있는 FCM Token들에 Notification을 생성하고 발송한다. + * @param tokenList Notification을 발송하고자 하는 FCM Token의 List + * @param diaryId 일기 생성이 완료되었다고 알릴 diaryId + * @param diaryTitle 일기 생성이 완료되었다고 알릴 diary의 Title + * @throws FirebaseMessagingException + */ + public void notifyImageComplete(List tokenList, Long diaryId, String diaryTitle) throws FirebaseMessagingException { + if (tokenList.isEmpty()) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "FCM Token이 하나도 없습니다."); + } + + Notification notification = Notification.builder() + .setTitle("이미지 생성 완료!") + .setBody("일기 \"" + diaryTitle + "\"의 이미지 생성이 완료되었습니다.") + .build(); + + MulticastMessage message = MulticastMessage.builder() + .setNotification(notification) + .putData("diaryId", String.valueOf(diaryId)) + .putData("diaryTitle", diaryTitle) + .addAllTokens(tokenList.stream().map(FCMToken::getToken).collect(Collectors.toList())) + .build(); + + BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message); + + // success 시 failCount = 0, fail 시 failCount++ + List responses = response.getResponses(); + for (int i = 0; i < responses.size(); i++) { + SendResponse sendResponse = responses.get(i); + FCMToken fcmToken = tokenList.get(i); + + if (sendResponse.isSuccessful()) { + fcmToken.setFailCount(0L); + } else { + fcmToken.setFailCount(fcmToken.getFailCount() + 1); + log.debug("\tFailed Token: {}", fcmToken.getToken()); + } + } } } diff --git a/src/main/java/grimuri/backend/domain/user/controller/UserController.java b/src/main/java/grimuri/backend/domain/user/controller/UserController.java index 0de5100..d37a14c 100644 --- a/src/main/java/grimuri/backend/domain/user/controller/UserController.java +++ b/src/main/java/grimuri/backend/domain/user/controller/UserController.java @@ -15,6 +15,26 @@ public interface UserController { + @Operation( + summary = "로그인한 사용자의 FCM Token 정보 등록", + description = "로그인한 뒤, Notification을 위해 사용자의 FCM Token을 서버에 등록한다.", + tags = { "UserController" } + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK.", + content = @Content(schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "400", description = "Bad Request, Header가 잘못되었음.", + content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", description = "Unauthorized, 유효하지 않은 토큰임.", + content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", description = "Not Found, 사용자를 찾을 수 없음.", + content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "500", description = "Internal Server Error", + content = @Content(schema = @Schema(hidden = true))) + }) + @ResponseStatus(HttpStatus.OK) + ResponseEntity postLoginFCMToken(@RequestBody UserRequestDto.FCMTokenRequest tokenRequest); + @Operation( summary = "사용자 정보 등록", description = "Firebase를 통해 인증 절차를 거치고 난 뒤, Authorization 헤더에 'Bearer {Token}'과 같은 형태로 " + diff --git a/src/main/java/grimuri/backend/domain/user/controller/UserControllerImpl.java b/src/main/java/grimuri/backend/domain/user/controller/UserControllerImpl.java index 2768add..dd6cdb2 100644 --- a/src/main/java/grimuri/backend/domain/user/controller/UserControllerImpl.java +++ b/src/main/java/grimuri/backend/domain/user/controller/UserControllerImpl.java @@ -2,6 +2,7 @@ import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.auth.FirebaseToken; +import grimuri.backend.domain.fcm.FCMTokenService; import grimuri.backend.domain.user.User; import grimuri.backend.domain.user.UserService; import grimuri.backend.domain.user.dto.UserRequestDto; @@ -21,6 +22,17 @@ public class UserControllerImpl implements UserController { private final FirebaseAuth firebaseAuth; private final UserService userService; + private final FCMTokenService fcmTokenService; + + @PostMapping("/fcmtoken") + @Override + public ResponseEntity postLoginFCMToken(@RequestBody UserRequestDto.FCMTokenRequest tokenRequest) { + User loginUser = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + fcmTokenService.registerFCMToken(loginUser.getEmail(), tokenRequest.getFcm_token()); + + return ResponseEntity.status(HttpStatus.OK).body("success"); + } /** * @param authorization Authorization Header diff --git a/src/main/java/grimuri/backend/domain/user/dto/UserRequestDto.java b/src/main/java/grimuri/backend/domain/user/dto/UserRequestDto.java index 723ba0d..ed87a7e 100644 --- a/src/main/java/grimuri/backend/domain/user/dto/UserRequestDto.java +++ b/src/main/java/grimuri/backend/domain/user/dto/UserRequestDto.java @@ -16,4 +16,17 @@ public static class Register { @Schema(description = SchemaDescriptionUtils.UserRegister.nickname) private String nickname; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @ToString + @Schema(description = "로그인 후 FCM 토큰 정보 전송") + public static class FCMTokenRequest { + + @Schema(description = SchemaDescriptionUtils.FCMToken.fcm_token) + private String fcm_token; + } + } diff --git a/src/main/java/grimuri/backend/global/util/SchemaDescriptionUtils.java b/src/main/java/grimuri/backend/global/util/SchemaDescriptionUtils.java index 8ab040d..279a75d 100644 --- a/src/main/java/grimuri/backend/global/util/SchemaDescriptionUtils.java +++ b/src/main/java/grimuri/backend/global/util/SchemaDescriptionUtils.java @@ -2,6 +2,10 @@ public class SchemaDescriptionUtils { + public static class FCMToken { + public static final String fcm_token = "사용자가 로그인한 기기의 FCM 토큰"; + } + public static class AfterSignup { public static final String username = "사용자의 이름"; public static final String email = "사용자의 이메일";