From a43525d0df1cadf37480b83d5a2731a7beb20ece Mon Sep 17 00:00:00 2001 From: gabang2 Date: Fri, 31 May 2024 04:20:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EA=B0=80:=20FCM=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/main.yml | 20 +++-- .gitignore | 8 +- build.gradle | 6 ++ .../service/CulturalEventService.java | 20 ++++- .../domain/fcm/controller/FcmController.java | 23 +++++ .../domain/fcm/service/FcmService.java | 51 +++++++++++ .../like/CulturalEventLikeListener.java | 14 ++- .../domain/like/entity/CulturalEventLike.java | 4 + .../member/controller/MemberController.java | 7 ++ .../domain/member/dto/MemberFcmTokenDto.java | 18 ++++ .../domain/member/dto/MemberSignupDto.java | 4 +- .../entity/{GENDER.java => Gender.java} | 4 +- .../backend/domain/member/entity/Member.java | 8 +- .../domain/member/service/MemberService.java | 16 +++- .../notice/controller/NoticeController.java | 81 ------------------ .../notice/dto/NoticePatchRequestDto.java | 13 --- .../notice/dto/NoticePostRequestDto.java | 13 --- .../domain/notice/dto/NoticeResponseDto.java | 16 ---- .../domain/notice/mapper/NoticeMapper.java | 21 ----- .../notice/repository/NoticeRepository.java | 7 -- .../domain/notice/service/NoticeService.java | 53 ------------ .../controller/NotificationController.java | 14 +++ .../entity/Notification.java} | 20 ++--- .../repository/NotificationRepository.java | 11 +++ .../scheduler/NotificationScheduler.java | 60 +++++++++++++ .../service/NotificationService.java | 55 ++++++++++++ .../traffic/controller/TrafficController.java | 44 ---------- .../traffic/dto/TrafficPostRequestDto.java | 12 --- .../traffic/dto/TrafficResponseDto.java | 18 ---- .../domain/traffic/entity/Traffic.java | 43 ---------- .../domain/traffic/mapper/TrafficMapper.java | 11 --- .../traffic/repository/TrafficRepository.java | 10 --- .../traffic/service/TrafficService.java | 35 -------- .../backend/global/config/FirebaseConfig.java | 27 ++++++ src/main/resources/keystore.p12 | Bin 4448 -> 0 bytes 35 files changed, 352 insertions(+), 415 deletions(-) create mode 100644 src/main/java/project/backend/domain/fcm/controller/FcmController.java create mode 100644 src/main/java/project/backend/domain/fcm/service/FcmService.java create mode 100644 src/main/java/project/backend/domain/member/dto/MemberFcmTokenDto.java rename src/main/java/project/backend/domain/member/entity/{GENDER.java => Gender.java} (79%) delete mode 100644 src/main/java/project/backend/domain/notice/controller/NoticeController.java delete mode 100644 src/main/java/project/backend/domain/notice/dto/NoticePatchRequestDto.java delete mode 100644 src/main/java/project/backend/domain/notice/dto/NoticePostRequestDto.java delete mode 100644 src/main/java/project/backend/domain/notice/dto/NoticeResponseDto.java delete mode 100644 src/main/java/project/backend/domain/notice/mapper/NoticeMapper.java delete mode 100644 src/main/java/project/backend/domain/notice/repository/NoticeRepository.java delete mode 100644 src/main/java/project/backend/domain/notice/service/NoticeService.java create mode 100644 src/main/java/project/backend/domain/notification/controller/NotificationController.java rename src/main/java/project/backend/domain/{notice/entity/Notice.java => notification/entity/Notification.java} (50%) create mode 100644 src/main/java/project/backend/domain/notification/repository/NotificationRepository.java create mode 100644 src/main/java/project/backend/domain/notification/scheduler/NotificationScheduler.java create mode 100644 src/main/java/project/backend/domain/notification/service/NotificationService.java delete mode 100644 src/main/java/project/backend/domain/traffic/controller/TrafficController.java delete mode 100644 src/main/java/project/backend/domain/traffic/dto/TrafficPostRequestDto.java delete mode 100644 src/main/java/project/backend/domain/traffic/dto/TrafficResponseDto.java delete mode 100644 src/main/java/project/backend/domain/traffic/entity/Traffic.java delete mode 100644 src/main/java/project/backend/domain/traffic/mapper/TrafficMapper.java delete mode 100644 src/main/java/project/backend/domain/traffic/repository/TrafficRepository.java delete mode 100644 src/main/java/project/backend/domain/traffic/service/TrafficService.java create mode 100644 src/main/java/project/backend/global/config/FirebaseConfig.java delete mode 100644 src/main/resources/keystore.p12 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1c381d6..baf61fc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,8 +26,14 @@ jobs: chmod +x ./gradlew ./gradlew clean build -x test - # dockerfile을 통해 이미지를 빌드하고, 이를 docker repo로 push 합니다. - # 이 때 사용되는 ${{ secrets.DOCKER_REPO }}/directors-dev 가 위에서 만든 도커 repository 입니다. + - name: create firebase key + run: | + cd ./src/main/resources + ls -a . + touch ./firebase-service-key.json + echo "${{ secrets.FIREBASE_KEY }}" > ./firebase-service-key.json + shell: bash + - name: Docker build & push to docker repo run: | docker login -u ${{ secrets.DOCKER_USERNAME }} -p ${{ secrets.DOCKER_PASSWORD }} @@ -43,8 +49,8 @@ jobs: key: ${{ secrets.KEY }} envs: GITHUB_SHA script: | - sudo docker stop ticats - sudo docker rm ticats - sudo docker image rm gabang2/ticats:latest - sudo docker pull gabang2/ticats:latest - sudo docker run -d -p 8080:8080 --name ticats ${{ secrets.ENVS }} gabang2/ticats:latest + sudo docker stop ticats || true + sudo docker rm ticats || true + sudo docker image rm ${{ secrets.DOCKER_USERNAME }}/ticats:latest || true + sudo docker pull ${{ secrets.DOCKER_USERNAME }}/ticats:latest + sudo docker run -d -p 8080:8080 --name ticats -v ${{ secrets.DOCKER_USERNAME }}/ticats:latest diff --git a/.gitignore b/.gitignore index da17530..b750cf8 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,10 @@ src/main/generated/ src/main/generated ### env ### -.env \ No newline at end of file +.env + +### static ### +src/main/java/project/backend/domain/notice +src/main/resources/static +src/main/resources/templates +src/main/resources/firebase-service-key.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index 9377c75..9ccb9e8 100644 --- a/build.gradle +++ b/build.gradle @@ -30,8 +30,10 @@ repositories { def queryDslVersion = '5.0.0' dependencies { + // Spring boot web implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' @@ -113,6 +115,10 @@ dependencies { // jsoup(html parsing) implementation 'org.jsoup:jsoup:1.14.3' + // FCM + implementation 'com.google.firebase:firebase-admin:8.2.0' + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + } tasks.named('test') { diff --git a/src/main/java/project/backend/domain/culturalevent/service/CulturalEventService.java b/src/main/java/project/backend/domain/culturalevent/service/CulturalEventService.java index 866965d..57a3e2e 100644 --- a/src/main/java/project/backend/domain/culturalevent/service/CulturalEventService.java +++ b/src/main/java/project/backend/domain/culturalevent/service/CulturalEventService.java @@ -11,6 +11,7 @@ import project.backend.domain.culturalevnetcategory.entity.CategoryTitle; import project.backend.domain.member.entity.Member; import project.backend.domain.member.service.MemberJwtService; +import project.backend.domain.notification.service.NotificationService; import project.backend.domain.visit.entity.CulturalEventVisit; import project.backend.domain.visit.repository.CulturalEventVisitRepository; import project.backend.global.error.exception.BusinessException; @@ -31,6 +32,7 @@ public class CulturalEventService { private final CulturalEventLikeRepository culturalEventLikeRepository; private final MemberJwtService memberJwtService; private final CulturalEventVisitRepository culturalEventVisitRepository; + private final NotificationService notificationService; public List getCulturalEventList(int page, int size, List categories, String ordering, Boolean isOpened, Double latitude, Double longitude) { return culturalEventRepository.getCulturalEventList(page, size, categories, ordering, isOpened, latitude, longitude); @@ -51,17 +53,29 @@ public void like(Long id) { CulturalEventLike culturalEventLike = new CulturalEventLike(); culturalEventLike.setCulturalEventLike(member, getCulturalEvent(id)); culturalEventLikeRepository.save(culturalEventLike); + + // Set Ticket Open Notification + LocalDateTime ticketOpenDate = culturalEventLike.getCulturalEvent().getTicketOpenDate(); + LocalDateTime currentTimePlus30Minutes = LocalDateTime.now().plusMinutes(30); + + if (ticketOpenDate.isAfter(currentTimePlus30Minutes)) { + notificationService.createCulturalEventLikeNotification(culturalEventLike); + } } public void unLike(Long id) { + Member member = memberJwtService.getMember(); CulturalEvent culturalEvent = getCulturalEvent(id); - Optional culturalEventLike = culturalEvent.findMemberLike(member); + Optional culturalEventLikeOptional = culturalEvent.findMemberLike(member); - if (culturalEventLike.isEmpty()) { + if (culturalEventLikeOptional.isEmpty()) { return; } - culturalEventLikeRepository.delete(culturalEventLike.get()); + + CulturalEventLike culturalEventLike = culturalEventLikeOptional.get(); + notificationService.deleteCulturalEventLikeNotification(culturalEventLike); + culturalEventLikeRepository.delete(culturalEventLike); } public void visit(CulturalEvent culturalEvent) { diff --git a/src/main/java/project/backend/domain/fcm/controller/FcmController.java b/src/main/java/project/backend/domain/fcm/controller/FcmController.java new file mode 100644 index 0000000..98f3703 --- /dev/null +++ b/src/main/java/project/backend/domain/fcm/controller/FcmController.java @@ -0,0 +1,23 @@ +package project.backend.domain.fcm.controller; + +import io.swagger.annotations.Api; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import project.backend.domain.fcm.service.FcmService; + +@RestController +@RequestMapping("/api/fcm") +@RequiredArgsConstructor +@Api(tags = "테스트용 FCM - 삭제 예정") +public class FcmController { + + private final FcmService fcmService; + + @PostMapping("/send") + public String sendNotification(@RequestParam String token, + @RequestParam String title, + @RequestParam String body){ + fcmService.sendNotificationByToken(token, title, body); + return "Notification sent successfully"; + } +} diff --git a/src/main/java/project/backend/domain/fcm/service/FcmService.java b/src/main/java/project/backend/domain/fcm/service/FcmService.java new file mode 100644 index 0000000..306dd5e --- /dev/null +++ b/src/main/java/project/backend/domain/fcm/service/FcmService.java @@ -0,0 +1,51 @@ +package project.backend.domain.fcm.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import org.springframework.stereotype.Service; +import project.backend.domain.member.entity.Member; + +@Service +public class FcmService { + public void sendNotification(Member member, String title, String body) { + String fcmToken = member.getFcmToken(); + + if (fcmToken != null) { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(notification) + .build(); + try { + String response = FirebaseMessaging.getInstance().send(message); + System.out.println("Successfully sent message: " + response); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + public void sendNotificationByToken(String token, String title, String body) { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + + Message message = Message.builder() + .setToken(token) + .setNotification(notification) + .build(); + + try { + String response = FirebaseMessaging.getInstance().send(message); + System.out.println("Successfully sent message: " + response); + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/project/backend/domain/like/CulturalEventLikeListener.java b/src/main/java/project/backend/domain/like/CulturalEventLikeListener.java index afb4fbe..906135a 100644 --- a/src/main/java/project/backend/domain/like/CulturalEventLikeListener.java +++ b/src/main/java/project/backend/domain/like/CulturalEventLikeListener.java @@ -1,18 +1,26 @@ package project.backend.domain.like; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; import project.backend.domain.like.entity.CulturalEventLike; +import project.backend.domain.notification.service.NotificationService; import javax.persistence.PrePersist; import javax.persistence.PreRemove; + + +@Component public class CulturalEventLikeListener { + @PrePersist - public void postPersist(CulturalEventLike culturalEventLike) { + public void prePersist(CulturalEventLike culturalEventLike) { culturalEventLike.culturalEvent.increaseLikeCount(); } + @PreRemove - public void postRemove(CulturalEventLike culturalEventLike) { + public void preRemove(CulturalEventLike culturalEventLike) { culturalEventLike.deleteCulturalEventLike(); culturalEventLike.culturalEvent.decreaseLikeCount(); } -} +} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/like/entity/CulturalEventLike.java b/src/main/java/project/backend/domain/like/entity/CulturalEventLike.java index eb556b9..1d21f31 100644 --- a/src/main/java/project/backend/domain/like/entity/CulturalEventLike.java +++ b/src/main/java/project/backend/domain/like/entity/CulturalEventLike.java @@ -5,6 +5,7 @@ import project.backend.domain.like.CulturalEventLikeListener; import project.backend.domain.culturalevent.entity.CulturalEvent; import project.backend.domain.member.entity.Member; +import project.backend.domain.notification.entity.Notification; import javax.persistence.*; import java.util.Optional; @@ -24,6 +25,9 @@ public class CulturalEventLike extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) public CulturalEvent culturalEvent; + @OneToOne(mappedBy = "culturalEventLike", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) + private Notification notification; + // == 연관관계 매핑 == // public void setCulturalEventLike(Member member, CulturalEvent culturalEvent) { if (this.member != null) { diff --git a/src/main/java/project/backend/domain/member/controller/MemberController.java b/src/main/java/project/backend/domain/member/controller/MemberController.java index 2e5c499..03da456 100644 --- a/src/main/java/project/backend/domain/member/controller/MemberController.java +++ b/src/main/java/project/backend/domain/member/controller/MemberController.java @@ -75,6 +75,13 @@ public ResponseEntity nicknameValidation(@Valid @RequestBody MemberNicknameValid return ResponseEntity.status(HttpStatus.OK).body(null); } + @ApiOperation(value = "FCM 토큰 등록") + @PostMapping("/fcm-token") + public ResponseEntity setFcmToken(@Valid @RequestBody MemberFcmTokenDto request) { + memberService.setFcmToken(request.fcmToken); + return ResponseEntity.status(HttpStatus.OK).body(null); + } + @ApiIgnore @GetMapping("/{memberId}") // todo : 관리자 권한 있어야 실행 가능한 것으로 바꾸기 public ResponseEntity getMember( diff --git a/src/main/java/project/backend/domain/member/dto/MemberFcmTokenDto.java b/src/main/java/project/backend/domain/member/dto/MemberFcmTokenDto.java new file mode 100644 index 0000000..bbaff44 --- /dev/null +++ b/src/main/java/project/backend/domain/member/dto/MemberFcmTokenDto.java @@ -0,0 +1,18 @@ +package project.backend.domain.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MemberFcmTokenDto { + @NotNull(message = "fcmToken은 필수 값입니다.") + public String fcmToken; +} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/member/dto/MemberSignupDto.java b/src/main/java/project/backend/domain/member/dto/MemberSignupDto.java index 90e6dee..8145795 100644 --- a/src/main/java/project/backend/domain/member/dto/MemberSignupDto.java +++ b/src/main/java/project/backend/domain/member/dto/MemberSignupDto.java @@ -4,7 +4,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.NoArgsConstructor; -import project.backend.domain.member.entity.GENDER; +import project.backend.domain.member.entity.Gender; import javax.validation.constraints.Email; import javax.validation.constraints.NotNull; @@ -32,7 +32,7 @@ public class MemberSignupDto { @NotNull(message = "nickname은 필수값입니다.") @Schema(description = "성별", example = "FEMALE", required = true) - public GENDER gender; + public Gender gender; @Schema(description = "마케팅 정보 수신 및 이용 동의", example = "true", required = false) public Boolean isMarketingAgree; diff --git a/src/main/java/project/backend/domain/member/entity/GENDER.java b/src/main/java/project/backend/domain/member/entity/Gender.java similarity index 79% rename from src/main/java/project/backend/domain/member/entity/GENDER.java rename to src/main/java/project/backend/domain/member/entity/Gender.java index 220d3e7..774f3fd 100644 --- a/src/main/java/project/backend/domain/member/entity/GENDER.java +++ b/src/main/java/project/backend/domain/member/entity/Gender.java @@ -3,13 +3,13 @@ import lombok.Getter; @Getter -public enum GENDER { +public enum Gender { MALE("남성"), FEMALE("여성"); private final String status; - GENDER(String status) { + Gender(String status) { this.status = status; } diff --git a/src/main/java/project/backend/domain/member/entity/Member.java b/src/main/java/project/backend/domain/member/entity/Member.java index 4ddd6c3..51d50dc 100644 --- a/src/main/java/project/backend/domain/member/entity/Member.java +++ b/src/main/java/project/backend/domain/member/entity/Member.java @@ -11,7 +11,6 @@ import project.backend.domain.onboardingmembercategory.entity.OnboardingMemberCategory; import project.backend.domain.ticket.entity.Ticket; import project.backend.domain.member.dto.MemberPatchRequestDto; -import project.backend.domain.traffic.entity.Traffic; import project.backend.domain.visit.entity.CulturalEventVisit; import javax.persistence.*; @@ -33,7 +32,7 @@ public class Member extends BaseEntity { public SocialType socialType; @Enumerated(value = EnumType.STRING) - public GENDER gender; + public Gender gender; public String socialId; @@ -49,6 +48,8 @@ public class Member extends BaseEntity { public String refreshToken; + public String fcmToken; + public Boolean isSignup = false; public Boolean isMarketingAgree = false; @@ -68,9 +69,6 @@ public class Member extends BaseEntity { @OneToMany(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) public List culturalEventVisitList = new ArrayList<>(); - @OneToMany(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) - private List traffics = new ArrayList<>(); - @Builder public Member(SocialType socialType, String socialId, String nickname, String profileUrl, String refreshToken) { this.socialType = socialType; diff --git a/src/main/java/project/backend/domain/member/service/MemberService.java b/src/main/java/project/backend/domain/member/service/MemberService.java index 3e54238..44414f9 100644 --- a/src/main/java/project/backend/domain/member/service/MemberService.java +++ b/src/main/java/project/backend/domain/member/service/MemberService.java @@ -5,20 +5,16 @@ import org.springframework.transaction.annotation.Transactional; import project.backend.domain.category.service.CategoryService; import project.backend.domain.member.dto.*; -import project.backend.domain.member.entity.Agree; import project.backend.domain.member.entity.SocialType; import project.backend.domain.member.entity.Member; import project.backend.domain.member.mapper.MemberMapper; import project.backend.domain.member.repository.MemberRepository; -import project.backend.domain.memberTicketLike.repository.MemberTicketLikeRepository; import project.backend.domain.onboardingmembercategory.service.OnboardingMemberCategoryService; import project.backend.domain.ticket.repository.TicketRepository; import project.backend.global.error.exception.BusinessException; import project.backend.global.error.exception.ErrorCode; -import java.time.LocalDateTime; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -93,6 +89,18 @@ public Member setMemberSignup(MemberSignupDto memberSignupDto) { return member; } + /** + * FCM 토큰 등록 + * @param fcmToken + * @return Member + */ + public void setFcmToken(String fcmToken) { + Member member = memberJwtService.getMember(); + member.fcmToken = fcmToken; + memberRepository.save(member); + } + + @Transactional(readOnly = true) public Member getMemberBySocialIdAndSocialType(String socialId, SocialType socialType) { return memberRepository.findFirstBySocialIdAndSocialType(socialId, socialType).orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); diff --git a/src/main/java/project/backend/domain/notice/controller/NoticeController.java b/src/main/java/project/backend/domain/notice/controller/NoticeController.java deleted file mode 100644 index 1e66192..0000000 --- a/src/main/java/project/backend/domain/notice/controller/NoticeController.java +++ /dev/null @@ -1,81 +0,0 @@ -package project.backend.domain.notice.controller; - -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import lombok.RequiredArgsConstructor; - -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.util.ObjectUtils; -import org.springframework.web.bind.annotation.*; -import project.backend.domain.culturalevent.service.CulturalEventBatchService; -import project.backend.domain.notice.dto.NoticePostRequestDto; -import project.backend.domain.notice.dto.NoticeResponseDto; -import project.backend.domain.notice.dto.NoticePatchRequestDto; -import project.backend.domain.notice.entity.Notice; -import project.backend.domain.notice.mapper.NoticeMapper; -import project.backend.domain.notice.service.NoticeService; -import project.backend.domain.place.service.PlaceService; -import project.backend.global.error.exception.BusinessException; -import project.backend.global.error.exception.ErrorCode; - -import java.util.List; - -@Api(tags = "공지 API") -@RestController -@RequestMapping("/api/notices") -@RequiredArgsConstructor -public class NoticeController { - - private final NoticeService noticeService; - private final NoticeMapper noticeMapper; - private final CulturalEventBatchService culturalEventBatchService; - private final PlaceService placeService; - - - @PostMapping - public ResponseEntity postNotice(@RequestBody(required = false) NoticePostRequestDto noticePostRequestDto) { - if (ObjectUtils.isEmpty(noticePostRequestDto)){ - throw new BusinessException(ErrorCode.MISSING_REQUEST); - } - Notice notice = noticeService.createNotice(noticePostRequestDto); - return ResponseEntity.status(HttpStatus.CREATED).body(noticeMapper.noticeToNoticeResponseDto(notice)); - } - - @ApiOperation(value = "공지 목록") - @GetMapping("/{noticeId}") - public ResponseEntity getNotice(@PathVariable(required = false) Long noticeId) { - if (ObjectUtils.isEmpty(noticeId)){ - throw new BusinessException(ErrorCode.MISSING_REQUEST); - } - NoticeResponseDto noticeResponseDto = noticeMapper.noticeToNoticeResponseDto(noticeService.getNotice(noticeId)); - return ResponseEntity.status(HttpStatus.OK).body(noticeResponseDto); - } - - @GetMapping - public ResponseEntity getNoticeList() { - culturalEventBatchService.createCulturalEvents(); - List noticeResponseDtoList = noticeMapper.noticesToNoticeResponseDtos(noticeService.getNoticeList()); - return ResponseEntity.status(HttpStatus.OK).body(noticeResponseDtoList); - } - - @PatchMapping("/{noticeId}") - public ResponseEntity putNotice( - @PathVariable(required = false) Long noticeId, - @RequestBody(required = false) NoticePatchRequestDto noticePatchRequestDto) { - if (ObjectUtils.isEmpty(noticeId) || ObjectUtils.isEmpty(noticePatchRequestDto)){ - throw new BusinessException(ErrorCode.MISSING_REQUEST); - } - NoticeResponseDto noticeResponseDto = noticeMapper.noticeToNoticeResponseDto(noticeService.patchNotice(noticeId, noticePatchRequestDto)); - return ResponseEntity.status(HttpStatus.OK).body(noticeResponseDto); - } - - @DeleteMapping("/{noticeId}") - public ResponseEntity deleteNotice(@PathVariable(required = false) Long noticeId) { - if (ObjectUtils.isEmpty(noticeId)){ - throw new BusinessException(ErrorCode.MISSING_REQUEST); - } - noticeService.deleteNotice(noticeId); - return ResponseEntity.status(HttpStatus.NO_CONTENT).body(null); - } -} diff --git a/src/main/java/project/backend/domain/notice/dto/NoticePatchRequestDto.java b/src/main/java/project/backend/domain/notice/dto/NoticePatchRequestDto.java deleted file mode 100644 index 51b5201..0000000 --- a/src/main/java/project/backend/domain/notice/dto/NoticePatchRequestDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package project.backend.domain.notice.dto; - -import lombok.*; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class NoticePatchRequestDto { - public String title; - public String content; -} diff --git a/src/main/java/project/backend/domain/notice/dto/NoticePostRequestDto.java b/src/main/java/project/backend/domain/notice/dto/NoticePostRequestDto.java deleted file mode 100644 index 2144d59..0000000 --- a/src/main/java/project/backend/domain/notice/dto/NoticePostRequestDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package project.backend.domain.notice.dto; - -import lombok.*; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class NoticePostRequestDto { - public String title; - public String content; -} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/notice/dto/NoticeResponseDto.java b/src/main/java/project/backend/domain/notice/dto/NoticeResponseDto.java deleted file mode 100644 index 09f1c10..0000000 --- a/src/main/java/project/backend/domain/notice/dto/NoticeResponseDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package project.backend.domain.notice.dto; -import lombok.*; - -import java.time.LocalDateTime; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class NoticeResponseDto { - public String title; - public String content; - public LocalDateTime createdDate; - public LocalDateTime updatedDate; -} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/notice/mapper/NoticeMapper.java b/src/main/java/project/backend/domain/notice/mapper/NoticeMapper.java deleted file mode 100644 index 9d5f748..0000000 --- a/src/main/java/project/backend/domain/notice/mapper/NoticeMapper.java +++ /dev/null @@ -1,21 +0,0 @@ -package project.backend.domain.notice.mapper; - -import org.mapstruct.Mapper; -import org.mapstruct.ReportingPolicy; -import project.backend.domain.notice.dto.NoticePatchRequestDto; -import project.backend.domain.notice.dto.NoticePostRequestDto; -import project.backend.domain.notice.dto.NoticeResponseDto; -import project.backend.domain.notice.entity.Notice; - -import java.util.List; - -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface NoticeMapper { - Notice noticePostRequestDtoToNotice(NoticePostRequestDto noticePostRequestDto); - - Notice noticePatchRequestDtoToNotice(NoticePatchRequestDto noticePatchRequestDto); - - NoticeResponseDto noticeToNoticeResponseDto(Notice notice); - - List noticesToNoticeResponseDtos(List notice); -} diff --git a/src/main/java/project/backend/domain/notice/repository/NoticeRepository.java b/src/main/java/project/backend/domain/notice/repository/NoticeRepository.java deleted file mode 100644 index 9b7ccd5..0000000 --- a/src/main/java/project/backend/domain/notice/repository/NoticeRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package project.backend.domain.notice.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import project.backend.domain.notice.entity.Notice; - -public interface NoticeRepository extends JpaRepository { -} diff --git a/src/main/java/project/backend/domain/notice/service/NoticeService.java b/src/main/java/project/backend/domain/notice/service/NoticeService.java deleted file mode 100644 index ef0817b..0000000 --- a/src/main/java/project/backend/domain/notice/service/NoticeService.java +++ /dev/null @@ -1,53 +0,0 @@ -package project.backend.domain.notice.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import project.backend.domain.notice.dto.NoticePostRequestDto; -import project.backend.domain.notice.dto.NoticePatchRequestDto; -import project.backend.domain.notice.entity.Notice; -import project.backend.domain.notice.mapper.NoticeMapper; -import project.backend.domain.notice.repository.NoticeRepository; -import project.backend.global.error.exception.BusinessException; -import project.backend.global.error.exception.ErrorCode; - -import java.util.List; - -@Service -@RequiredArgsConstructor -@Transactional -public class NoticeService { - private final NoticeRepository noticeRepository; - private final NoticeMapper noticeMapper; - - public Notice createNotice(NoticePostRequestDto noticePostRequestDto){ - Notice notice = Notice.builder() - .title(noticePostRequestDto.getTitle()) - .content(noticePostRequestDto.getContent()).build(); - noticeRepository.save(notice); - return notice; - } - - public Notice getNotice(Long id) { - return verifiedNotice(id); - } - - public List getNoticeList() { - return noticeRepository.findAll(); - } - - public Notice patchNotice(Long id, NoticePatchRequestDto noticePatchRequestDto) { - Notice notice = verifiedNotice(id).patchNotice(noticePatchRequestDto); - noticeRepository.save(notice); - return notice; - } - - public void deleteNotice(Long id) { - noticeRepository.delete(verifiedNotice(id)); - } - - private Notice verifiedNotice(Long id) { - return noticeRepository.findById(id).orElseThrow(() -> new BusinessException(ErrorCode.NOTICE_NOT_FOUND)); - } - -} diff --git a/src/main/java/project/backend/domain/notification/controller/NotificationController.java b/src/main/java/project/backend/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..5eabaef --- /dev/null +++ b/src/main/java/project/backend/domain/notification/controller/NotificationController.java @@ -0,0 +1,14 @@ +package project.backend.domain.notification.controller; + +import io.swagger.annotations.Api; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + + +@Api(tags = "알림 API") +@RestController +@RequestMapping("/api/notification") +@RequiredArgsConstructor +public class NotificationController { + +} diff --git a/src/main/java/project/backend/domain/notice/entity/Notice.java b/src/main/java/project/backend/domain/notification/entity/Notification.java similarity index 50% rename from src/main/java/project/backend/domain/notice/entity/Notice.java rename to src/main/java/project/backend/domain/notification/entity/Notification.java index 87d9635..49a0861 100644 --- a/src/main/java/project/backend/domain/notice/entity/Notice.java +++ b/src/main/java/project/backend/domain/notification/entity/Notification.java @@ -1,11 +1,11 @@ -package project.backend.domain.notice.entity; +package project.backend.domain.notification.entity; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.RequiredArgsConstructor; import project.backend.domain.common.entity.BaseEntity; -import project.backend.domain.notice.dto.NoticePatchRequestDto; +import project.backend.domain.like.entity.CulturalEventLike; import javax.persistence.*; import java.util.Optional; @@ -13,7 +13,7 @@ @Entity @Getter @RequiredArgsConstructor(access = AccessLevel.PROTECTED) -public class Notice extends BaseEntity { +public class Notification extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id; @@ -22,16 +22,14 @@ public class Notice extends BaseEntity { public String content; + @OneToOne + @JoinColumn(name = "cultural_event_like_id") + private CulturalEventLike culturalEventLike; + @Builder - public Notice(String title, String content){ + public Notification(String title, String content, CulturalEventLike culturalEventLike) { this.title = title; this.content = content; - } - - // Patch - public Notice patchNotice(NoticePatchRequestDto noticePatchRequestDto){ - this.title = Optional.ofNullable(noticePatchRequestDto.getTitle()).orElse(this.title); - this.content = Optional.ofNullable(noticePatchRequestDto.getContent()).orElse(this.content); - return this; + this.culturalEventLike = culturalEventLike; } } diff --git a/src/main/java/project/backend/domain/notification/repository/NotificationRepository.java b/src/main/java/project/backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..5882e6a --- /dev/null +++ b/src/main/java/project/backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,11 @@ +package project.backend.domain.notification.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import project.backend.domain.like.entity.CulturalEventLike; +import project.backend.domain.notification.entity.Notification; + +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + Optional findNotificationByCulturalEventLikeId(Long culturalEventLikeId); +} diff --git a/src/main/java/project/backend/domain/notification/scheduler/NotificationScheduler.java b/src/main/java/project/backend/domain/notification/scheduler/NotificationScheduler.java new file mode 100644 index 0000000..e874cea --- /dev/null +++ b/src/main/java/project/backend/domain/notification/scheduler/NotificationScheduler.java @@ -0,0 +1,60 @@ +package project.backend.domain.notification.scheduler; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.stereotype.Component; +import project.backend.domain.member.entity.Member; + +import java.util.Date; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; + +@Component +public class NotificationScheduler { + + private final ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + private final Map> scheduledTasks = new ConcurrentHashMap<>(); + + + public NotificationScheduler() { + taskScheduler.initialize(); + } + + public void scheduleNotification(Long notificationId, Date triggerDate, Member member, String title, String body) { + ScheduledFuture scheduledTask = taskScheduler.schedule(() -> { + sendNotification(member, title, body); + }, triggerDate); + scheduledTasks.put(notificationId, scheduledTask); + } + + public void cancelNotification(Long notificationId) { + ScheduledFuture scheduledTask = scheduledTasks.remove(notificationId); + if (scheduledTask != null) { + scheduledTask.cancel(false); + } + } + + public void sendNotification(Member member, String title, String body) { + String fcmToken = member.getFcmToken(); + + if (fcmToken != null) { + com.google.firebase.messaging.Notification notification = com.google.firebase.messaging.Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + + Message message = Message.builder() + .setToken(fcmToken) + .setNotification(notification) + .build(); + try { + String response = FirebaseMessaging.getInstance().send(message); + System.out.println("Successfully sent message: " + response); + } catch (Exception e) { + e.printStackTrace(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/notification/service/NotificationService.java b/src/main/java/project/backend/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..9957dd9 --- /dev/null +++ b/src/main/java/project/backend/domain/notification/service/NotificationService.java @@ -0,0 +1,55 @@ +package project.backend.domain.notification.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import project.backend.domain.like.entity.CulturalEventLike; +import project.backend.domain.notification.entity.Notification; +import project.backend.domain.notification.repository.NotificationRepository; +import project.backend.domain.notification.scheduler.NotificationScheduler; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional +public class NotificationService { + private final NotificationScheduler notificationScheduler; + private final NotificationRepository notificationRepository; + + + public void createCulturalEventLikeNotification(CulturalEventLike culturalEventLike) { + Notification notification = Notification.builder() + .title("알림 타이틀") + .content("알림 콘텐츠") + .culturalEventLike(culturalEventLike) + .build(); + notificationRepository.save(notification); + + // 티켓 오픈 30분 전 + ZonedDateTime ticketOpenDateTime = culturalEventLike.culturalEvent.ticketOpenDate.atZone(ZoneId.systemDefault()); + ZonedDateTime scheduledDateTime = ticketOpenDateTime.minusMinutes(30); + Date scheduledTime = Date.from(scheduledDateTime.toInstant()); + + // Notification 설정 + notificationScheduler.scheduleNotification( + notification.getId(), + scheduledTime, + culturalEventLike.getMember(), + notification.getTitle(), + notification.getContent() + ); + } + + public void deleteCulturalEventLikeNotification(CulturalEventLike culturalEventLike) { + Optional notificationOptional = notificationRepository.findNotificationByCulturalEventLikeId(culturalEventLike.id); + + if (notificationOptional.isPresent()) { + Notification notification = notificationOptional.get(); + notificationScheduler.cancelNotification(notification.id); + } + } +} diff --git a/src/main/java/project/backend/domain/traffic/controller/TrafficController.java b/src/main/java/project/backend/domain/traffic/controller/TrafficController.java deleted file mode 100644 index 8e1e44e..0000000 --- a/src/main/java/project/backend/domain/traffic/controller/TrafficController.java +++ /dev/null @@ -1,44 +0,0 @@ -package project.backend.domain.traffic.controller; - -import io.swagger.annotations.Api; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.util.ObjectUtils; -import org.springframework.web.bind.annotation.*; -import project.backend.domain.jwt.service.JwtService; -import project.backend.domain.member.entity.Member; -import project.backend.domain.traffic.dto.TrafficPostRequestDto; -import project.backend.domain.traffic.dto.TrafficResponseDto; -import project.backend.domain.traffic.entity.Traffic; -import project.backend.domain.traffic.mapper.TrafficMapper; -import project.backend.domain.traffic.service.TrafficService; -import project.backend.global.error.exception.BusinessException; -import project.backend.global.error.exception.ErrorCode; - - -@Api(tags = "트래픽 API") -@RestController -@RequestMapping("/api/traffics") -@RequiredArgsConstructor -public class TrafficController { - - private final TrafficService trafficService; - private final TrafficMapper trafficMapper; - private final JwtService jwtService; - - @PostMapping - public ResponseEntity postTraffic(@RequestBody(required = false) TrafficPostRequestDto trafficPostRequestDto, - @RequestHeader(value = "Authorization", required = false) String accessToken) { - Member member = null; - if (ObjectUtils.isEmpty(trafficPostRequestDto)) { - throw new BusinessException(ErrorCode.MISSING_REQUEST); - } - if (!ObjectUtils.isEmpty(accessToken)) { - member = jwtService.getMemberFromAccessToken(accessToken); - } - - Traffic traffic = trafficService.createTraffic(trafficPostRequestDto, member); - return ResponseEntity.status(HttpStatus.CREATED).body(trafficMapper.trafficToTrafficResponseDto(traffic)); - } -} diff --git a/src/main/java/project/backend/domain/traffic/dto/TrafficPostRequestDto.java b/src/main/java/project/backend/domain/traffic/dto/TrafficPostRequestDto.java deleted file mode 100644 index 0bb77f1..0000000 --- a/src/main/java/project/backend/domain/traffic/dto/TrafficPostRequestDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package project.backend.domain.traffic.dto; - -import lombok.*; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TrafficPostRequestDto { - public String buttonName; -} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/traffic/dto/TrafficResponseDto.java b/src/main/java/project/backend/domain/traffic/dto/TrafficResponseDto.java deleted file mode 100644 index c03339c..0000000 --- a/src/main/java/project/backend/domain/traffic/dto/TrafficResponseDto.java +++ /dev/null @@ -1,18 +0,0 @@ -package project.backend.domain.traffic.dto; -import lombok.*; -import project.backend.domain.member.dto.MemberResponseDto; - -import java.time.LocalDateTime; - -@Getter -@Setter -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TrafficResponseDto { - public Long id; - public String buttonName; - public MemberResponseDto member; - public LocalDateTime createdDate; - public LocalDateTime updatedDate; -} \ No newline at end of file diff --git a/src/main/java/project/backend/domain/traffic/entity/Traffic.java b/src/main/java/project/backend/domain/traffic/entity/Traffic.java deleted file mode 100644 index 279c1b2..0000000 --- a/src/main/java/project/backend/domain/traffic/entity/Traffic.java +++ /dev/null @@ -1,43 +0,0 @@ -package project.backend.domain.traffic.entity; - -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import project.backend.domain.common.entity.BaseEntity; -import project.backend.domain.member.entity.Member; - -import javax.persistence.*; -import java.util.Optional; - -@Entity -@Getter -@RequiredArgsConstructor(access = AccessLevel.PROTECTED) -public class Traffic extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - public Long id; - - public String buttonName; - - @ManyToOne(fetch = FetchType.LAZY) - public Member member; - - @Builder - public Traffic(String buttonName) { - this.buttonName = buttonName; - } - - - // == 연관관계 매핑 == // - public void setMember(Member member) { - if (this.member != null) { - if (this.member.getTraffics().contains(this)) { - this.member.getTraffics().remove(this); - } - } - this.member = Optional.ofNullable(member).orElse(this.member); - this.member.getTraffics().add(this); - } -} diff --git a/src/main/java/project/backend/domain/traffic/mapper/TrafficMapper.java b/src/main/java/project/backend/domain/traffic/mapper/TrafficMapper.java deleted file mode 100644 index b7d5c48..0000000 --- a/src/main/java/project/backend/domain/traffic/mapper/TrafficMapper.java +++ /dev/null @@ -1,11 +0,0 @@ -package project.backend.domain.traffic.mapper; - -import org.mapstruct.Mapper; -import org.mapstruct.ReportingPolicy; -import project.backend.domain.traffic.dto.TrafficResponseDto; -import project.backend.domain.traffic.entity.Traffic; - -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface TrafficMapper { - TrafficResponseDto trafficToTrafficResponseDto(Traffic traffic); -} diff --git a/src/main/java/project/backend/domain/traffic/repository/TrafficRepository.java b/src/main/java/project/backend/domain/traffic/repository/TrafficRepository.java deleted file mode 100644 index d465684..0000000 --- a/src/main/java/project/backend/domain/traffic/repository/TrafficRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package project.backend.domain.traffic.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import project.backend.domain.traffic.entity.Traffic; - -import java.util.Optional; - -public interface TrafficRepository extends JpaRepository { - Optional findFirstByOrderByCreatedDateDesc(); -} diff --git a/src/main/java/project/backend/domain/traffic/service/TrafficService.java b/src/main/java/project/backend/domain/traffic/service/TrafficService.java deleted file mode 100644 index 5dc4082..0000000 --- a/src/main/java/project/backend/domain/traffic/service/TrafficService.java +++ /dev/null @@ -1,35 +0,0 @@ -package project.backend.domain.traffic.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import project.backend.domain.member.entity.Member; -import project.backend.domain.member.service.MemberService; -import project.backend.domain.traffic.dto.TrafficPostRequestDto; -import project.backend.domain.traffic.entity.Traffic; -import project.backend.domain.traffic.repository.TrafficRepository; -import project.backend.global.error.exception.BusinessException; -import project.backend.global.error.exception.ErrorCode; - -import javax.mail.MessagingException; -import javax.mail.internet.MimeMessage; - -@Service -@RequiredArgsConstructor -@Transactional -public class TrafficService { - private final TrafficRepository trafficRepository; - - public Traffic createTraffic(TrafficPostRequestDto trafficPostRequestDto, Member member) { - Traffic traffic = Traffic.builder().buttonName(trafficPostRequestDto.getButtonName()).build(); - trafficRepository.save(traffic); - if (member != null) { - traffic.setMember(member); - } - return traffic; - } -} - - diff --git a/src/main/java/project/backend/global/config/FirebaseConfig.java b/src/main/java/project/backend/global/config/FirebaseConfig.java new file mode 100644 index 0000000..ebc13ff --- /dev/null +++ b/src/main/java/project/backend/global/config/FirebaseConfig.java @@ -0,0 +1,27 @@ +package project.backend.global.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +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; + +@Configuration +public class FirebaseConfig { + + @Bean + public FirebaseApp initializeFirebaseApp() throws IOException { + ClassPathResource resource = new ClassPathResource("firebase-service-key.json"); + InputStream serviceAccount = resource.getInputStream(); + + FirebaseOptions options = new FirebaseOptions.Builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + return FirebaseApp.initializeApp(options); + } +} \ No newline at end of file diff --git a/src/main/resources/keystore.p12 b/src/main/resources/keystore.p12 deleted file mode 100644 index 9685b2244e7b5e4a91c8c5c8382a84d65008ae74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4448 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