diff --git a/.github/workflows/master_weekly_cicd.yml b/.github/workflows/master_weekly_cicd.yml index 3a04a2c..8d38d13 100644 --- a/.github/workflows/master_weekly_cicd.yml +++ b/.github/workflows/master_weekly_cicd.yml @@ -20,6 +20,10 @@ jobs: distribution: 'corretto' java-version: '21' + - name: FCM 서비스 계정 키 파일 생성 + run: | + echo '${{ secrets.FCM_SERVICE_ACCOUNT_KEY }}' > ./src/main/resources/everymoment.json + - name: AWS S3 관련 정보를 설정 파일에 주입 uses: microsoft/variable-substitution@v1 with: diff --git a/.github/workflows/pr_weekly_ci.yml b/.github/workflows/pr_weekly_ci.yml index 0acf431..b0a7200 100644 --- a/.github/workflows/pr_weekly_ci.yml +++ b/.github/workflows/pr_weekly_ci.yml @@ -23,6 +23,10 @@ jobs: distribution: 'corretto' java-version: '21' + - name: FCM 서비스 계정 키 파일 생성 + run: | + echo '${{ secrets.FCM_SERVICE_ACCOUNT_KEY }}' > ./src/main/resources/everymoment.json + - name: AWS S3 관련 정보를 설정 파일에 주입 uses: microsoft/variable-substitution@v1 with: diff --git a/.gitignore b/.gitignore index 8eb016e..145cd25 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ !**/src/test/**/build/ application-dev.yml +everymoment.json ### STS ### .apt_generated diff --git a/build.gradle b/build.gradle index 8b03ef5..98243d9 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.12.657' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + implementation 'com.google.firebase:firebase-admin:9.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' diff --git a/src/main/java/com/potatocake/everymoment/config/FcmConfig.java b/src/main/java/com/potatocake/everymoment/config/FcmConfig.java new file mode 100644 index 0000000..d3a0dca --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/config/FcmConfig.java @@ -0,0 +1,29 @@ +package com.potatocake.everymoment.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +@Configuration +public class FcmConfig { + + @Bean + public FirebaseMessaging firebaseMessaging() throws IOException { + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource("everymoment.json").getInputStream()); + + FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(googleCredentials) + .build(); + + FirebaseApp app = FirebaseApp.initializeApp(firebaseOptions); + + return FirebaseMessaging.getInstance(app); + } + +} diff --git a/src/main/java/com/potatocake/everymoment/controller/FcmController.java b/src/main/java/com/potatocake/everymoment/controller/FcmController.java new file mode 100644 index 0000000..b6ccd7f --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/controller/FcmController.java @@ -0,0 +1,60 @@ +package com.potatocake.everymoment.controller; + +import com.potatocake.everymoment.dto.SuccessResponse; +import com.potatocake.everymoment.dto.request.FcmTokenRequest; +import com.potatocake.everymoment.security.MemberDetails; +import com.potatocake.everymoment.service.FcmService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "FCM", description = "FCM 토큰 관리 API") +@RestController +@RequestMapping("/api/fcm") +@RequiredArgsConstructor +public class FcmController { + + private final FcmService fcmService; + + @Operation(summary = "FCM 토큰 등록/갱신", description = "디바이스의 FCM 토큰을 등록하거나 갱신합니다.") + @ApiResponse(responseCode = "200", description = "토큰 등록/갱신 성공") + @PostMapping("/token") + public ResponseEntity registerToken( + @Parameter(description = "인증된 사용자 정보", hidden = true) + @AuthenticationPrincipal MemberDetails memberDetails, + @Parameter(description = "FCM 토큰 정보", required = true) + @RequestBody @Valid FcmTokenRequest request) { + + fcmService.registerToken(memberDetails.getId(), request.getDeviceId(), request.getFcmToken()); + + return ResponseEntity.ok() + .body(SuccessResponse.ok()); + } + + @Operation(summary = "FCM 토큰 삭제", description = "디바이스의 FCM 토큰을 삭제합니다.") + @ApiResponse(responseCode = "200", description = "토큰 삭제 성공") + @DeleteMapping("/token") + public ResponseEntity removeToken( + @Parameter(description = "인증된 사용자 정보", hidden = true) + @AuthenticationPrincipal MemberDetails memberDetails, + @Parameter(description = "디바이스 ID", required = true) + @RequestParam String deviceId) { + + fcmService.removeToken(memberDetails.getId(), deviceId); + + return ResponseEntity.ok() + .body(SuccessResponse.ok()); + } + +} diff --git a/src/main/java/com/potatocake/everymoment/dto/request/CommentRequest.java b/src/main/java/com/potatocake/everymoment/dto/request/CommentRequest.java index db0252a..5fe38f7 100644 --- a/src/main/java/com/potatocake/everymoment/dto/request/CommentRequest.java +++ b/src/main/java/com/potatocake/everymoment/dto/request/CommentRequest.java @@ -1,8 +1,12 @@ package com.potatocake.everymoment.dto.request; +import jakarta.validation.constraints.NotEmpty; import lombok.Getter; @Getter public class CommentRequest { + + @NotEmpty(message = "댓글 내용을 입력해 주세요.") private String content; + } diff --git a/src/main/java/com/potatocake/everymoment/dto/request/DiaryManualCreateRequest.java b/src/main/java/com/potatocake/everymoment/dto/request/DiaryManualCreateRequest.java index 9df2ed2..2a544e0 100644 --- a/src/main/java/com/potatocake/everymoment/dto/request/DiaryManualCreateRequest.java +++ b/src/main/java/com/potatocake/everymoment/dto/request/DiaryManualCreateRequest.java @@ -1,17 +1,30 @@ package com.potatocake.everymoment.dto.request; import com.potatocake.everymoment.dto.LocationPoint; +import jakarta.validation.constraints.Size; import java.util.List; import lombok.Getter; @Getter public class DiaryManualCreateRequest { + private List categories; + private LocationPoint locationPoint; + + @Size(max = 50, message = "장소명은 50자를 초과할 수 없습니다") private String locationName; + + @Size(max = 200, message = "주소는 200자를 초과할 수 없습니다") private String address; + private boolean isBookmark; private boolean isPublic; + + @Size(max = 10, message = "이모지는 10자를 초과할 수 없습니다") private String emoji; + + @Size(max = 5000, message = "일기 내용은 5000자를 초과할 수 없습니다") private String content; + } diff --git a/src/main/java/com/potatocake/everymoment/dto/request/FcmNotificationRequest.java b/src/main/java/com/potatocake/everymoment/dto/request/FcmNotificationRequest.java new file mode 100644 index 0000000..1726828 --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/dto/request/FcmNotificationRequest.java @@ -0,0 +1,15 @@ +package com.potatocake.everymoment.dto.request; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FcmNotificationRequest { + + private String title; + private String body; + private String type; + private Long targetId; + +} diff --git a/src/main/java/com/potatocake/everymoment/dto/request/FcmTokenRequest.java b/src/main/java/com/potatocake/everymoment/dto/request/FcmTokenRequest.java new file mode 100644 index 0000000..f5ff007 --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/dto/request/FcmTokenRequest.java @@ -0,0 +1,15 @@ +package com.potatocake.everymoment.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class FcmTokenRequest { + + @NotBlank(message = "FCM 토큰은 필수입니다.") + private String fcmToken; + + @NotBlank(message = "디바이스 ID는 필수입니다.") + private String deviceId; + +} diff --git a/src/main/java/com/potatocake/everymoment/entity/DeviceToken.java b/src/main/java/com/potatocake/everymoment/entity/DeviceToken.java new file mode 100644 index 0000000..ab1ddc6 --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/entity/DeviceToken.java @@ -0,0 +1,48 @@ +package com.potatocake.everymoment.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeviceToken extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(nullable = false) + private Member member; + + @Column(nullable = false) + private String fcmToken; + + @Column(nullable = false) + @Lob + private String deviceId; + + @Builder + public DeviceToken(Member member, String fcmToken, String deviceId) { + this.member = member; + this.fcmToken = fcmToken; + this.deviceId = deviceId; + } + + public void updateToken(String fcmToken) { + this.fcmToken = fcmToken; + } + +} diff --git a/src/main/java/com/potatocake/everymoment/exception/ErrorCode.java b/src/main/java/com/potatocake/everymoment/exception/ErrorCode.java index 19b0d7c..043f2ae 100644 --- a/src/main/java/com/potatocake/everymoment/exception/ErrorCode.java +++ b/src/main/java/com/potatocake/everymoment/exception/ErrorCode.java @@ -15,6 +15,7 @@ public enum ErrorCode { /* Diary */ + DIARY_NOT_PUBLIC("비공개 일기입니다.", HttpStatus.FORBIDDEN), DIARY_NOT_FOUND("존재하지 않는 일기입니다.", NOT_FOUND), /* Member */ @@ -60,7 +61,14 @@ public enum ErrorCode { /* FriendRequestService */ FRIEND_REQUEST_ALREADY_EXISTS("이미 친구 요청을 보냈습니다.", CONFLICT), - FRIEND_REQUEST_NOT_FOUND("존재하지 않는 친구 요청입니다.", NOT_FOUND); + FRIEND_REQUEST_NOT_FOUND("존재하지 않는 친구 요청입니다.", NOT_FOUND), + + /* FCM */ + FCM_TOKEN_NOT_FOUND("FCM 토큰이 존재하지 않습니다.", HttpStatus.NOT_FOUND), + FCM_MESSAGE_SEND_FAILED("FCM 메시지 전송에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + /* Friend */ + ALREADY_FRIEND("이미 친구 관계입니다.", HttpStatus.CONFLICT); private final String message; private final HttpStatus status; diff --git a/src/main/java/com/potatocake/everymoment/repository/DeviceTokenRepository.java b/src/main/java/com/potatocake/everymoment/repository/DeviceTokenRepository.java new file mode 100644 index 0000000..0668494 --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/repository/DeviceTokenRepository.java @@ -0,0 +1,16 @@ +package com.potatocake.everymoment.repository; + +import com.potatocake.everymoment.entity.DeviceToken; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DeviceTokenRepository extends JpaRepository { + + List findAllByMemberId(Long memberId); + + Optional findByMemberIdAndDeviceId(Long memberId, String deviceId); + + void deleteByMemberIdAndDeviceId(Long memberId, String deviceId); + +} diff --git a/src/main/java/com/potatocake/everymoment/service/CommentService.java b/src/main/java/com/potatocake/everymoment/service/CommentService.java index 085ed0c..a5963ba 100644 --- a/src/main/java/com/potatocake/everymoment/service/CommentService.java +++ b/src/main/java/com/potatocake/everymoment/service/CommentService.java @@ -1,6 +1,7 @@ package com.potatocake.everymoment.service; import com.potatocake.everymoment.dto.request.CommentRequest; +import com.potatocake.everymoment.dto.request.FcmNotificationRequest; import com.potatocake.everymoment.dto.response.CommentFriendResponse; import com.potatocake.everymoment.dto.response.CommentResponse; import com.potatocake.everymoment.dto.response.CommentsResponse; @@ -30,6 +31,7 @@ public class CommentService { private final CommentRepository commentRepository; private final DiaryRepository diaryRepository; private final MemberRepository memberRepository; + private final FcmService fcmService; // 댓글 목록 조회 public CommentsResponse getComments(Long diaryId, int key, int size) { @@ -56,6 +58,10 @@ public void createComment(Long memberId, Long diaryId, CommentRequest commentReq Diary diary = diaryRepository.findById(diaryId) .orElseThrow(() -> new GlobalException(ErrorCode.DIARY_NOT_FOUND)); + if (!diary.isPublic()) { + throw new GlobalException(ErrorCode.DIARY_NOT_PUBLIC); + } + Comment comment = Comment.builder() .content(commentRequest.getContent()) .member(currentMember) @@ -63,6 +69,16 @@ public void createComment(Long memberId, Long diaryId, CommentRequest commentReq .build(); commentRepository.save(comment); + + // 다이어리 작성자에게 FCM 알림 전송 (자신의 댓글에는 알림 안보냄) + if (!diary.getMember().getId().equals(memberId)) { + fcmService.sendNotification(diary.getMember().getId(), FcmNotificationRequest.builder() + .title("새로운 댓글") + .body(currentMember.getNickname() + "님이 회원님의 일기에 댓글을 남겼습니다.") + .type("COMMENT") + .targetId(diary.getId()) + .build()); + } } // 댓글 수정 @@ -85,7 +101,7 @@ private Comment getExistComment(Long memberId, Long commentId) { Comment comment = commentRepository.findById(commentId) .orElseThrow(() -> new GlobalException(ErrorCode.COMMENT_NOT_FOUND)); - if(!Objects.equals(currentMember.getId(), comment.getMember().getId())){ + if (!Objects.equals(currentMember.getId(), comment.getMember().getId())) { throw new GlobalException(ErrorCode.COMMENT_NOT_FOUND); } @@ -93,7 +109,7 @@ private Comment getExistComment(Long memberId, Long commentId) { } // 친구 프로필 DTO 변환 - private CommentFriendResponse convertToCommentFriendResponseDTO(Member member){ + private CommentFriendResponse convertToCommentFriendResponseDTO(Member member) { return CommentFriendResponse.builder() .id(member.getId()) .nickname(member.getNickname()) @@ -102,7 +118,7 @@ private CommentFriendResponse convertToCommentFriendResponseDTO(Member member){ } // 댓글 DTO 변환 - private CommentResponse convertToCommentResponseDTO(Comment comment){ + private CommentResponse convertToCommentResponseDTO(Comment comment) { return CommentResponse.builder() .id(comment.getId()) .commentFriendResponse(convertToCommentFriendResponseDTO(comment.getMember())) @@ -110,5 +126,5 @@ private CommentResponse convertToCommentResponseDTO(Comment comment){ .createdAt(comment.getCreateAt()) .build(); } -} +} diff --git a/src/main/java/com/potatocake/everymoment/service/FcmService.java b/src/main/java/com/potatocake/everymoment/service/FcmService.java new file mode 100644 index 0000000..328fce0 --- /dev/null +++ b/src/main/java/com/potatocake/everymoment/service/FcmService.java @@ -0,0 +1,122 @@ +package com.potatocake.everymoment.service; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.Notification; +import com.google.firebase.messaging.SendResponse; +import com.potatocake.everymoment.dto.request.FcmNotificationRequest; +import com.potatocake.everymoment.entity.DeviceToken; +import com.potatocake.everymoment.entity.Member; +import com.potatocake.everymoment.exception.ErrorCode; +import com.potatocake.everymoment.exception.GlobalException; +import com.potatocake.everymoment.repository.DeviceTokenRepository; +import com.potatocake.everymoment.repository.MemberRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class FcmService { + + private final FirebaseMessaging firebaseMessaging; + private final DeviceTokenRepository deviceTokenRepository; + private final MemberRepository memberRepository; + + public void sendNotification(Long targetMemberId, FcmNotificationRequest request) { + List deviceTokens = deviceTokenRepository.findAllByMemberId(targetMemberId); + + if (deviceTokens.isEmpty()) { + throw new GlobalException(ErrorCode.FCM_TOKEN_NOT_FOUND); + } + + List messages = deviceTokens.stream() + .map(token -> Message.builder() + .setToken(token.getFcmToken()) + .setNotification(Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .build()) + .putData("type", request.getType()) + .putData("targetId", request.getTargetId().toString()) + .build()) + .collect(Collectors.toList()); + + try { + BatchResponse response = firebaseMessaging.sendEach(messages); + handleBatchResponse(response, deviceTokens); + } catch (FirebaseMessagingException e) { + log.error("FCM 메시지 전송 실패 : {}", e.getMessage(), e); + throw new GlobalException(ErrorCode.FCM_MESSAGE_SEND_FAILED); + } + } + + private void handleBatchResponse(BatchResponse response, List deviceTokens) { + List tokensToDelete = new ArrayList<>(); + + for (int i = 0; i < response.getResponses().size(); i++) { + SendResponse sendResponse = response.getResponses().get(i); + DeviceToken deviceToken = deviceTokens.get(i); + + if (!sendResponse.isSuccessful()) { + FirebaseMessagingException exception = sendResponse.getException(); + MessagingErrorCode errorCode = exception.getMessagingErrorCode(); + + log.warn("FCM 토큰 전송 실패: {}. 에러 코드: {}, 메시지: {}", + deviceToken.getFcmToken(), + errorCode, + exception.getMessage()); + + if (shouldDeleteToken(errorCode)) { + tokensToDelete.add(deviceToken); + } + } + } + + if (!tokensToDelete.isEmpty()) { + log.info("유효하지 않은 FCM 토큰 {} 개를 삭제합니다", tokensToDelete.size()); + deviceTokenRepository.deleteAll(tokensToDelete); + } + + log.info("FCM 일괄 전송 결과 - 성공: {}, 실패: {}", + response.getSuccessCount(), + response.getFailureCount()); + } + + private boolean shouldDeleteToken(MessagingErrorCode errorCode) { + return errorCode == MessagingErrorCode.UNREGISTERED || + errorCode == MessagingErrorCode.INVALID_ARGUMENT || + errorCode == MessagingErrorCode.SENDER_ID_MISMATCH || + errorCode == MessagingErrorCode.THIRD_PARTY_AUTH_ERROR; + } + + public void registerToken(Long memberId, String deviceId, String fcmToken) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + + deviceTokenRepository.findByMemberIdAndDeviceId(memberId, deviceId) + .ifPresentOrElse( + deviceToken -> deviceToken.updateToken(fcmToken), + () -> deviceTokenRepository.save(DeviceToken.builder() + .member(member) + .deviceId(deviceId) + .fcmToken(fcmToken) + .build()) + + ); + } + + public void removeToken(Long memberId, String deviceId) { + deviceTokenRepository.deleteByMemberIdAndDeviceId(memberId, deviceId); + } + +} diff --git a/src/main/java/com/potatocake/everymoment/service/FriendRequestService.java b/src/main/java/com/potatocake/everymoment/service/FriendRequestService.java index 89f99a9..4354f69 100644 --- a/src/main/java/com/potatocake/everymoment/service/FriendRequestService.java +++ b/src/main/java/com/potatocake/everymoment/service/FriendRequestService.java @@ -3,6 +3,7 @@ import static java.util.function.Function.identity; import static org.springframework.data.domain.Sort.Direction.DESC; +import com.potatocake.everymoment.dto.request.FcmNotificationRequest; import com.potatocake.everymoment.dto.response.FriendRequestPageRequest; import com.potatocake.everymoment.dto.response.FriendRequestResponse; import com.potatocake.everymoment.entity.Friend; @@ -16,6 +17,7 @@ import com.potatocake.everymoment.util.PagingUtil; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -34,6 +36,7 @@ public class FriendRequestService { private final MemberRepository memberRepository; private final FriendRepository friendRepository; private final PagingUtil pagingUtil; + private final FcmService fcmService; @Transactional(readOnly = true) public FriendRequestPageRequest getFriendRequests(Long key, int size, Long memberId) { @@ -48,22 +51,52 @@ public FriendRequestPageRequest getFriendRequests(Long key, int size, Long membe } public void sendFriendRequest(Long senderId, Long receiverId) { - boolean isAlreadySend = friendRequestRepository.existsBySenderIdAndReceiverId(senderId, receiverId); + // 이미 친구인 경우 체크 + if (friendRepository.existsByMemberIdAndFriendId(senderId, receiverId)) { + throw new GlobalException(ErrorCode.ALREADY_FRIEND); + } - if (isAlreadySend) { + // 이미 친구 요청을 보낸 경우 체크 + if (friendRequestRepository.existsBySenderIdAndReceiverId(senderId, receiverId)) { throw new GlobalException(ErrorCode.FRIEND_REQUEST_ALREADY_EXISTS); } Member sender = memberRepository.findById(senderId) .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); - Member receiver = memberRepository.findById(receiverId) .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); - friendRequestRepository.save(FriendRequest.builder() - .sender(sender) - .receiver(receiver) - .build()); + // 상대방이 나에게 보낸 친구 요청이 있는지 확인 + Optional oppositeRequest = friendRequestRepository + .findBySenderIdAndReceiverId(receiverId, senderId); + + if (oppositeRequest.isPresent()) { + // 상대방이 이미 나에게 친구 요청을 보낸 상태라면 자동으로 수락 처리 + createFriendRelationship(receiver, sender); + friendRequestRepository.delete(oppositeRequest.get()); + + // 상대방에게 친구 수락 알림 발송 + fcmService.sendNotification(receiverId, FcmNotificationRequest.builder() + .title("친구 요청 수락") + .body(sender.getNickname() + "님이 친구 요청을 수락했습니다.") + .type("FRIEND_ACCEPT") + .targetId(sender.getId()) + .build()); + } else { + // 상대방이 보낸 요청이 없다면 새로운 친구 요청 생성 + FriendRequest friendRequest = friendRequestRepository.save(FriendRequest.builder() + .sender(sender) + .receiver(receiver) + .build()); + + // 상대방에게 친구 요청 알림 발송 + fcmService.sendNotification(receiverId, FcmNotificationRequest.builder() + .title("새로운 친구 요청") + .body(sender.getNickname() + "님이 친구 요청을 보냈습니다.") + .type("FRIEND_REQUEST") + .targetId(friendRequest.getId()) + .build()); + } } public void acceptFriendRequest(Long requestId, Long memberId) { @@ -76,6 +109,14 @@ public void acceptFriendRequest(Long requestId, Long memberId) { friendRepository.save(friend2); friendRequestRepository.delete(friendRequest); + + // 알림 발송 + fcmService.sendNotification(friendRequest.getSender().getId(), FcmNotificationRequest.builder() + .title("친구 요청 수락") + .body(friendRequest.getReceiver().getNickname() + "님이 친구 요청을 수락했습니다.") + .type("FRIEND_ACCEPT") + .targetId(friendRequest.getReceiver().getId()) + .build()); } public void rejectFriendRequest(Long requestId, Long memberId) { @@ -84,6 +125,21 @@ public void rejectFriendRequest(Long requestId, Long memberId) { friendRequestRepository.delete(friendRequest); } + private void createFriendRelationship(Member member1, Member member2) { + Friend friend1 = Friend.builder() + .member(member1) + .friend(member2) + .build(); + + Friend friend2 = Friend.builder() + .member(member2) + .friend(member1) + .build(); + + friendRepository.save(friend1); + friendRepository.save(friend2); + } + private FriendRequest findAndValidateFriendRequest(Long requestId, Long memberId) { FriendRequest friendRequest = friendRequestRepository.findById(requestId) .orElseThrow(() -> new GlobalException(ErrorCode.FRIEND_REQUEST_NOT_FOUND)); diff --git a/src/main/java/com/potatocake/everymoment/service/LikeService.java b/src/main/java/com/potatocake/everymoment/service/LikeService.java index 5e9bc3d..8d8a45f 100644 --- a/src/main/java/com/potatocake/everymoment/service/LikeService.java +++ b/src/main/java/com/potatocake/everymoment/service/LikeService.java @@ -1,5 +1,6 @@ package com.potatocake.everymoment.service; +import com.potatocake.everymoment.dto.request.FcmNotificationRequest; import com.potatocake.everymoment.dto.response.LikeCountResponse; import com.potatocake.everymoment.entity.Diary; import com.potatocake.everymoment.entity.Like; @@ -22,6 +23,7 @@ public class LikeService { private final LikeRepository likeRepository; private final DiaryRepository diaryRepository; private final MemberRepository memberRepository; + private final FcmService fcmService; @Transactional(readOnly = true) public LikeCountResponse getLikeCount(Long diaryId) { @@ -38,6 +40,11 @@ public LikeCountResponse getLikeCount(Long diaryId) { public void toggleLike(Long memberId, Long diaryId) { Diary diary = diaryRepository.findById(diaryId) .orElseThrow(() -> new GlobalException(ErrorCode.DIARY_NOT_FOUND)); + + if (!diary.isPublic()) { + throw new GlobalException(ErrorCode.DIARY_NOT_PUBLIC); + } + Member member = memberRepository.findById(memberId) .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); @@ -54,6 +61,18 @@ public void toggleLike(Long memberId, Long diaryId) { .build(); likeRepository.save(likeEntity); + + if (!diary.getMember().getId().equals(memberId)) { + // 좋아요를 눌렀을 때만 (취소 제외), 그리고 자신의 게시물이 아닐 때만 알림 발송 + fcmService.sendNotification(diary.getMember().getId(), + FcmNotificationRequest.builder() + .title("새로운 좋아요") + .body(member.getNickname() + "님이 회원님의 일기를 좋아합니다.") + .type("LIKE") + .targetId(diary.getId()) + .build() + ); + } } } diff --git a/src/test/java/com/potatocake/everymoment/EverymomentApplicationTests.java b/src/test/java/com/potatocake/everymoment/EverymomentApplicationTests.java index a277c76..6705d38 100644 --- a/src/test/java/com/potatocake/everymoment/EverymomentApplicationTests.java +++ b/src/test/java/com/potatocake/everymoment/EverymomentApplicationTests.java @@ -6,8 +6,8 @@ @SpringBootTest class EverymomentApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/potatocake/everymoment/controller/MemberControllerTest.java b/src/test/java/com/potatocake/everymoment/controller/MemberControllerTest.java index c5ac5df..d24b883 100644 --- a/src/test/java/com/potatocake/everymoment/controller/MemberControllerTest.java +++ b/src/test/java/com/potatocake/everymoment/controller/MemberControllerTest.java @@ -7,9 +7,11 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -22,8 +24,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mock.web.MockMultipartFile; import org.springframework.security.test.context.support.WithMockUser; @@ -32,8 +33,7 @@ import org.springframework.web.multipart.MultipartFile; @WithMockUser -@AutoConfigureMockMvc -@SpringBootTest +@WebMvcTest(MemberController.class) class MemberControllerTest { @Autowired @@ -146,13 +146,15 @@ void should_UpdateMemberInfo_When_ValidInput() throws Exception { @DisplayName("프로필 이미지와 닉네임이 모두 누락되면 예외가 발생한다.") void should_ThrowException_When_ProfileImageAndNicknameAreMissing() throws Exception { // when - ResultActions result = mockMvc.perform(multipart("/api/members")); + ResultActions result = mockMvc.perform(multipart("/api/members") + .with(csrf())); // then result .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(INFO_REQUIRED.getStatus().value())) - .andExpect(jsonPath("$.message").value(INFO_REQUIRED.getMessage())); + .andExpect(jsonPath("$.message").value(INFO_REQUIRED.getMessage())) + .andDo(print()); then(memberService).shouldHaveNoInteractions(); } @@ -180,7 +182,8 @@ private ResultActions performMultipart(String url, MockMultipartFile file, Strin return mockMvc.perform(multipart(url) .file(file) .param("nickname", nickname) - .with(user(memberDetails))); + .with(user(memberDetails)) + .with(csrf())); } }