Skip to content

Commit

Permalink
feat: 푸시 알림 기능 (#430)
Browse files Browse the repository at this point in the history
* feat: config 설정

* feat: FcmMessage 전송 형태 추가

- https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send?hl=ko#request-body

* feat: FcmMessageSender 구현

* feat: 물주기 수행 시 NotificationEvents 발행

* feat: Notification 이벤트를 받아 메시지 전송 수행

* refactor: 로그인 RequestBody로 변경

* refactor: target -> device

* test: 로그인 테스트 수정

* refactor: API 명세 롤백

* feat: 사용자 알림 구독/취소 기능 추가

* feat: Admin 알림 전송기능 추가

* build: config 파일 최신화

* refactor: 코드 포맷팅

* test: 어드민 페이지 테스트 수정

* test: 테스트 및 문서 보완

* chore: 불필요 컨트롤러 삭제

* build: device_token 컬럼 추가

* refactor: NotificationEvent builder로 수정

* refactor: 미사용 ObjectMapper 제거

* style: 개행 추가

* test: memberSupport 적용

* test: fcm 패키지 분리

* refactor: member에 isSubscribe 추가

* chore: 어드민 사용자 페이지 제목 변경

* refactor: NotificationSubscribeRequest NotNull 검증조건 추가

* refactor: 알림을 구독하고있을 때 구독요청하면 예외

* refactor: 알림 구독 해지 시 Ok로 응답 변경

* refactor: NotificationEvent NotNull 추가

* test: 테스트 클래스의 public 제거

* test: 물주기 알림 대상 식물 조회 테스트 추가

* test: 알림 구독 취소 응답값 변경

* test: 알림 구독/취소 예외 테스트 추가

* refactor: 어드민 알림 전송 요청 검증 추가

* refactor: 알림 전송 이벤트 비동기처리

* style: import * 수정 (컨벤션)
  • Loading branch information
Choi-JJunho authored and hozzijeong committed Oct 20, 2023
1 parent f2a80f9 commit 2e06d47
Show file tree
Hide file tree
Showing 47 changed files with 899 additions and 61 deletions.
1 change: 1 addition & 0 deletions backend/pium/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
implementation 'com.google.firebase:firebase-admin:9.2.0'

implementation 'com.github.maricn:logback-slack-appender:1.6.1'
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
Expand Down
1 change: 1 addition & 0 deletions backend/pium/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ include::petPlant.adoc[]
include::reminder.adoc[]
include::history.adoc[]
include::garden.adoc[]
include::member.adoc[]
72 changes: 72 additions & 0 deletions backend/pium/src/docs/asciidoc/member.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
== 사용자(Member)

=== 로그인

==== Request

include::{snippets}/auth/login/http-request.adoc[]

==== Response

include::{snippets}/auth/login/http-response.adoc[]

=== 로그아웃

==== Request

include::{snippets}/auth/logout/http-request.adoc[]

==== Response

include::{snippets}/auth/logout/http-response.adoc[]

=== 세션확인

==== Request

include::{snippets}/member/checkSession/http-request.adoc[]

==== Response

include::{snippets}/member/checkSession/http-response.adoc[]

=== 회원탈퇴

==== Request

include::{snippets}/member/withdraw/http-request.adoc[]

==== Response

include::{snippets}/member/withdraw/http-response.adoc[]

=== 알림구독

==== Request

include::{snippets}/member/subscribeNotification/http-request.adoc[]

==== Response

include::{snippets}/member/subscribeNotification/http-response.adoc[]

=== 알림구독해지

==== Request

include::{snippets}/member/unSubscribeNotification/http-request.adoc[]

==== Response

include::{snippets}/member/unSubscribeNotification/http-response.adoc[]

=== 알림 구독상태 확인

==== Request

include::{snippets}/member/checkNotification/http-request.adoc[]

==== Response

include::{snippets}/member/checkNotification/http-response.adoc[]
include::{snippets}/member/checkNotification/response-body.adoc[]
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.official.pium.admin.controller;

import com.official.pium.admin.domain.Registration;
import com.official.pium.admin.dto.AdminSendNotificationRequest;
import com.official.pium.admin.repository.RegistrationRepository;
import com.official.pium.admin.service.AdminService;
import com.official.pium.domain.Admin;
import com.official.pium.domain.AdminAuth;
import com.official.pium.domain.DictionaryPlant;
import com.official.pium.domain.Member;
import com.official.pium.service.NotificationService;
import com.official.pium.repository.DictionaryPlantRepository;
import com.official.pium.repository.MemberRepository;
import com.official.pium.service.dto.AdminLoginRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
Expand Down Expand Up @@ -37,7 +41,9 @@ public class AdminPageController {

private final RegistrationRepository registrationRepository;
private final DictionaryPlantRepository dictionaryPlantRepository;
private final MemberRepository memberRepository;
private final AdminService adminService;
private final NotificationService notificationService;

@GetMapping("/**")
public String adminPage(@AdminAuth Admin admin, Model model) {
Expand Down Expand Up @@ -113,6 +119,28 @@ public String dictionaryPlantRequests(@PageableDefault Pageable pageable, @Admin
return "admin/dict/requests";
}

@GetMapping("/member/requests")
public String membersPage(@PageableDefault Pageable pageable, @AdminAuth Admin admin, Model model) {
if (admin == null) {
return REDIRECT_ADMIN_LOGIN;
}
Page<Member> members = memberRepository.findAll(pageable);
model.addAttribute(ADMIN_FIELD, admin);
model.addAttribute("page", members);
model.addAttribute("members", members.getContent());
return "admin/member/requests";
}

@PostMapping("/notification")
public ResponseEntity<Void> sendNotification(@AdminAuth Admin admin, @RequestBody @Valid AdminSendNotificationRequest request) {
if (admin == null) {
return ResponseEntity.status(401).build();
}

notificationService.sendNotification(request.getDeviceToken(), request.getTitle(), request.getBody());
return ResponseEntity.ok().build();
}

@GetMapping("/login")
public String loginPage(Model model) {
return "admin/login";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.official.pium.admin.dto;

import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class AdminSendNotificationRequest {

@NotNull
private String deviceToken;

@NotNull
private String title;

@NotNull
private String body;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.official.pium.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class ScheduleConfig {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.official.pium.controller;

import com.official.pium.exception.AuthorizationException;
import com.official.pium.fcm.exception.FcmException;
import com.official.pium.exception.OAuthException;
import com.official.pium.exception.OAuthException.KaKaoMemberInfoRequestException;
import com.official.pium.exception.OAuthException.KakaoServerException;
Expand Down Expand Up @@ -121,6 +122,14 @@ public ResponseEntity<GlobalExceptionResponse> handleAuthorizationException(Auth
return new ResponseEntity<>(exceptionResponse, HttpStatus.FORBIDDEN);
}

@ExceptionHandler(FcmException.class)
public ResponseEntity<GlobalExceptionResponse> handleFcmException(FcmException e) {
String message = e.getMessage();
GlobalExceptionResponse exceptionResponse = createExceptionResponse("FCM 메시지 전송 중 오류가 발생했습니다.");
log.error(message);
return ResponseEntity.internalServerError().body(exceptionResponse);
}

private StringBuilder getExceptionMessages(ConstraintViolationException e) {
Iterator<ConstraintViolation<?>> iterator =
e.getConstraintViolations().iterator();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
import com.official.pium.domain.Auth;
import com.official.pium.domain.Member;
import com.official.pium.service.MemberService;
import com.official.pium.service.dto.NotificationCheckResponse;
import com.official.pium.service.dto.NotificationSubscribeRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;

Expand Down Expand Up @@ -45,4 +50,21 @@ public ResponseEntity<Void> checkSessionStatus(HttpServletRequest request, @Auth

return ResponseEntity.ok().build();
}

@GetMapping("/notification")
public ResponseEntity<NotificationCheckResponse> checkNotificationStatus(@Auth Member member) {
return ResponseEntity.ok(memberService.checkNotification(member));
}

@PostMapping("/notification")
public ResponseEntity<Void> subscribeNotification(@Auth Member member, @RequestBody @Valid NotificationSubscribeRequest request) {
memberService.subscribeNotification(member, request);
return ResponseEntity.ok().build();
}

@DeleteMapping("/notification")
public ResponseEntity<Void> delete(@Auth Member member) {
memberService.unSubscribeNotification(member);
return ResponseEntity.ok().build();
}
}
14 changes: 13 additions & 1 deletion backend/pium/src/main/java/com/official/pium/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,21 @@ public class Member extends BaseEntity {
@Column(name = "kakao_id", unique = true)
private Long kakaoId;

@Column(name = "device_token")
private String deviceToken;

@Builder
public Member(Long kakaoId) {
public Member(Long kakaoId, String deviceToken) {
this.kakaoId = kakaoId;
this.deviceToken = deviceToken;
}

public void updateDeviceToken(String deviceToken) {
this.deviceToken = deviceToken;
}

public boolean isSubscribe() {
return deviceToken != null;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.official.pium.event.notification;

import jakarta.validation.constraints.NotNull;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;

@Getter
@Builder
@Validated
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class NotificationEvent {

@NotNull
private final String deviceToken;

@NotNull
private final String title;

@NotNull
private final String body;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.official.pium.event.notification;

import com.official.pium.service.NotificationService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.*;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class NotificationEventListener {

private final NotificationService notificationService;

@EventListener
@Async
public void handleNotificationEvents(List<NotificationEvent> notificationEvent) {
for (NotificationEvent event : notificationEvent) {
notificationService.sendNotification(event.getDeviceToken(), event.getTitle(), event.getBody());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.official.pium.fcm.dto;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class FcmMessageResponse {

private boolean validate_only;
private Message message;

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Message {
private Notification notification;
private String token;
}

@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Notification {
private String title;
private String body;
private String image;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.official.pium.fcm.exception;

public class FcmException extends RuntimeException {

public FcmException(String message) {
super(message);
}

public static class FcmMessageSendException extends FcmException {
public FcmMessageSendException(String message) {
super(message);
}
}
}
Loading

0 comments on commit 2e06d47

Please sign in to comment.