Skip to content

Commit

Permalink
[Resolves #24] 클라이언트에 이미지 생성 완료 Notification (#43)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
BambooKim committed May 9, 2023
1 parent 5bd45b3 commit 58cb8a1
Show file tree
Hide file tree
Showing 11 changed files with 328 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/main/java/grimuri/backend/GrimUriBackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/grimuri/backend/domain/fcm/FCMToken.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
31 changes: 31 additions & 0 deletions src/main/java/grimuri/backend/domain/fcm/FCMTokenController.java
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<FCMToken> 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");
}
}
15 changes: 15 additions & 0 deletions src/main/java/grimuri/backend/domain/fcm/FCMTokenRepository.java
Original file line number Diff line number Diff line change
@@ -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<FCMToken, Long> {

List<FCMToken> findAllByUser_Email(String email);

Optional<FCMToken> findByToken(String tokenStr);
}
86 changes: 86 additions & 0 deletions src/main/java/grimuri/backend/domain/fcm/FCMTokenService.java
Original file line number Diff line number Diff line change
@@ -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<String> getTokenListByUser(String email) {
List<FCMToken> tokenList = fcmTokenRepository.findAllByUser_Email(email);

return tokenList.stream()
.map(FCMToken::getToken)
.collect(Collectors.toList());
}
}
62 changes: 62 additions & 0 deletions src/main/java/grimuri/backend/domain/image/ImageService.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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가 존재하지 않습니다.");
Expand All @@ -47,5 +57,57 @@ public void saveImageWithDiary(ImageRequestDto.Complete request) {

imageRepository.save(eachImage);
});

List<FCMToken> 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<FCMToken> 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<SendResponse> 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());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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}'과 같은 형태로 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/grimuri/backend/domain/user/dto/UserRequestDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
Loading

0 comments on commit 58cb8a1

Please sign in to comment.