diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dcdb8a3..7ba5d31 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ name: Build -on: [pull_request, workflow_dispatch] +on: [ pull_request, workflow_dispatch ] jobs: @@ -17,6 +17,10 @@ jobs: java-version: 17 - name: Run Checkstyle run: ./gradlew checkstyleMain checkstyleTest + - name: Make firebase-adminsdk.json + run: | + touch src/main/resources/timeet-firebase-adminsdk.json + echo "${{ secrets.FIREBASE_ADMINSDK }}" | base64 --decode > src/main/resources/timeet-firebase-adminsdk.json - name: Make application-prod.yml run: | touch src/main/resources/application-prod.yml diff --git a/.gitignore b/.gitignore index 74f33d8..f8fb2e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +timeet-firebase-adminsdk.json HELP.md .gradle build/ diff --git a/build.gradle b/build.gradle index 234dda6..e212ffc 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' implementation("org.springframework.boot:spring-boot-devtools") implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'com.google.firebase:firebase-admin:9.2.0' implementation 'org.springframework.boot:spring-boot-starter-websocket' diff --git a/src/main/java/org/dnd/timeet/common/security/annotation/ReqUser.java b/src/main/java/org/dnd/timeet/common/security/annotation/ReqUser.java new file mode 100644 index 0000000..07c8819 --- /dev/null +++ b/src/main/java/org/dnd/timeet/common/security/annotation/ReqUser.java @@ -0,0 +1,14 @@ +package org.dnd.timeet.common.security.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") +public @interface ReqUser { + +} diff --git a/src/main/java/org/dnd/timeet/config/FCMConfig.java b/src/main/java/org/dnd/timeet/config/FCMConfig.java new file mode 100644 index 0000000..ba1f36d --- /dev/null +++ b/src/main/java/org/dnd/timeet/config/FCMConfig.java @@ -0,0 +1,41 @@ +package org.dnd.timeet.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +@Configuration +public class FCMConfig { + + @Bean + FirebaseMessaging firebaseMessaging() throws IOException { + ClassPathResource resource = new ClassPathResource("timeet-firebase-adminsdk.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); + } + + +} diff --git a/src/main/java/org/dnd/timeet/fcm/application/FCMNotificationService.java b/src/main/java/org/dnd/timeet/fcm/application/FCMNotificationService.java new file mode 100644 index 0000000..ba76173 --- /dev/null +++ b/src/main/java/org/dnd/timeet/fcm/application/FCMNotificationService.java @@ -0,0 +1,50 @@ +package org.dnd.timeet.fcm.application; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.dnd.timeet.common.exception.InternalServerError; +import org.dnd.timeet.common.exception.NotFoundError; +import org.dnd.timeet.fcm.domain.FCMNotificationRequestDto; +import org.dnd.timeet.member.domain.Member; +import org.dnd.timeet.member.domain.MemberRepository; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class FCMNotificationService { + + private final FirebaseMessaging firebaseMessaging; + private final MemberRepository memberRepository; + + public void sendNotificationByToken(FCMNotificationRequestDto requestDto) { + Member member = memberRepository.findById(requestDto.getTargetMemberId()) + .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MemberId", "Member not found"))); + if (member.getFcmToken() == null) { + throw new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("fcmToken", "fcmToken not exist")); + } + + Notification notification = Notification.builder() + .setTitle(requestDto.getTitle()) + .setBody(requestDto.getBody()) + .build(); + + Message message = Message.builder() + .setToken(member.getFcmToken()) + .setNotification(notification) + .build(); + + try { + firebaseMessaging.send(message); + } catch (Exception e) { + throw new InternalServerError(InternalServerError.ErrorCode.INTERNAL_SERVER_ERROR, + Collections.singletonMap("fcmSend", "Fail to send fcm")); + } + + + } +} diff --git a/src/main/java/org/dnd/timeet/fcm/domain/FCMNotificationRequestDto.java b/src/main/java/org/dnd/timeet/fcm/domain/FCMNotificationRequestDto.java new file mode 100644 index 0000000..710f56c --- /dev/null +++ b/src/main/java/org/dnd/timeet/fcm/domain/FCMNotificationRequestDto.java @@ -0,0 +1,22 @@ +package org.dnd.timeet.fcm.domain; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FCMNotificationRequestDto { + + private Long targetMemberId; + private String title; + private String body; + + @Builder + public FCMNotificationRequestDto(Long targetMemberId, String title, String body) { + this.targetMemberId = targetMemberId; + this.title = title; + this.body = body; + } + +} diff --git a/src/main/java/org/dnd/timeet/member/application/MemberService.java b/src/main/java/org/dnd/timeet/member/application/MemberService.java index 0fd9236..e0c6910 100644 --- a/src/main/java/org/dnd/timeet/member/application/MemberService.java +++ b/src/main/java/org/dnd/timeet/member/application/MemberService.java @@ -1,7 +1,10 @@ package org.dnd.timeet.member.application; +import java.util.Collections; import lombok.RequiredArgsConstructor; +import org.dnd.timeet.common.exception.NotFoundError; +import org.dnd.timeet.member.domain.Member; import org.dnd.timeet.member.domain.MemberRepository; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -14,4 +17,14 @@ public class MemberService { private final PasswordEncoder passwordEncoder; private final MemberRepository memberRepository; + + @Transactional + public void upsertFcmToken(Long id, String fcmToken) { + Member member = memberRepository.findById(id) + .orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND, + Collections.singletonMap("MemberId", "Member not found"))); + member.setFcmToken(fcmToken); + + + } } \ No newline at end of file diff --git a/src/main/java/org/dnd/timeet/member/controller/MemberController.java b/src/main/java/org/dnd/timeet/member/controller/MemberController.java index 77cd84c..2013586 100644 --- a/src/main/java/org/dnd/timeet/member/controller/MemberController.java +++ b/src/main/java/org/dnd/timeet/member/controller/MemberController.java @@ -1,8 +1,14 @@ package org.dnd.timeet.member.controller; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.dnd.timeet.common.security.annotation.ReqUser; import org.dnd.timeet.member.application.MemberService; +import org.dnd.timeet.member.domain.Member; +import org.dnd.timeet.member.dto.RegisterFcmRequest; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,5 +20,12 @@ public class MemberController { private final MemberService memberService; + @PatchMapping + @Operation(summary = "fcmToken 등록", description = "fcmToken을 등록한다.") + public void registerFcmToken(@RequestBody RegisterFcmRequest registerFcmRequest, + @ReqUser Member member) { + memberService.upsertFcmToken(member.getId(), registerFcmRequest.getFcmToken()); + + } } diff --git a/src/main/java/org/dnd/timeet/member/domain/Member.java b/src/main/java/org/dnd/timeet/member/domain/Member.java index a8b49cc..9921eb4 100644 --- a/src/main/java/org/dnd/timeet/member/domain/Member.java +++ b/src/main/java/org/dnd/timeet/member/domain/Member.java @@ -44,6 +44,9 @@ public class Member extends BaseEntity { @Column(length = 50, nullable = false) private OAuth2Provider provider; + @Column(length = 255) + private String fcmToken; + @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) private Set participations = new HashSet<>(); @@ -56,4 +59,9 @@ public Member(MemberRole role, String name, String imageUrl, Long oauthId, OAuth this.oauthId = oauthId; this.provider = provider; } + + + public void setFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } } diff --git a/src/main/java/org/dnd/timeet/member/dto/RegisterFcmRequest.java b/src/main/java/org/dnd/timeet/member/dto/RegisterFcmRequest.java new file mode 100644 index 0000000..61594c8 --- /dev/null +++ b/src/main/java/org/dnd/timeet/member/dto/RegisterFcmRequest.java @@ -0,0 +1,16 @@ +package org.dnd.timeet.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Schema(description = "fcmToken 등록 요청") +@Getter +@Setter +@NoArgsConstructor +public class RegisterFcmRequest { + + @Schema(description = "fcmToken", nullable = false, example = "fcmToken") + private String fcmToken; +}