Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

메세지를 전송할 수 있다 #194

Merged
merged 16 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ dependencies {

implementation 'org.springframework.boot:spring-boot-starter-cache'

implementation 'com.google.firebase:firebase-admin:9.2.0'
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2'

runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.snackgame.server.messaging.push.config;

import java.io.InputStream;

import javax.annotation.PostConstruct;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.snackgame.server.messaging.push.exception.FCMException;

@Configuration
public class FCMConfig {

private static final String FIREBASE_PATH_RESOURCE = "secrets/firebase-service-key.json";
private static final String PROJECT_ID = "snackgame";

@PostConstruct
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplicationContext 생명 주기와 맞물리는 ContextRefreshed 이벤트도 있는데요.
PostConstruct는 Bean 생명 주기와 맞물려서 영향 범위가 이 FCMConfig 빈 안으로 한정이 되네요.
오히려 더 좋은 접근 같아요. 하나 배워갑니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 초기화 방법에 대해서 고민을 한 후 선택하여 PostConstruct를 선택한건 아니었지만(아는게 이것뿐.. 😢)
저도 덕분에 다양한 방법이 있다는 것을 알게되었네요 감사합니다!

public void init() {
try {
InputStream serviceAccount = new ClassPathResource(FIREBASE_PATH_RESOURCE).getInputStream();
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.setProjectId(PROJECT_ID)
.build();
if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
}
} catch (Exception exception) {
throw new FCMException();
}
}

}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.snackgame.server.messaging.notification.controller
package com.snackgame.server.messaging.push.controller

import com.snackgame.server.auth.token.support.Authenticated
import com.snackgame.server.member.domain.Member
import com.snackgame.server.messaging.notification.service.NotificationService
import com.snackgame.server.messaging.notification.service.dto.DeviceTokenRequest
import com.snackgame.server.messaging.push.service.DeviceService
import com.snackgame.server.messaging.push.service.dto.DeviceTokenRequest
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import org.springframework.web.bind.annotation.PostMapping
Expand All @@ -12,14 +12,15 @@ import org.springframework.web.bind.annotation.RestController

