diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 70ca78f..fc1d031 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,9 @@ jobs: touch ./src/main/resources/forbidden-words.txt echo "${{ secrets.FORBIDDEN_TXT }}" | base64 --decode > src/main/resources/forbidden-words.txt + + touch ./src/main/resources/services-account.json + echo "${{ secrets.SERVICES_ACCOUNT_JSON }}" | base64 --decode > src/main/resources/services-account.json #추가 - name: Make Gradle Wrapper script executable diff --git a/.gitignore b/.gitignore index 04bc01d..78958d2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ out/ /src/main/resources/application-auth.yml /src/main/resources/forbidden-words.txt +/src/main/resources/services-account.json diff --git a/build.gradle b/build.gradle index fbd065b..149ed5b 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,10 @@ dependencies { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + //fcm + implementation 'com.google.firebase:firebase-admin:6.8.1' + implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2' } tasks.named('test') { diff --git a/src/main/java/depth/jeonsilog/domain/alarm/application/AlarmService.java b/src/main/java/depth/jeonsilog/domain/alarm/application/AlarmService.java index b68404a..8f670f5 100644 --- a/src/main/java/depth/jeonsilog/domain/alarm/application/AlarmService.java +++ b/src/main/java/depth/jeonsilog/domain/alarm/application/AlarmService.java @@ -8,6 +8,7 @@ import depth.jeonsilog.domain.exhibition.domain.Exhibition; import depth.jeonsilog.domain.exhibition.domain.OperatingKeyword; import depth.jeonsilog.domain.exhibition.domain.repository.ExhibitionRepository; +import depth.jeonsilog.domain.fcm.application.FcmService; import depth.jeonsilog.domain.follow.domain.Follow; import depth.jeonsilog.domain.follow.domain.repository.FollowRepository; import depth.jeonsilog.domain.interest.domain.Interest; @@ -24,7 +25,6 @@ import depth.jeonsilog.global.payload.Message; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; @@ -33,6 +33,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; @@ -53,6 +54,7 @@ public class AlarmService { private final UserRepository userRepository; private final ExhibitionRepository exhibitionRepository; private final UserService userService; + private final FcmService fcmService; private static String BEFORE_7DAYS = "전시 시작까지 7일 남았어요"; private static String BEFORE_3DAYS = "전시 시작까지 3일 남았어요"; @@ -156,11 +158,12 @@ public ResponseEntity checkAlarm(UserPrincipal userPrincipal, Long alarmId) { // TODO: 팔로잉한 사람의 new 감상평 -> 알림 생성 @Transactional - public void makeReviewAlarm(Review review) { + public void makeReviewAlarm(Review review) throws IOException { List follows = followRepository.findAllByFollow(review.getUser()); for (Follow follow : follows) { + User receiver = follow.getUser(); Alarm alarm = Alarm.builder() - .user(follow.getUser()) + .user(receiver) .senderId(follow.getFollow().getId()) .alarmType(AlarmType.REVIEW) .targetId(review.getId()) @@ -168,16 +171,20 @@ public void makeReviewAlarm(Review review) { .isChecked(false) .build(); alarmRepository.save(alarm); + + if (!receiver.getIsRecvActive() || receiver.getFcmToken() == null) return; + fcmService.send(receiver.getFcmToken(), "전시로그", follow.getFollow().getNickname() + " 님이 감상평 남겼어요"); } } - // 팔로잉한 사람의 new 별점 -> 알림 생성 + // TODO: 팔로잉한 사람의 new 별점 -> 알림 생성 @Transactional - public void makeRatingAlarm(Rating rating) { + public void makeRatingAlarm(Rating rating) throws IOException { List follows = followRepository.findAllByFollow(rating.getUser()); for (Follow follow : follows) { + User receiver = follow.getUser(); Alarm alarm = Alarm.builder() - .user(follow.getUser()) + .user(receiver) .senderId(follow.getFollow().getId()) .alarmType(AlarmType.RATING) .targetId(rating.getId()) @@ -185,14 +192,18 @@ public void makeRatingAlarm(Rating rating) { .isChecked(false) .build(); alarmRepository.save(alarm); + + if (!receiver.getIsRecvActive() || receiver.getFcmToken() == null) return; + fcmService.send(receiver.getFcmToken(), "전시로그", follow.getFollow().getNickname() + " 님이 별점을 남겼어요"); } } - // 나의 감상평에 달린 댓글 -> 알림 생성 + // TODO: 나의 감상평에 달린 댓글 -> 알림 생성 @Transactional - public void makeReplyAlarm(Reply reply) { + public void makeReplyAlarm(Reply reply) throws IOException { + User receiver = reply.getReview().getUser(); Alarm alarm = Alarm.builder() - .user(reply.getReview().getUser()) + .user(receiver) .senderId(reply.getUser().getId()) .alarmType(AlarmType.REPLY) .targetId(reply.getId()) @@ -200,13 +211,17 @@ public void makeReplyAlarm(Reply reply) { .isChecked(false) .build(); alarmRepository.save(alarm); + + if (!receiver.getIsRecvActive() || receiver.getFcmToken() == null) return; + fcmService.send(receiver.getFcmToken(), "전시로그", reply.getUser().getNickname() + " 님이 댓글을 남겼어요"); } - // 나를 팔로우 -> 알림 생성 + // TODO: 나를 팔로우 -> 알림 생성 @Transactional - public void makeFollowAlarm(Follow follow) { + public void makeFollowAlarm(Follow follow) throws IOException { + User receiver = follow.getFollow(); Alarm alarm = Alarm.builder() - .user(follow.getFollow()) + .user(receiver) .senderId(follow.getUser().getId()) .alarmType(AlarmType.FOLLOW) .targetId(follow.getId()) @@ -214,9 +229,12 @@ public void makeFollowAlarm(Follow follow) { .isChecked(false) .build(); alarmRepository.save(alarm); + + if (!receiver.getIsRecvActive() || receiver.getFcmToken() == null) return; + fcmService.send(receiver.getFcmToken(), "전시로그", follow.getUser().getNickname() + " 님이 나를 팔로우해요"); } - // 관심 전시회 시작 전 -> 알림 생성 + // TODO: 관심 전시회 시작 전 -> 알림 생성 @Transactional @Scheduled(cron = "0 0 9 * * *") // 오전 9시에 실행 public void makeExhibitionAlarm() { @@ -226,6 +244,7 @@ public void makeExhibitionAlarm() { log.info("현재 날짜: " + currentDate); for (Interest interest : interests) { Exhibition exhibition = interest.getExhibition(); + User receiver = interest.getUser(); LocalDate targetDate = LocalDate.parse(exhibition.getStartDate(), DateTimeFormatter.ofPattern("yyyyMMdd")); log.info("전시회 시작 날짜: " + targetDate); @@ -233,17 +252,20 @@ public void makeExhibitionAlarm() { Alarm alarm = null; long daysDifference = ChronoUnit.DAYS.between(currentDate, targetDate); if (daysDifference == 7) { - if (checkDuplicateAlarm(interest.getUser().getId(), interest.getExhibition().getId(), BEFORE_7DAYS)) continue; + if (checkDuplicateAlarm(receiver.getId(), exhibition.getId(), BEFORE_7DAYS)) continue; alarm = AlarmConverter.toExhibitionAlarm(interest, BEFORE_7DAYS); } else if (daysDifference == 3) { - if (checkDuplicateAlarm(interest.getUser().getId(), interest.getExhibition().getId(), BEFORE_3DAYS)) continue; + if (checkDuplicateAlarm(receiver.getId(), exhibition.getId(), BEFORE_3DAYS)) continue; alarm = AlarmConverter.toExhibitionAlarm(interest, BEFORE_3DAYS); } else if (daysDifference == 1) { - if (checkDuplicateAlarm(interest.getUser().getId(), interest.getExhibition().getId(), BEFORE_1DAYS)) continue; + if (checkDuplicateAlarm(receiver.getId(), exhibition.getId(), BEFORE_1DAYS)) continue; alarm = AlarmConverter.toExhibitionAlarm(interest, BEFORE_1DAYS); } assert alarm != null; alarmRepository.save(alarm); + + if (!receiver.getIsRecvExhibition() || receiver.getFcmToken() == null) return; + fcmService.send(receiver.getFcmToken(), "전시로그", "[" + exhibition.getName() + "]\n" + alarm.getContents()); } } diff --git a/src/main/java/depth/jeonsilog/domain/auth/converter/AuthConverter.java b/src/main/java/depth/jeonsilog/domain/auth/converter/AuthConverter.java index f2dbdff..728f3f4 100644 --- a/src/main/java/depth/jeonsilog/domain/auth/converter/AuthConverter.java +++ b/src/main/java/depth/jeonsilog/domain/auth/converter/AuthConverter.java @@ -23,7 +23,7 @@ public static User toUser(AuthRequestDto.SignUpReq signUpReq, PasswordEncoder pa .provider(Provider.KAKAO) .role(Role.USER) .isOpen(true) - .isRecvFollowing(true) + .isRecvExhibition(true) .isRecvActive(true) .build(); } diff --git a/src/main/java/depth/jeonsilog/domain/exhibition/application/ExhibitionService.java b/src/main/java/depth/jeonsilog/domain/exhibition/application/ExhibitionService.java index de798b8..df86147 100644 --- a/src/main/java/depth/jeonsilog/domain/exhibition/application/ExhibitionService.java +++ b/src/main/java/depth/jeonsilog/domain/exhibition/application/ExhibitionService.java @@ -191,7 +191,7 @@ public ResponseEntity updateExhibitionDetail(UserPrincipal userPrincipal, Exh String storedFileName = null; if (updateExhibitionDetailReq.getIsImageChange()) { // 이미지를 변경하는 경우 - if (!img.isEmpty()) { // 이미지 삭제가 아닌 이미지를 변경하거나 추가하는 경우 + if (img != null) { // 이미지 삭제가 아닌 이미지를 변경하거나 추가하는 경우 storedFileName = s3Uploader.upload(img, DIRNAME); } // 기존 포스터 이미지가 s3에 있으면, 이미지 삭제 / 없으면(OPEN API 포스터 이미지 or NULL의 경우) 말고 diff --git a/src/main/java/depth/jeonsilog/domain/fcm/application/FcmService.java b/src/main/java/depth/jeonsilog/domain/fcm/application/FcmService.java new file mode 100644 index 0000000..2037e34 --- /dev/null +++ b/src/main/java/depth/jeonsilog/domain/fcm/application/FcmService.java @@ -0,0 +1,71 @@ +package depth.jeonsilog.domain.fcm.application; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONObject; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import java.io.FileInputStream; +import java.io.IOException; +import java.util.Arrays; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FcmService { + + // API_URL은 메세지 전송을 위해 요청하는 주소이다. {프로젝트 ID}넣기 + private static final String API_URL = "https://fcm.googleapis.com/v1/projects/jeonsilog-fd54e/messages:send"; + private static final String MESSAGING_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"; + private static final String[] SCOPES = { MESSAGING_SCOPE }; + + //AccessToken 발급 받기. -> Header에 포함하여 푸시 알림 요청 + private static String getAccessToken() throws IOException { + ClassPathResource resource = new ClassPathResource("services-account.json"); + GoogleCredential googleCredential = GoogleCredential + .fromStream(new FileInputStream(resource.getFile())) + .createScoped(Arrays.asList(SCOPES)); + googleCredential.refreshToken(); + log.info("액세스 토큰 발급: " + googleCredential.getAccessToken()); + return googleCredential.getAccessToken(); + } + + public void send(String fcmToken, String title, String body) { + + // 1. create message body + JSONObject jsonValue = new JSONObject(); + jsonValue.put("title", title); + jsonValue.put("body", body); + + JSONObject jsonData = new JSONObject(); + jsonData.put("token", fcmToken); + jsonData.put("data", jsonValue); + + JSONObject jsonMessage = new JSONObject(); + jsonMessage.put("message", jsonData); + + // 2. create token & send push + try { + OkHttpClient okHttpClient = new OkHttpClient(); + Request request = new Request.Builder() + .addHeader("Authorization", "Bearer " + getAccessToken()) + .addHeader("Content-Type", "application/json; UTF-8") + .url(API_URL) + .post(RequestBody.create(jsonMessage.toString(), MediaType.parse("application/json"))) + .build(); + Response response = okHttpClient.newCall(request).execute(); + + log.info("### response str : " + response.toString()); + log.info("### response result : " + response.isSuccessful()); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/depth/jeonsilog/domain/follow/application/FollowService.java b/src/main/java/depth/jeonsilog/domain/follow/application/FollowService.java index 0e3624b..42b6aad 100644 --- a/src/main/java/depth/jeonsilog/domain/follow/application/FollowService.java +++ b/src/main/java/depth/jeonsilog/domain/follow/application/FollowService.java @@ -12,7 +12,6 @@ import depth.jeonsilog.global.payload.ApiResponse; import depth.jeonsilog.global.payload.Message; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; @@ -20,6 +19,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -35,7 +35,7 @@ public class FollowService { // 팔로우하기 @Transactional - public ResponseEntity follow(UserPrincipal userPrincipal, Long userId) { + public ResponseEntity follow(UserPrincipal userPrincipal, Long userId) throws IOException { User findUser = userService.validateUserByToken(userPrincipal); User followUser = userService.validateUserById(userId); diff --git a/src/main/java/depth/jeonsilog/domain/follow/presentation/FollowController.java b/src/main/java/depth/jeonsilog/domain/follow/presentation/FollowController.java index 805dceb..5b3b1ac 100644 --- a/src/main/java/depth/jeonsilog/domain/follow/presentation/FollowController.java +++ b/src/main/java/depth/jeonsilog/domain/follow/presentation/FollowController.java @@ -19,6 +19,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; + @Tag(name = "Follow API", description = "Follow 관련 API입니다.") @RequiredArgsConstructor @RestController @@ -36,7 +38,7 @@ public class FollowController { public ResponseEntity follow( @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, @Parameter(description = "팔로우할 유저의 ID를 입력해주세요.", required = true) @PathVariable(value = "userId") Long userId - ) { + ) throws IOException { return followService.follow(userPrincipal, userId); } diff --git a/src/main/java/depth/jeonsilog/domain/rating/application/RatingService.java b/src/main/java/depth/jeonsilog/domain/rating/application/RatingService.java index 31d6c72..ed13d58 100644 --- a/src/main/java/depth/jeonsilog/domain/rating/application/RatingService.java +++ b/src/main/java/depth/jeonsilog/domain/rating/application/RatingService.java @@ -8,7 +8,6 @@ import depth.jeonsilog.domain.rating.domain.repository.RatingRepository; import depth.jeonsilog.domain.rating.dto.RatingRequestDto; import depth.jeonsilog.domain.rating.dto.RatingResponseDto; -import depth.jeonsilog.domain.review.domain.Review; import depth.jeonsilog.domain.user.application.UserService; import depth.jeonsilog.domain.user.domain.User; import depth.jeonsilog.global.DefaultAssert; @@ -23,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -39,7 +39,7 @@ public class RatingService { // 별점 등록 @Transactional - public ResponseEntity registerRating(UserPrincipal userPrincipal, RatingRequestDto.RatingReq ratingReq) { + public ResponseEntity registerRating(UserPrincipal userPrincipal, RatingRequestDto.RatingReq ratingReq) throws IOException { User findUser = userService.validateUserByToken(userPrincipal); Exhibition exhibition = exhibitionService.validateExhibitionById(ratingReq.getExhibitionId()); diff --git a/src/main/java/depth/jeonsilog/domain/rating/presentation/RatingController.java b/src/main/java/depth/jeonsilog/domain/rating/presentation/RatingController.java index f50a15d..4dfa4c6 100644 --- a/src/main/java/depth/jeonsilog/domain/rating/presentation/RatingController.java +++ b/src/main/java/depth/jeonsilog/domain/rating/presentation/RatingController.java @@ -1,6 +1,5 @@ package depth.jeonsilog.domain.rating.presentation; -import depth.jeonsilog.domain.place.dto.PlaceResponseDto; import depth.jeonsilog.domain.rating.application.RatingService; import depth.jeonsilog.domain.rating.dto.RatingRequestDto; import depth.jeonsilog.domain.rating.dto.RatingResponseDto; @@ -10,7 +9,6 @@ import depth.jeonsilog.global.payload.Message; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -21,6 +19,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; + @Tag(name = "Rating API", description = "Rating 관련 API입니다.") @RestController @RequiredArgsConstructor @@ -38,7 +38,7 @@ public class RatingController { public ResponseEntity registerInterest( @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, @Parameter(description = "Schemas의 RatingReq를 참고해주세요.", required = true) @Valid @RequestBody RatingRequestDto.RatingReq ratingReq - ) { + ) throws IOException { return ratingService.registerRating(userPrincipal, ratingReq); } diff --git a/src/main/java/depth/jeonsilog/domain/reply/application/ReplyService.java b/src/main/java/depth/jeonsilog/domain/reply/application/ReplyService.java index d547bf1..4bedf83 100644 --- a/src/main/java/depth/jeonsilog/domain/reply/application/ReplyService.java +++ b/src/main/java/depth/jeonsilog/domain/reply/application/ReplyService.java @@ -17,7 +17,6 @@ import depth.jeonsilog.global.payload.ApiResponse; import depth.jeonsilog.global.payload.Message; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; @@ -25,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -59,7 +59,7 @@ public ResponseEntity findReplyList(Integer page, Long reviewId) { // Description : 댓글 작성 @Transactional - public ResponseEntity createReply(UserPrincipal userPrincipal, ReplyRequestDto.CreateReplyReq createReplyReq) { + public ResponseEntity createReply(UserPrincipal userPrincipal, ReplyRequestDto.CreateReplyReq createReplyReq) throws IOException { Review review = reviewService.validateReviewById(createReplyReq.getReviewId()); User user = userService.validateUserByToken(userPrincipal); diff --git a/src/main/java/depth/jeonsilog/domain/reply/domain/repository/ReplyRepository.java b/src/main/java/depth/jeonsilog/domain/reply/domain/repository/ReplyRepository.java index b6aa836..bfadd92 100644 --- a/src/main/java/depth/jeonsilog/domain/reply/domain/repository/ReplyRepository.java +++ b/src/main/java/depth/jeonsilog/domain/reply/domain/repository/ReplyRepository.java @@ -2,9 +2,12 @@ import depth.jeonsilog.domain.reply.domain.Reply; import depth.jeonsilog.domain.review.domain.Review; +import depth.jeonsilog.domain.user.domain.User; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -16,6 +19,10 @@ public interface ReplyRepository extends JpaRepository { List findByReview(Review review); - List findAllByUserId(Long userId); + @Query(value = "SELECT * FROM reply WHERE review_id = :reviewId", nativeQuery = true) + List findAllRepliesByReviewId(@Param("reviewId") Long reviewId); + + @Query(value = "SELECT * FROM reply WHERE user_id = :userId", nativeQuery = true) + List findAllRepliesByUserId(@Param("userId") Long userId); } diff --git a/src/main/java/depth/jeonsilog/domain/reply/presentation/ReplyController.java b/src/main/java/depth/jeonsilog/domain/reply/presentation/ReplyController.java index 5f2ec12..a285394 100644 --- a/src/main/java/depth/jeonsilog/domain/reply/presentation/ReplyController.java +++ b/src/main/java/depth/jeonsilog/domain/reply/presentation/ReplyController.java @@ -1,7 +1,5 @@ package depth.jeonsilog.domain.reply.presentation; -import depth.jeonsilog.domain.exhibition.dto.ExhibitionRequestDto; -import depth.jeonsilog.domain.exhibition.dto.ExhibitionResponseDto; import depth.jeonsilog.domain.reply.application.ReplyService; import depth.jeonsilog.domain.reply.dto.ReplyRequestDto; import depth.jeonsilog.domain.reply.dto.ReplyResponseDto; @@ -11,7 +9,6 @@ import depth.jeonsilog.global.payload.Message; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -21,6 +18,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; + @Tag(name = "Reply API", description = "Reply 관련 API입니다.") @RequiredArgsConstructor @RestController @@ -53,7 +52,7 @@ public ResponseEntity findReplyList( public ResponseEntity createReply( @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, @Parameter(description = "Schemas의 CreateReplyReq를 참고해주세요", required = true) @RequestBody ReplyRequestDto.CreateReplyReq createReplyReq - ) { + ) throws IOException { return replyService.createReply(userPrincipal, createReplyReq); } diff --git a/src/main/java/depth/jeonsilog/domain/review/application/ReviewService.java b/src/main/java/depth/jeonsilog/domain/review/application/ReviewService.java index 7f94eee..2be9400 100644 --- a/src/main/java/depth/jeonsilog/domain/review/application/ReviewService.java +++ b/src/main/java/depth/jeonsilog/domain/review/application/ReviewService.java @@ -3,14 +3,8 @@ import depth.jeonsilog.domain.alarm.application.AlarmService; import depth.jeonsilog.domain.common.Status; -import depth.jeonsilog.domain.exhibition.application.ExhibitionService; -import depth.jeonsilog.domain.exhibition.converter.ExhibitionConverter; import depth.jeonsilog.domain.exhibition.domain.Exhibition; import depth.jeonsilog.domain.exhibition.domain.repository.ExhibitionRepository; -import depth.jeonsilog.domain.exhibition.dto.ExhibitionResponseDto; -import depth.jeonsilog.domain.place.converter.PlaceConverter; -import depth.jeonsilog.domain.place.domain.Place; -import depth.jeonsilog.domain.place.dto.PlaceResponseDto; import depth.jeonsilog.domain.rating.application.RatingService; import depth.jeonsilog.domain.rating.domain.Rating; import depth.jeonsilog.domain.rating.domain.repository.RatingRepository; @@ -39,6 +33,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.util.List; import java.util.Optional; @@ -53,13 +48,12 @@ public class ReviewService { private final ReplyRepository replyRepository; private final UserService userService; - private final ExhibitionService exhibitionService; private final AlarmService alarmService; private final RatingService ratingService; // 감상평 작성 @Transactional - public ResponseEntity writeReview(UserPrincipal userPrincipal, ReviewRequestDto.WriteReviewReq writeReviewReq) { + public ResponseEntity writeReview(UserPrincipal userPrincipal, ReviewRequestDto.WriteReviewReq writeReviewReq) throws IOException { User findUser = userService.validateUserByToken(userPrincipal); Optional exhibition = exhibitionRepository.findById(writeReviewReq.getExhibitionId()); DefaultAssert.isTrue(exhibition.isPresent(), "전시회 id가 올바르지 않습니다."); diff --git a/src/main/java/depth/jeonsilog/domain/review/domain/repository/ReviewRepository.java b/src/main/java/depth/jeonsilog/domain/review/domain/repository/ReviewRepository.java index 1155b77..8252449 100644 --- a/src/main/java/depth/jeonsilog/domain/review/domain/repository/ReviewRepository.java +++ b/src/main/java/depth/jeonsilog/domain/review/domain/repository/ReviewRepository.java @@ -1,10 +1,13 @@ package depth.jeonsilog.domain.review.domain.repository; import depth.jeonsilog.domain.review.domain.Review; +import depth.jeonsilog.domain.user.domain.User; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -13,7 +16,8 @@ @Repository public interface ReviewRepository extends JpaRepository { - List findAllByUserId(Long userId); + @Query(value = "SELECT * FROM review WHERE user_id = :userId", nativeQuery = true) + List findAllReviewsByUserId(@Param("userId") Long userId); Optional findByUserIdAndExhibitionId(Long userId, Long exhibitionId); diff --git a/src/main/java/depth/jeonsilog/domain/review/presentation/ReviewController.java b/src/main/java/depth/jeonsilog/domain/review/presentation/ReviewController.java index 0d2c14b..f87ecd2 100644 --- a/src/main/java/depth/jeonsilog/domain/review/presentation/ReviewController.java +++ b/src/main/java/depth/jeonsilog/domain/review/presentation/ReviewController.java @@ -1,6 +1,5 @@ package depth.jeonsilog.domain.review.presentation; -import depth.jeonsilog.domain.rating.dto.RatingResponseDto; import depth.jeonsilog.domain.review.application.ReviewService; import depth.jeonsilog.domain.review.dto.ReviewRequestDto; import depth.jeonsilog.domain.review.dto.ReviewResponseDto; @@ -10,7 +9,6 @@ import depth.jeonsilog.global.payload.Message; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -21,6 +19,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.io.IOException; + @Tag(name = "Review API", description = "Review 관련 API입니다.") @RestController @RequiredArgsConstructor @@ -38,7 +38,7 @@ public class ReviewController { public ResponseEntity writeReview( @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, @Parameter(description = "Schemas의 WriteReviewReq 를 참고해주세요.", required = true) @Valid @RequestBody ReviewRequestDto.WriteReviewReq writeReviewReq - ) { + ) throws IOException { return reviewService.writeReview(userPrincipal, writeReviewReq); } diff --git a/src/main/java/depth/jeonsilog/domain/user/application/UserService.java b/src/main/java/depth/jeonsilog/domain/user/application/UserService.java index c63f6ea..ac44be9 100644 --- a/src/main/java/depth/jeonsilog/domain/user/application/UserService.java +++ b/src/main/java/depth/jeonsilog/domain/user/application/UserService.java @@ -1,6 +1,5 @@ package depth.jeonsilog.domain.user.application; -import depth.jeonsilog.domain.alarm.domain.Alarm; import depth.jeonsilog.domain.auth.domain.Token; import depth.jeonsilog.domain.auth.domain.repository.TokenRepository; import depth.jeonsilog.domain.common.Status; @@ -9,10 +8,8 @@ import depth.jeonsilog.domain.interest.domain.repository.InterestRepository; import depth.jeonsilog.domain.rating.domain.Rating; import depth.jeonsilog.domain.rating.domain.repository.RatingRepository; -import depth.jeonsilog.domain.reply.converter.ReplyConverter; import depth.jeonsilog.domain.reply.domain.Reply; import depth.jeonsilog.domain.reply.domain.repository.ReplyRepository; -import depth.jeonsilog.domain.reply.dto.ReplyResponseDto; import depth.jeonsilog.domain.review.domain.Review; import depth.jeonsilog.domain.review.domain.repository.ReviewRepository; import depth.jeonsilog.domain.s3.application.S3Uploader; @@ -26,7 +23,6 @@ import depth.jeonsilog.global.payload.ApiResponse; import depth.jeonsilog.global.payload.Message; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; @@ -100,7 +96,7 @@ public ResponseEntity changeNickname(UserPrincipal userPrincipal, UserRequest public ResponseEntity changeProfile(UserPrincipal userPrincipal, MultipartFile img) throws IOException { User findUser = validateUserByToken(userPrincipal); - if (!img.isEmpty()) { + if (img != null) { String storedFileName = s3Uploader.upload(img, DIRNAME); findUser.updateProfileImg(storedFileName); } @@ -139,17 +135,23 @@ public ResponseEntity deleteUser(UserPrincipal userPrincipal) { Long userId = findUser.getId(); // 즐겨찾기, 별점, 감상평, 댓글 DELETE 처리 - List reviews = reviewRepository.findAllByUserId(userId); + List reviews = reviewRepository.findAllReviewsByUserId(userId); List ratings = ratingRepository.findAllByUserId(userId); List interests = interestRepository.findAllByUserId(userId); // review의 replies도 for (Review review : reviews) { - List replyByReview = replyRepository.findByReview(review); + List replyByReview = replyRepository.findAllRepliesByReviewId(review.getId()); + review.updateNumReply(review.getNumReply() - replyByReview.size()); replyRepository.deleteAll(replyByReview); } + // 얘만 여기 있는 이유 : 바로 위에서 지운 reply와 겹치지 않도록 하기 위함 - List replyByUser = replyRepository.findAllByUserId(userId); + List replyByUser = replyRepository.findAllRepliesByUserId(userId); + for (Reply reply : replyByUser) { + Review review = reply.getReview(); + review.updateNumReply(review.getNumReply() - 1); + } replyRepository.deleteAll(replyByUser); // soft delete를 통한 별점 변동 x @@ -192,15 +194,15 @@ public ResponseEntity switchIsOpen(UserPrincipal userPrincipal) { // 팔로잉 알림 수신 여부 변경 @Transactional - public ResponseEntity switchIsRecvFollowing(UserPrincipal userPrincipal) { + public ResponseEntity switchIsRecvExhibition(UserPrincipal userPrincipal) { User findUser = validateUserByToken(userPrincipal); - findUser.updateIsRecvFollowing(!findUser.getIsRecvFollowing()); + findUser.updateIsRecvExhibition(!findUser.getIsRecvExhibition()); - UserResponseDto.SwitchIsRecvFollowingRes switchIsRecvFollowingRes = UserConverter.toSwitchIsRecvFollowingRes(findUser); + UserResponseDto.SwitchIsRecvExhibitionRes switchIsRecvExhbitionRes = UserConverter.toSwitchIsRecvExhibitionRes(findUser); - ApiResponse apiResponse = ApiResponse.toApiResponse(switchIsRecvFollowingRes); + ApiResponse apiResponse = ApiResponse.toApiResponse(switchIsRecvExhbitionRes); return ResponseEntity.ok(apiResponse); } @@ -229,6 +231,17 @@ public ResponseEntity getIsOpen(Long userId) { return ResponseEntity.ok(apiResponse); } + @Transactional + public ResponseEntity updateFcmToken(UserPrincipal userPrincipal, UserRequestDto.UpdateFcmToken updateFcmToken) { + User findUser = validateUserByToken(userPrincipal); + findUser.updateFcmToken(updateFcmToken.getFcmToken()); + + ApiResponse apiResponse = ApiResponse.toApiResponse( + Message.builder().message("Fcm Token이 업데이트 되었습니다.").build()); + + return ResponseEntity.ok(apiResponse); + } + public User validateUserByToken(UserPrincipal userPrincipal) { Optional user = userRepository.findById(userPrincipal.getId()); DefaultAssert.isTrue(user.isPresent(), "유저 정보가 올바르지 않습니다."); diff --git a/src/main/java/depth/jeonsilog/domain/user/converter/UserConverter.java b/src/main/java/depth/jeonsilog/domain/user/converter/UserConverter.java index 8a776b1..f3f2d69 100644 --- a/src/main/java/depth/jeonsilog/domain/user/converter/UserConverter.java +++ b/src/main/java/depth/jeonsilog/domain/user/converter/UserConverter.java @@ -53,10 +53,10 @@ public static UserResponseDto.SwitchIsOpenRes toSwitchIsOpenRes(User user) { } // USER -> SwitchIsRecvFollowingRes - public static UserResponseDto.SwitchIsRecvFollowingRes toSwitchIsRecvFollowingRes(User user) { - return UserResponseDto.SwitchIsRecvFollowingRes.builder() + public static UserResponseDto.SwitchIsRecvExhibitionRes toSwitchIsRecvExhibitionRes(User user) { + return UserResponseDto.SwitchIsRecvExhibitionRes.builder() .userId(user.getId()) - .isRecvFollowing(user.getIsRecvFollowing()) + .isRecvExhbition(user.getIsRecvExhibition()) .build(); } diff --git a/src/main/java/depth/jeonsilog/domain/user/domain/User.java b/src/main/java/depth/jeonsilog/domain/user/domain/User.java index 43264a6..29a711e 100644 --- a/src/main/java/depth/jeonsilog/domain/user/domain/User.java +++ b/src/main/java/depth/jeonsilog/domain/user/domain/User.java @@ -5,6 +5,7 @@ import depth.jeonsilog.domain.common.BaseEntity; import depth.jeonsilog.domain.follow.domain.Follow; import depth.jeonsilog.domain.report.domain.Report; +import jakarta.annotation.Nullable; import jakarta.persistence.*; import jakarta.validation.constraints.Email; import lombok.AccessLevel; @@ -38,7 +39,7 @@ public class User extends BaseEntity { // 포토 캘린더 공개 여부 private Boolean isOpen; - private Boolean isRecvFollowing; + private Boolean isRecvExhibition; private Boolean isRecvActive; @@ -50,6 +51,9 @@ public class User extends BaseEntity { // 카카오 고유 ID private String providerId; + @Nullable + private String fcmToken; + // CASCADE 추가 @OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE) private List followers = new ArrayList<>(); @@ -79,16 +83,20 @@ public void updateIsOpen(boolean isOpen) { this.isOpen = isOpen; } - public void updateIsRecvFollowing(boolean isRecvFollowing) { - this.isRecvFollowing = isRecvFollowing; + public void updateIsRecvExhibition(boolean isRecvFollowing) { + this.isRecvExhibition = isRecvFollowing; } public void updateIsRecvActive(boolean isRecvActive) { this.isRecvActive = isRecvActive; } + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + @Builder - public User(Long id, String password, String nickname, String email, String providerId, String profileImg, boolean isOpen, boolean isRecvFollowing, boolean isRecvActive, Role role, Provider provider) { + public User(Long id, String password, String nickname, String email, String providerId, String profileImg, boolean isOpen, boolean isRecvExhibition, boolean isRecvActive, Role role, Provider provider, @Nullable String fcmToken) { this.id = id; this.password = password; this.providerId = providerId; @@ -96,9 +104,10 @@ public User(Long id, String password, String nickname, String email, String prov this.email = email; this.profileImg = profileImg; this.isOpen = isOpen; - this.isRecvFollowing = isRecvFollowing; + this.isRecvExhibition = isRecvExhibition; this.isRecvActive = isRecvActive; this.role = role; this.provider = provider; + this.fcmToken = fcmToken; } } diff --git a/src/main/java/depth/jeonsilog/domain/user/dto/UserRequestDto.java b/src/main/java/depth/jeonsilog/domain/user/dto/UserRequestDto.java index a8f7885..5e392c0 100644 --- a/src/main/java/depth/jeonsilog/domain/user/dto/UserRequestDto.java +++ b/src/main/java/depth/jeonsilog/domain/user/dto/UserRequestDto.java @@ -10,9 +10,16 @@ public class UserRequestDto { @Data public static class ChangeNicknameReq { - @Schema( type = "string", example = "양파쿵야", description="닉네임 입니다.") + @Schema(type = "string", example = "양파쿵야", description = "닉네임 입니다.") @NotBlank(message = "닉네임을 입력해야 합니다.") @Pattern(regexp = "^(?=.*[가-힣a-zA-Z0-9])[가-힣a-zA-Z0-9]{2,10}", message = "한글, 영어, 숫자 가능, 2~10자, 특수기호 불가") private String nickname; } + + @Data + public static class UpdateFcmToken { + + @Schema(type = "string", example = "c8z22dyWSxqH_e7Gk..", description = "Fcm Token 입니다.") + private String fcmToken; + } } diff --git a/src/main/java/depth/jeonsilog/domain/user/dto/UserResponseDto.java b/src/main/java/depth/jeonsilog/domain/user/dto/UserResponseDto.java index 91d18da..b3c4783 100644 --- a/src/main/java/depth/jeonsilog/domain/user/dto/UserResponseDto.java +++ b/src/main/java/depth/jeonsilog/domain/user/dto/UserResponseDto.java @@ -1,7 +1,6 @@ package depth.jeonsilog.domain.user.dto; -import depth.jeonsilog.domain.reply.dto.ReplyResponseDto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @@ -67,13 +66,13 @@ public static class SwitchIsOpenRes { @Data @Builder - public static class SwitchIsRecvFollowingRes { + public static class SwitchIsRecvExhibitionRes { @Schema(type = "long", example = "1", description = "유저의 ID를 출력합니다.") private Long userId; - @Schema(type = "boolean", example = "true", description = "유저의 팔로잉 알림 수신 여부를 출력합니다.") - private Boolean isRecvFollowing; + @Schema(type = "boolean", example = "true", description = "유저의 전시 알림 수신 여부를 출력합니다.") + private Boolean isRecvExhbition; } @Data @@ -83,7 +82,7 @@ public static class SwitchIsRecvActiveRes { @Schema(type = "long", example = "1", description = "유저의 ID를 출력합니다.") private Long userId; - @Schema(type = "boolean", example = "true", description = "유저의 나의 활동 알림 수신 여부를 출력합니다.") + @Schema(type = "boolean", example = "true", description = "유저의 활동 알림 수신 여부를 출력합니다.") private Boolean isRecvActive; } diff --git a/src/main/java/depth/jeonsilog/domain/user/presentation/UserController.java b/src/main/java/depth/jeonsilog/domain/user/presentation/UserController.java index e046997..ba35a40 100644 --- a/src/main/java/depth/jeonsilog/domain/user/presentation/UserController.java +++ b/src/main/java/depth/jeonsilog/domain/user/presentation/UserController.java @@ -1,6 +1,5 @@ package depth.jeonsilog.domain.user.presentation; -import depth.jeonsilog.domain.review.dto.ReviewResponseDto; import depth.jeonsilog.domain.user.application.UserService; import depth.jeonsilog.domain.user.dto.UserRequestDto; import depth.jeonsilog.domain.user.dto.UserResponseDto; @@ -10,7 +9,6 @@ import depth.jeonsilog.global.payload.Message; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -109,19 +107,19 @@ public ResponseEntity switchIsOpen( return userService.switchIsOpen(userPrincipal); } - @Operation(summary = "팔로잉 알림 수신 여부 변경", description = "팔로잉 알림 수신 여부를 변경합니다.") + @Operation(summary = "전시 알림 수신 여부 변경", description = "전시 알림 수신 여부를 변경합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "변경 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.SwitchIsRecvFollowingRes.class))}), + @ApiResponse(responseCode = "200", description = "변경 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.SwitchIsRecvExhibitionRes.class))}), @ApiResponse(responseCode = "400", description = "변경 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), }) - @PatchMapping("/alarm-following") - public ResponseEntity switchIsRecvFollowing( + @PatchMapping("/alarm-exhibition") + public ResponseEntity switchIsRecvExhibition( @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal ) { - return userService.switchIsRecvFollowing(userPrincipal); + return userService.switchIsRecvExhibition(userPrincipal); } - @Operation(summary = "나의 활동 알림 수신 여부 변경", description = "나의 활동 알림 수신 여부를 변경합니다.") + @Operation(summary = "활동 알림 수신 여부 변경", description = "활동 알림 수신 여부를 변경합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "변경 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDto.SwitchIsRecvActiveRes.class))}), @ApiResponse(responseCode = "400", description = "변경 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), @@ -156,4 +154,17 @@ public ResponseEntity getIsOpen( ) { return userService.getIsOpen(userId); } + + @Operation(summary = "Fcm Token 변경", description = "Fcm Token을 변경합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "변경 성공", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = Message.class))}), + @ApiResponse(responseCode = "400", description = "변경 실패", content = {@Content(mediaType = "application/json", schema = @Schema(implementation = ErrorResponse.class))}), + }) + @PatchMapping("/fcm/token") + public ResponseEntity updateFcmToken( + @Parameter(description = "Access Token을 입력해주세요.", required = true) @CurrentUser UserPrincipal userPrincipal, + @Parameter(description = "UpdateFcmToken 을 확인해주세요", required = true) @RequestBody UserRequestDto.UpdateFcmToken fcmToken + ) { + return userService.updateFcmToken(userPrincipal, fcmToken); + } } diff --git a/src/main/java/depth/jeonsilog/global/config/FcmConfig.java b/src/main/java/depth/jeonsilog/global/config/FcmConfig.java new file mode 100644 index 0000000..4a712e9 --- /dev/null +++ b/src/main/java/depth/jeonsilog/global/config/FcmConfig.java @@ -0,0 +1,39 @@ +package depth.jeonsilog.global.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.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +@Configuration +public class FcmConfig { + + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + ClassPathResource resource = new ClassPathResource("services-account.json"); + InputStream refreshToken = resource.getInputStream(); + + FirebaseApp firebaseApp = null; + List firebaseAppList = FirebaseApp.getApps(); + if (firebaseAppList != null && !firebaseAppList.isEmpty()) { + for (FirebaseApp app : firebaseAppList) { + if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) { + firebaseApp = app; + } + } + } else { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(refreshToken)) + .build(); + firebaseApp = FirebaseApp.initializeApp(options); + } + return FirebaseMessaging.getInstance(firebaseApp); + } +}