@Tag(name = "🔔 알림")
@RestController
class NotificationController(private val notificationService: NotificationService) {
class PushController(private val deviceService: DeviceService) {

@Operation(summary = "기기 등록", description = "기기를 등록한다")
@PostMapping("/notifications/devices")
fun registerDevice(
@Authenticated member: Member,
@RequestBody deviceTokenRequest: DeviceTokenRequest
) {
notificationService.registerDeviceFor(member.id, deviceTokenRequest)
deviceService.registerDeviceFor(member.id, deviceTokenRequest)
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.snackgame.server.messaging.notification.domain
package com.snackgame.server.messaging.push.domain

import javax.persistence.Entity
import javax.persistence.GeneratedValue
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.snackgame.server.messaging.notification.domain
package com.snackgame.server.messaging.push.domain

import org.springframework.data.jpa.repository.JpaRepository

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.snackgame.server.messaging.notification.exception
package com.snackgame.server.messaging.push.exception

import com.snackgame.server.common.exception.Kind

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.snackgame.server.messaging.push.exception;

import com.snackgame.server.common.exception.Kind;

public class FCMException extends NotificationException {

public FCMException() {
super("잘못된 FCM 접근입니다.", Kind.INTERNAL_SERVER_ERROR);
}

}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.snackgame.server.messaging.notification.exception
package com.snackgame.server.messaging.push.exception

import com.snackgame.server.common.exception.BusinessException
import com.snackgame.server.common.exception.Kind
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.snackgame.server.messaging.notification.service
package com.snackgame.server.messaging.push.service

import com.snackgame.server.messaging.notification.domain.Device
import com.snackgame.server.messaging.notification.domain.DeviceRepository
import com.snackgame.server.messaging.notification.exception.DuplicatedDeviceException
import com.snackgame.server.messaging.notification.service.dto.DeviceResponse
import com.snackgame.server.messaging.notification.service.dto.DeviceTokenRequest
import com.snackgame.server.messaging.push.domain.Device
import com.snackgame.server.messaging.push.domain.DeviceRepository
import com.snackgame.server.messaging.push.exception.DuplicatedDeviceException
import com.snackgame.server.messaging.push.service.dto.DeviceResponse
import com.snackgame.server.messaging.push.service.dto.DeviceTokenRequest
import org.springframework.stereotype.Service

@Service
class NotificationService(private val deviceRepository: DeviceRepository) {
class DeviceService(private val deviceRepository: DeviceRepository) {

fun registerDeviceFor(ownerId: Long, deviceToken: DeviceTokenRequest) {
if (!deviceRepository.existsByOwnerIdAndToken(ownerId, deviceToken.token)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.snackgame.server.messaging.push.service;

import java.util.List;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.MulticastMessage;
import com.snackgame.server.messaging.push.service.dto.DeviceResponse;
import com.snackgame.server.messaging.push.service.dto.NotificationRequest;

@Service
public class FCMPushService implements PushService {

private final DeviceService deviceService;

public FCMPushService(DeviceService deviceService) {
this.deviceService = deviceService;
}

@Override
public Future<?> sendPushMessage(NotificationRequest request, Long ownerId) {
MulticastMessage multicastMessage = makeMessage(request, ownerId);
return FirebaseMessaging.getInstance().sendEachForMulticastAsync(multicastMessage);
}

private MulticastMessage makeMessage(NotificationRequest request, Long ownerId) {

List<DeviceResponse> devicesOf = deviceService.getDevicesOf(ownerId);

MulticastMessage message = MulticastMessage.builder()
.addAllTokens(devicesOf.stream().map(DeviceResponse::getToken).collect(Collectors.toList()))
.setNotification(request.toNotification())
.build();
return message;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.snackgame.server.messaging.push.service;

import java.util.concurrent.Future;

import com.snackgame.server.messaging.push.service.dto.NotificationRequest;

public interface PushService {

Future<?> sendPushMessage(NotificationRequest request, Long ownerId);
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.snackgame.server.messaging.notification.service.dto
package com.snackgame.server.messaging.push.service.dto

import com.snackgame.server.messaging.notification.domain.Device
import com.snackgame.server.messaging.push.domain.Device

data class DeviceResponse(
val ownerId: Long,
val token: String,
val id: Long
) {

companion object {

fun of(device: Device) = DeviceResponse(device.ownerId, device.token, device.id)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.snackgame.server.messaging.notification.service.dto
package com.snackgame.server.messaging.push.service.dto

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonSetter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.snackgame.server.messaging.push.service.dto;

import com.google.firebase.messaging.Notification;

import lombok.AllArgsConstructor;

@AllArgsConstructor
public class NotificationRequest {

public String title;
public String body;

public Notification toNotification() {
return Notification.builder()
.setTitle(title)
.setBody(body)
.build();
}

}
2 changes: 1 addition & 1 deletion src/main/resources/secrets
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
@file:Suppress("NonAsciiCharacters")

package com.snackgame.server.messaging.notification.controller
package com.snackgame.server.messaging.push.controller

import com.snackgame.server.member.fixture.MemberFixture
import com.snackgame.server.member.fixture.MemberFixture.땡칠_인증정보
import com.snackgame.server.messaging.notification.service.dto.DeviceTokenRequest
import com.snackgame.server.messaging.push.service.dto.DeviceTokenRequest
import com.snackgame.server.support.restassured.RestAssuredTest
import com.snackgame.server.support.restassured.RestAssuredUtil
import io.restassured.http.ContentType
Expand All @@ -13,7 +13,7 @@ import org.junit.jupiter.api.Test
import org.springframework.http.HttpStatus

@RestAssuredTest
class NotificationControllerTest {
class PushControllerTest {

@BeforeEach
fun setUp() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
@file:Suppress("NonAsciiCharacters")

package com.snackgame.server.messaging.notification.service
package com.snackgame.server.messaging.push.service

import com.snackgame.server.member.fixture.MemberFixture.땡칠
import com.snackgame.server.messaging.notification.exception.DuplicatedDeviceException
import com.snackgame.server.messaging.notification.service.dto.DeviceTokenRequest
import com.snackgame.server.messaging.push.exception.DuplicatedDeviceException
import com.snackgame.server.messaging.push.service.dto.DeviceTokenRequest
import com.snackgame.server.support.general.ServiceTest
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired

@ServiceTest
class NotificationServiceTest {
class DeviceServiceTest {

@Autowired
private lateinit var notificationService: NotificationService
private lateinit var deviceService: DeviceService

@Test
fun `기기를 등록한다`() {
val deviceToken = "a_device_token"
notificationService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken))
deviceService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken))

assertThat(notificationService.getDevicesOf(땡칠().id))
assertThat(deviceService.getDevicesOf(땡칠().id))
.singleElement()
.matches { it.ownerId == 땡칠().id }
.matches { it.token == deviceToken }
Expand All @@ -31,9 +31,9 @@ class NotificationServiceTest {
@Test
fun `한 기기는 한 계정당 한 번만 등록할 수 있다`() {
val deviceToken = "a_device_token"
notificationService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken))
deviceService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken))

assertThatThrownBy { notificationService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken)) }
assertThatThrownBy { deviceService.registerDeviceFor(땡칠().id, DeviceTokenRequest(deviceToken)) }
.isInstanceOf(DuplicatedDeviceException::class.java)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.snackgame.server.messaging.push.service;

import static com.snackgame.server.member.fixture.MemberFixture.정환;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

import com.snackgame.server.member.fixture.MemberFixture;
import com.snackgame.server.messaging.push.service.dto.DeviceTokenRequest;
import com.snackgame.server.messaging.push.service.dto.NotificationRequest;
import com.snackgame.server.support.general.ServiceTest;

@SuppressWarnings("NonAsciiCharacters")
@Disabled
@ServiceTest
public class FCMPushServiceTest {

@Autowired
private FCMPushService fcmPushService;

@Autowired
private DeviceService deviceService;

@BeforeEach
void setUp() {
MemberFixture.saveAll();
}

@DisplayName("토큰을 통해 푸시알림을 전송할 수 있다")
@Test
void sendMessage() throws ExecutionException, InterruptedException {
deviceService.registerDeviceFor(정환().getId(),
new DeviceTokenRequest(
"cFwP3VYHh0eyoXkyY9MwJr:APA91bHX5YiVXuIvi-pLDqNHcJMhl7hKrqLTC7opFMbzj4CsXrg1wu2ayG_LFVREto678gQdWGUnmBXwKEpEJTfXheX0Fz83xwqDzVrKvXF3H5t07XXU6e-boq8JnZVCbs6NB_VfGRh8"));

Future<?> future = fcmPushService.sendPushMessage(
new NotificationRequest("테스트", "테스트"),
정환().getId());

future.get();
}

}
Loading