From d066964865b88a0d5750f3b4ca23af49091b0706 Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 12:41:07 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore:=20fcm=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ build.gradle | 3 +++ src/main/resources/application.yml | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/.gitignore b/.gitignore index f821ca3..e7f6541 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### docker data ### /docker/data/mysql/petlog_local + +# firebase +src/main/resources/firebase diff --git a/build.gradle b/build.gradle index 0fb33e9..1ba72f3 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,9 @@ dependencies { //AWS S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // Firebase + implementation 'com.google.firebase:firebase-admin:9.4.3' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 373bf06..00f777d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,3 +20,7 @@ cloud: credentials: access-key: ${AWS_ACCESS_KEY:access_key} secret-key: ${AWS_SECRET_KEY:secret_key} + +firebase: + petlog-firebase-key: + path: "/firebase/petlog-firebase-key.json" From a1edbc042393e9e145a585bc23a2fbb757be4efe Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 12:42:14 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20jwt=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/petlog/{common/config => auth}/jwt/JwtProperties.java | 2 +- .../com/petlog/{common/config => auth}/jwt/TokenProvider.java | 2 +- .../com/petlog/{common/config => auth}/jwt/TokenType.java | 2 +- .../config => auth}/jwt/filter/TokenAuthenticationFilter.java | 4 ++-- .../petlog/auth/resolver/AuthenticatedArgumentResolver.java | 2 +- src/main/java/com/petlog/auth/service/TokenService.java | 2 +- src/main/java/com/petlog/common/config/SecurityConfig.java | 4 ++-- src/test/java/com/petlog/common/config/jwt/JwtFactory.java | 1 + .../java/com/petlog/common/config/jwt/TokenProviderTest.java | 2 ++ 9 files changed, 12 insertions(+), 9 deletions(-) rename src/main/java/com/petlog/{common/config => auth}/jwt/JwtProperties.java (89%) rename src/main/java/com/petlog/{common/config => auth}/jwt/TokenProvider.java (98%) rename src/main/java/com/petlog/{common/config => auth}/jwt/TokenType.java (65%) rename src/main/java/com/petlog/{common/config => auth}/jwt/filter/TokenAuthenticationFilter.java (94%) diff --git a/src/main/java/com/petlog/common/config/jwt/JwtProperties.java b/src/main/java/com/petlog/auth/jwt/JwtProperties.java similarity index 89% rename from src/main/java/com/petlog/common/config/jwt/JwtProperties.java rename to src/main/java/com/petlog/auth/jwt/JwtProperties.java index 1fc16e0..ac21d57 100644 --- a/src/main/java/com/petlog/common/config/jwt/JwtProperties.java +++ b/src/main/java/com/petlog/auth/jwt/JwtProperties.java @@ -1,4 +1,4 @@ -package com.petlog.common.config.jwt; +package com.petlog.auth.jwt; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/petlog/common/config/jwt/TokenProvider.java b/src/main/java/com/petlog/auth/jwt/TokenProvider.java similarity index 98% rename from src/main/java/com/petlog/common/config/jwt/TokenProvider.java rename to src/main/java/com/petlog/auth/jwt/TokenProvider.java index cc22aec..bf970fd 100644 --- a/src/main/java/com/petlog/common/config/jwt/TokenProvider.java +++ b/src/main/java/com/petlog/auth/jwt/TokenProvider.java @@ -1,4 +1,4 @@ -package com.petlog.common.config.jwt; +package com.petlog.auth.jwt; import com.petlog.member.entity.Member; import io.jsonwebtoken.Claims; diff --git a/src/main/java/com/petlog/common/config/jwt/TokenType.java b/src/main/java/com/petlog/auth/jwt/TokenType.java similarity index 65% rename from src/main/java/com/petlog/common/config/jwt/TokenType.java rename to src/main/java/com/petlog/auth/jwt/TokenType.java index 23262bc..6279319 100644 --- a/src/main/java/com/petlog/common/config/jwt/TokenType.java +++ b/src/main/java/com/petlog/auth/jwt/TokenType.java @@ -1,4 +1,4 @@ -package com.petlog.common.config.jwt; +package com.petlog.auth.jwt; public enum TokenType { diff --git a/src/main/java/com/petlog/common/config/jwt/filter/TokenAuthenticationFilter.java b/src/main/java/com/petlog/auth/jwt/filter/TokenAuthenticationFilter.java similarity index 94% rename from src/main/java/com/petlog/common/config/jwt/filter/TokenAuthenticationFilter.java rename to src/main/java/com/petlog/auth/jwt/filter/TokenAuthenticationFilter.java index d9bb421..e621d9f 100644 --- a/src/main/java/com/petlog/common/config/jwt/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/petlog/auth/jwt/filter/TokenAuthenticationFilter.java @@ -1,6 +1,6 @@ -package com.petlog.common.config.jwt.filter; +package com.petlog.auth.jwt.filter; -import com.petlog.common.config.jwt.TokenProvider; +import com.petlog.auth.jwt.TokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/petlog/auth/resolver/AuthenticatedArgumentResolver.java b/src/main/java/com/petlog/auth/resolver/AuthenticatedArgumentResolver.java index 3101f7d..662aead 100644 --- a/src/main/java/com/petlog/auth/resolver/AuthenticatedArgumentResolver.java +++ b/src/main/java/com/petlog/auth/resolver/AuthenticatedArgumentResolver.java @@ -1,6 +1,6 @@ package com.petlog.auth.resolver; -import com.petlog.common.config.jwt.TokenProvider; +import com.petlog.auth.jwt.TokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.core.MethodParameter; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/petlog/auth/service/TokenService.java b/src/main/java/com/petlog/auth/service/TokenService.java index 2edeaf1..ecbab6c 100644 --- a/src/main/java/com/petlog/auth/service/TokenService.java +++ b/src/main/java/com/petlog/auth/service/TokenService.java @@ -2,7 +2,7 @@ import com.petlog.auth.entity.RefreshToken; import com.petlog.auth.repository.RefreshTokenRepository; -import com.petlog.common.config.jwt.TokenProvider; +import com.petlog.auth.jwt.TokenProvider; import com.petlog.member.entity.Member; import com.petlog.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/petlog/common/config/SecurityConfig.java b/src/main/java/com/petlog/common/config/SecurityConfig.java index 25a3462..26b2657 100644 --- a/src/main/java/com/petlog/common/config/SecurityConfig.java +++ b/src/main/java/com/petlog/common/config/SecurityConfig.java @@ -1,7 +1,7 @@ package com.petlog.common.config; -import com.petlog.common.config.jwt.TokenProvider; -import com.petlog.common.config.jwt.filter.TokenAuthenticationFilter; +import com.petlog.auth.jwt.TokenProvider; +import com.petlog.auth.jwt.filter.TokenAuthenticationFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/test/java/com/petlog/common/config/jwt/JwtFactory.java b/src/test/java/com/petlog/common/config/jwt/JwtFactory.java index 788bdae..6a53fe8 100644 --- a/src/test/java/com/petlog/common/config/jwt/JwtFactory.java +++ b/src/test/java/com/petlog/common/config/jwt/JwtFactory.java @@ -1,5 +1,6 @@ package com.petlog.common.config.jwt; +import com.petlog.auth.jwt.JwtProperties; import io.jsonwebtoken.Header; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; diff --git a/src/test/java/com/petlog/common/config/jwt/TokenProviderTest.java b/src/test/java/com/petlog/common/config/jwt/TokenProviderTest.java index 71f5dc8..569a4d9 100644 --- a/src/test/java/com/petlog/common/config/jwt/TokenProviderTest.java +++ b/src/test/java/com/petlog/common/config/jwt/TokenProviderTest.java @@ -1,5 +1,7 @@ package com.petlog.common.config.jwt; +import com.petlog.auth.jwt.JwtProperties; +import com.petlog.auth.jwt.TokenProvider; import com.petlog.member.entity.Member; import com.petlog.member.repository.MemberRepository; import io.jsonwebtoken.Jwts; From 743ed2590a02929dedef7b3362d090ad2c8ea633 Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 13:03:36 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20config=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/petlog/{common => auth}/config/SecurityConfig.java | 2 +- src/main/java/com/petlog/{common => s3}/config/S3Config.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/java/com/petlog/{common => auth}/config/SecurityConfig.java (98%) rename src/main/java/com/petlog/{common => s3}/config/S3Config.java (96%) diff --git a/src/main/java/com/petlog/common/config/SecurityConfig.java b/src/main/java/com/petlog/auth/config/SecurityConfig.java similarity index 98% rename from src/main/java/com/petlog/common/config/SecurityConfig.java rename to src/main/java/com/petlog/auth/config/SecurityConfig.java index 26b2657..85fd2fd 100644 --- a/src/main/java/com/petlog/common/config/SecurityConfig.java +++ b/src/main/java/com/petlog/auth/config/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.petlog.common.config; +package com.petlog.auth.config; import com.petlog.auth.jwt.TokenProvider; import com.petlog.auth.jwt.filter.TokenAuthenticationFilter; diff --git a/src/main/java/com/petlog/common/config/S3Config.java b/src/main/java/com/petlog/s3/config/S3Config.java similarity index 96% rename from src/main/java/com/petlog/common/config/S3Config.java rename to src/main/java/com/petlog/s3/config/S3Config.java index 8b7a89c..00524e2 100644 --- a/src/main/java/com/petlog/common/config/S3Config.java +++ b/src/main/java/com/petlog/s3/config/S3Config.java @@ -1,4 +1,4 @@ -package com.petlog.common.config; +package com.petlog.s3.config; import com.amazonaws.auth.AWSCredentials; import com.amazonaws.auth.AWSStaticCredentialsProvider; From 3dd3f04138c6dba96f052db9caf03ad66442bb79 Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 15:13:45 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EB=B0=98=EB=A0=A4=EB=8F=99?= =?UTF-8?q?=EB=AC=BC=20=EB=B0=A5,=20=EB=AC=BC=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/petlog/PetlogApplication.java | 2 + .../docs/NotificationControllerDocs.java | 22 +++++ .../notification/config/FirebaseConfig.java | 40 ++++++++ .../controller/NotificationController.java | 50 ++++++++++ .../controller/NotificationSuccessCode.java | 18 ++++ .../DeleteNotificationTokenRequestDto.java | 8 ++ .../SaveNotificationTokenRequestDto.java | 8 ++ .../entity/NotificationToken.java | 39 ++++++++ .../NotificationTokenRepository.java | 19 ++++ .../sender/FcmNotificationSender.java | 33 +++++++ .../sender/dto/FcmNotificationRequestDto.java | 49 ++++++++++ .../service/NotificationService.java | 87 +++++++++++++++++ .../pet/scheduling/PetInfoScheduling.java | 96 +++++++++++++++++++ 13 files changed, 471 insertions(+) create mode 100644 src/main/java/com/petlog/docs/NotificationControllerDocs.java create mode 100644 src/main/java/com/petlog/notification/config/FirebaseConfig.java create mode 100644 src/main/java/com/petlog/notification/controller/NotificationController.java create mode 100644 src/main/java/com/petlog/notification/controller/NotificationSuccessCode.java create mode 100644 src/main/java/com/petlog/notification/controller/dto/request/DeleteNotificationTokenRequestDto.java create mode 100644 src/main/java/com/petlog/notification/controller/dto/request/SaveNotificationTokenRequestDto.java create mode 100644 src/main/java/com/petlog/notification/entity/NotificationToken.java create mode 100644 src/main/java/com/petlog/notification/repository/NotificationTokenRepository.java create mode 100644 src/main/java/com/petlog/notification/sender/FcmNotificationSender.java create mode 100644 src/main/java/com/petlog/notification/sender/dto/FcmNotificationRequestDto.java create mode 100644 src/main/java/com/petlog/notification/service/NotificationService.java create mode 100644 src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java diff --git a/src/main/java/com/petlog/PetlogApplication.java b/src/main/java/com/petlog/PetlogApplication.java index 650793d..72f2069 100644 --- a/src/main/java/com/petlog/PetlogApplication.java +++ b/src/main/java/com/petlog/PetlogApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @SpringBootApplication public class PetlogApplication { diff --git a/src/main/java/com/petlog/docs/NotificationControllerDocs.java b/src/main/java/com/petlog/docs/NotificationControllerDocs.java new file mode 100644 index 0000000..8df4891 --- /dev/null +++ b/src/main/java/com/petlog/docs/NotificationControllerDocs.java @@ -0,0 +1,22 @@ +package com.petlog.docs; + +import com.petlog.auth.resolver.Authenticated; +import com.petlog.common.response.ApiResponse; +import com.petlog.notification.controller.dto.request.DeleteNotificationTokenRequestDto; +import com.petlog.notification.controller.dto.request.SaveNotificationTokenRequestDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "푸시 알림 API") +public interface NotificationControllerDocs { + + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "푸시 알림 토큰 저장에 성공하였습니다.") + @Operation(summary = "푸시 알림 토큰 저장 API") + ResponseEntity> saveNotificationToken(@Authenticated final Long memberId, @RequestBody final SaveNotificationTokenRequestDto request); + + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "푸시 알림 토큰 삭제에 성공하였습니다.") + @Operation(summary = "푸시 알림 토큰 삭제 API") + ResponseEntity> deleteNotificationToken(@Authenticated final Long memberId, @RequestBody final DeleteNotificationTokenRequestDto request); +} diff --git a/src/main/java/com/petlog/notification/config/FirebaseConfig.java b/src/main/java/com/petlog/notification/config/FirebaseConfig.java new file mode 100644 index 0000000..0a9ce1b --- /dev/null +++ b/src/main/java/com/petlog/notification/config/FirebaseConfig.java @@ -0,0 +1,40 @@ +package com.petlog.notification.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Configuration +public class FirebaseConfig { + + @Value("${firebase.service-account.path}") + private String SERVICE_ACCOUNT_PATH; + + @PostConstruct + public void init() throws IOException { + try { + final InputStream serviceAccount = new ClassPathResource(SERVICE_ACCOUNT_PATH).getInputStream(); + final FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + + log.info("FirebaseApp 초기화 성공 - {}", SERVICE_ACCOUNT_PATH); + } catch (final Exception e) { + log.error("FirebaseApp 초기화 실패", e); + throw e; + } + } +} diff --git a/src/main/java/com/petlog/notification/controller/NotificationController.java b/src/main/java/com/petlog/notification/controller/NotificationController.java new file mode 100644 index 0000000..0231261 --- /dev/null +++ b/src/main/java/com/petlog/notification/controller/NotificationController.java @@ -0,0 +1,50 @@ +package com.petlog.notification.controller; + +import com.petlog.auth.resolver.Authenticated; +import com.petlog.common.response.ApiResponse; +import com.petlog.docs.NotificationControllerDocs; +import com.petlog.notification.controller.dto.request.DeleteNotificationTokenRequestDto; +import com.petlog.notification.controller.dto.request.SaveNotificationTokenRequestDto; +import com.petlog.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +import static com.petlog.notification.controller.NotificationSuccessCode.DELETE_NOTIFICATION_TOKEN; +import static com.petlog.notification.controller.NotificationSuccessCode.SAVE_NOTIFICATION_TOKEN; + +@RequiredArgsConstructor +@RequestMapping("api/notification") +@RestController +public class NotificationController implements NotificationControllerDocs { + + private final NotificationService notificationService; + + @PostMapping("/token") + public ResponseEntity> saveNotificationToken( + @Authenticated final Long memberId, + @RequestBody final SaveNotificationTokenRequestDto request + ) { + notificationService.saveNotificationToken(memberId, request.token()); + + return ResponseEntity.ok( + ApiResponse.success(SAVE_NOTIFICATION_TOKEN) + ); + } + + @DeleteMapping("/token") + public ResponseEntity> deleteNotificationToken( + @Authenticated final Long memberId, + @RequestBody final DeleteNotificationTokenRequestDto request + ) { + notificationService.deleteNotificationToken(memberId, request.token()); + + return ResponseEntity.ok( + ApiResponse.success(DELETE_NOTIFICATION_TOKEN) + ); + } +} diff --git a/src/main/java/com/petlog/notification/controller/NotificationSuccessCode.java b/src/main/java/com/petlog/notification/controller/NotificationSuccessCode.java new file mode 100644 index 0000000..e5f454b --- /dev/null +++ b/src/main/java/com/petlog/notification/controller/NotificationSuccessCode.java @@ -0,0 +1,18 @@ +package com.petlog.notification.controller; + +import com.petlog.common.response.SuccessCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum NotificationSuccessCode implements SuccessCode { + + SAVE_NOTIFICATION_TOKEN(HttpStatus.CREATED.value(), "푸시 알림 토큰 저장에 성공하였습니다."), + DELETE_NOTIFICATION_TOKEN(HttpStatus.OK.value(), "푸시 알림 토큰 삭제에 성공하였습니다."), + ; + + private final int value; + private final String message; +} diff --git a/src/main/java/com/petlog/notification/controller/dto/request/DeleteNotificationTokenRequestDto.java b/src/main/java/com/petlog/notification/controller/dto/request/DeleteNotificationTokenRequestDto.java new file mode 100644 index 0000000..7a8a8ac --- /dev/null +++ b/src/main/java/com/petlog/notification/controller/dto/request/DeleteNotificationTokenRequestDto.java @@ -0,0 +1,8 @@ +package com.petlog.notification.controller.dto.request; + +public record DeleteNotificationTokenRequestDto( + + String token + +) { +} diff --git a/src/main/java/com/petlog/notification/controller/dto/request/SaveNotificationTokenRequestDto.java b/src/main/java/com/petlog/notification/controller/dto/request/SaveNotificationTokenRequestDto.java new file mode 100644 index 0000000..eeea72a --- /dev/null +++ b/src/main/java/com/petlog/notification/controller/dto/request/SaveNotificationTokenRequestDto.java @@ -0,0 +1,8 @@ +package com.petlog.notification.controller.dto.request; + +public record SaveNotificationTokenRequestDto( + + String token + +) { +} diff --git a/src/main/java/com/petlog/notification/entity/NotificationToken.java b/src/main/java/com/petlog/notification/entity/NotificationToken.java new file mode 100644 index 0000000..c9a0320 --- /dev/null +++ b/src/main/java/com/petlog/notification/entity/NotificationToken.java @@ -0,0 +1,39 @@ +package com.petlog.notification.entity; + +import com.petlog.common.entity.BaseEntity; +import com.petlog.member.entity.Member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notification_token") +@Entity +public class NotificationToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false, updatable = false) + private Member member; + + @Column(name = "value", length = 500, nullable = false, unique = true) + private String value; + + public NotificationToken(final Member member, final String value) { + this.member = member; + this.value = value; + } +} diff --git a/src/main/java/com/petlog/notification/repository/NotificationTokenRepository.java b/src/main/java/com/petlog/notification/repository/NotificationTokenRepository.java new file mode 100644 index 0000000..1ca51f0 --- /dev/null +++ b/src/main/java/com/petlog/notification/repository/NotificationTokenRepository.java @@ -0,0 +1,19 @@ +package com.petlog.notification.repository; + +import com.petlog.member.entity.Member; +import com.petlog.notification.entity.NotificationToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationTokenRepository extends JpaRepository { + + List findAllByMember_Id(final Long memberId); + + void deleteAllByValue(final String value); + + boolean existsByValue(final String token); + + Optional findByMemberAndValue(final Member member, final String value); +} diff --git a/src/main/java/com/petlog/notification/sender/FcmNotificationSender.java b/src/main/java/com/petlog/notification/sender/FcmNotificationSender.java new file mode 100644 index 0000000..0eabf10 --- /dev/null +++ b/src/main/java/com/petlog/notification/sender/FcmNotificationSender.java @@ -0,0 +1,33 @@ +package com.petlog.notification.sender; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.petlog.notification.sender.dto.FcmNotificationRequestDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class FcmNotificationSender { + + public void send(final FcmNotificationRequestDto request) { + try { + final String response = FirebaseMessaging.getInstance().send(request.convertFcmMessage()); + log.info("푸시 알림 전송 완료 - {}", response); + } + catch (final FirebaseMessagingException e) { + log.error("푸시 알림 전송에 실패하였습니다.", e); + handleFcmException(e.getMessage()); + } + } + + private void handleFcmException(final String errorResponse) { + if (checkInvalidFcmTokenResponse(errorResponse)) { + throw new IllegalArgumentException("유효하지 않은 푸시 알림 토큰입니다."); + } + } + + private boolean checkInvalidFcmTokenResponse(final String errorResponse) { + return errorResponse.contains("The registration token is not a valid FCM registration token"); + } +} diff --git a/src/main/java/com/petlog/notification/sender/dto/FcmNotificationRequestDto.java b/src/main/java/com/petlog/notification/sender/dto/FcmNotificationRequestDto.java new file mode 100644 index 0000000..946e703 --- /dev/null +++ b/src/main/java/com/petlog/notification/sender/dto/FcmNotificationRequestDto.java @@ -0,0 +1,49 @@ +package com.petlog.notification.sender.dto; + +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +public record FcmNotificationRequestDto( + + String fcmToken, + String title, + String body, + String type + +) { + + public Message convertFcmMessage() { + return Message.builder() + .setNotification(createNotification()) + .setToken(fcmToken) + .setApnsConfig(createApnsConfig()) + .setAndroidConfig(createAndroidConfig()) + .putData("type", type) + .build(); + } + + private ApnsConfig createApnsConfig() { + return ApnsConfig.builder() + .setAps( + Aps.builder() + .setContentAvailable(true) + .build()) + .build(); + } + + private static AndroidConfig createAndroidConfig() { + return AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) + .build(); + } + + private Notification createNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } +} diff --git a/src/main/java/com/petlog/notification/service/NotificationService.java b/src/main/java/com/petlog/notification/service/NotificationService.java new file mode 100644 index 0000000..3ad3bd7 --- /dev/null +++ b/src/main/java/com/petlog/notification/service/NotificationService.java @@ -0,0 +1,87 @@ +package com.petlog.notification.service; + +import com.petlog.member.entity.Member; +import com.petlog.member.repository.MemberRepository; +import com.petlog.notification.entity.NotificationToken; +import com.petlog.notification.repository.NotificationTokenRepository; +import com.petlog.notification.sender.FcmNotificationSender; +import com.petlog.notification.sender.dto.FcmNotificationRequestDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Service +public class NotificationService { + + private final FcmNotificationSender fcmNotificationSender; + private final NotificationTokenRepository notificationTokenRepository; + private final MemberRepository memberRepository; + + @Transactional + public void sendNotification( + final Long addresseeId, + final String title, + final String body, + final String type + ) { + notificationTokenRepository.findAllByMember_Id(addresseeId).forEach( + notificationToken -> sendNotification(notificationToken, title, body, type)); + } + + private void sendNotification( + final NotificationToken notificationToken, + final String title, + final String body, + final String type + ) { + try { + final FcmNotificationRequestDto fcmNotificationRequest = new FcmNotificationRequestDto(notificationToken.getValue(), title, body, type); + fcmNotificationSender.send(fcmNotificationRequest); + } + catch (final IllegalArgumentException e) { + notificationTokenRepository.deleteAllByValue(notificationToken.getValue()); + log.info("유효하지 않은 토큰 제거 - {}", notificationToken.getValue()); + } + } + + @Transactional + public void saveNotificationToken(final Long memberId, final String notificationToken) { + validateNotificationTokenIsNullOrEmpty(notificationToken); + + final Member member = getMember(memberId); + if (notificationTokenRepository.existsByValue(notificationToken)) { + log.trace("이미 존재하는 푸시 알림 토큰입니다."); + return; + } + + final NotificationToken notificationTokenJpaEntity = new NotificationToken(member, notificationToken); + notificationTokenRepository.save(notificationTokenJpaEntity); + log.trace("푸시 알림 토큰 저장 - {}", notificationToken); + } + + private void validateNotificationTokenIsNullOrEmpty(final String token) { + if (token == null || token.isEmpty()) { + log.info("입력된 알림 토큰 값이 null 혹은 공백 - {}", token); + throw new IllegalArgumentException("알림 토큰 값은 공백일 수 없습니다."); + } + } + + private Member getMember(final Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + } + + @Transactional + public void deleteNotificationToken(final Long memberId, final String notificationToken) { + validateNotificationTokenIsNullOrEmpty(notificationToken); + + final Member member = getMember(memberId); + notificationTokenRepository.findByMemberAndValue(member, notificationToken) + .ifPresent(notificationTokenJpaEntity -> { + notificationTokenRepository.delete(notificationTokenJpaEntity); + log.info("푸시 알림 토큰 제거 - {}", notificationToken);}); + } +} diff --git a/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java b/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java new file mode 100644 index 0000000..f448f96 --- /dev/null +++ b/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java @@ -0,0 +1,96 @@ +package com.petlog.pet.scheduling; + +import com.petlog.notification.service.NotificationService; +import com.petlog.pet.entity.FeedingDailyRecord; +import com.petlog.pet.entity.PetProfile; +import com.petlog.pet.entity.WateringDailyRecord; +import com.petlog.pet.repository.FeedingDailyRecordRepository; +import com.petlog.pet.repository.PetProfileRepository; +import com.petlog.pet.repository.WateringDailyRecordRepository; +import com.petlog.petgroup.entity.PetGroup; +import com.petlog.petgroup.entity.PetGroupMember; +import com.petlog.petgroup.repository.PetGroupMemberRepository; +import com.petlog.petgroup.repository.PetGroupRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class PetInfoScheduling { + + private final NotificationService notificationService; + private final PetProfileRepository petProfileRepository; + private final PetGroupRepository petGroupRepository; + private final PetGroupMemberRepository petGroupMemberRepository; + private final FeedingDailyRecordRepository feedingDailyRecordRepository; + private final WateringDailyRecordRepository wateringDailyRecordRepository; + + @Scheduled(cron = "0 0 0/1 * * *") + public void checkFeedingCycleAndSendNotification() { + final List petGroups = petGroupRepository.findAll(); + + for(final PetGroup petGroup : petGroups) { + final List groupMembers = getPetGroupMembers(petGroup); + + if (groupMembers.isEmpty()) { + continue; + } + + final PetProfile petProfile = petProfileRepository.findByPetGroupId(petGroup.getId()); + final FeedingDailyRecord feedingDailyRecord = feedingDailyRecordRepository.findTopByPetProfileOrderByTimeDesc(petProfile); + final WateringDailyRecord wateringDailyRecord = wateringDailyRecordRepository.findTopByPetProfileOrderByTimeDesc(petProfile); + + final long hoursSinceLastFeeding = Duration + .between(feedingDailyRecord.getTime(), LocalDateTime.now()) + .toHours(); + + final long hoursSinceLastWatering = Duration + .between(wateringDailyRecord.getTime(), LocalDateTime.now()) + .toHours(); + + if(hoursSinceLastFeeding >= petProfile.getFeedingCycle()) { + sendFeedingNotificationToAllGroupMembers(groupMembers, petProfile, hoursSinceLastFeeding); + } + + if(hoursSinceLastWatering >= petProfile.getWateringCycle()) { + sendWateringNotificationToAllGroupMembers(groupMembers, petProfile, hoursSinceLastWatering); + } + } + } + + private List getPetGroupMembers(final PetGroup petGroup) { + return petGroupMemberRepository.findAllByPetGroup(petGroup) + .orElse(List.of()); + } + + private void sendFeedingNotificationToAllGroupMembers(final List groupMembers, final PetProfile petProfile, final Long hoursSinceLastFeeding) { + for (final PetGroupMember groupMember : groupMembers) { + final Long groupMemberId = groupMember.getMember().getId(); + + notificationService.sendNotification( + groupMemberId, + petProfile.getName() + "이에게 밥을 줄 시간이에요!", + "밥을 준 지 " + hoursSinceLastFeeding + "시간이 지났어요.", + "FEEDING" + ); + } + } + + private void sendWateringNotificationToAllGroupMembers(final List groupMembers, final PetProfile petProfile, final Long hoursSinceLastWatering) { + for (final PetGroupMember groupMember : groupMembers) { + final Long groupMemberId = groupMember.getMember().getId(); + + notificationService.sendNotification( + groupMemberId, + petProfile.getName() + "이에게 물을 교체해 줄 시간이에요!", + "물을 교체해 준 지 " + hoursSinceLastWatering + "시간이 지났어요.", + "WATERING" + ); + } + } +} From 74ad7da50265a7ceb14a50ecc2d345c4f3dd4043 Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 15:21:01 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=EA=B7=B8=EB=A3=B9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EB=A7=88=EC=A7=80=EB=A7=89=20=EB=B0=A5?= =?UTF-8?q?,=20=EB=AC=BC=20=EC=A4=80=20=EC=8B=9C=EA=B0=84=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/petlog/pet/entity/FeedingDailyRecord.java | 12 ++++++++++++ .../com/petlog/pet/entity/WateringDailyRecord.java | 12 ++++++++++++ .../com/petlog/petgroup/service/PetGroupService.java | 2 ++ 3 files changed, 26 insertions(+) diff --git a/src/main/java/com/petlog/pet/entity/FeedingDailyRecord.java b/src/main/java/com/petlog/pet/entity/FeedingDailyRecord.java index ad18fd7..b9c4df9 100644 --- a/src/main/java/com/petlog/pet/entity/FeedingDailyRecord.java +++ b/src/main/java/com/petlog/pet/entity/FeedingDailyRecord.java @@ -41,6 +41,18 @@ public class FeedingDailyRecord extends BaseEntity { @Column(name = "memo", length = 200, nullable = true) private String memo; + public FeedingDailyRecord( + final PetProfile petProfile, + final Member member, + final LocalDateTime time, + final String memo + ) { + this.petProfile = petProfile; + this.member = member; + this.time = time; + this.memo = memo; + } + public FeedingDailyRecord( final PetProfile petProfile, final Member member, diff --git a/src/main/java/com/petlog/pet/entity/WateringDailyRecord.java b/src/main/java/com/petlog/pet/entity/WateringDailyRecord.java index 3836a11..6922230 100644 --- a/src/main/java/com/petlog/pet/entity/WateringDailyRecord.java +++ b/src/main/java/com/petlog/pet/entity/WateringDailyRecord.java @@ -41,6 +41,18 @@ public class WateringDailyRecord extends BaseEntity { @Column(name = "memo", length = 200, nullable = true) private String memo; + public WateringDailyRecord( + final PetProfile petProfile, + final Member member, + final LocalDateTime time, + final String memo + ) { + this.petProfile = petProfile; + this.member = member; + this.time = time; + this.memo = memo; + } + public WateringDailyRecord( final PetProfile petProfile, final Member member, diff --git a/src/main/java/com/petlog/petgroup/service/PetGroupService.java b/src/main/java/com/petlog/petgroup/service/PetGroupService.java index c47e264..bb1afcd 100644 --- a/src/main/java/com/petlog/petgroup/service/PetGroupService.java +++ b/src/main/java/com/petlog/petgroup/service/PetGroupService.java @@ -61,6 +61,7 @@ public void createPetGroup(final Long memberId, final CreatePetGroupDto dto) { final FeedingDailyRecord feedingDailyRecord = new FeedingDailyRecord( petProfile, member, + dto.lastFeedingTime(), null ); feedingDailyRecordRepository.save(feedingDailyRecord); @@ -68,6 +69,7 @@ public void createPetGroup(final Long memberId, final CreatePetGroupDto dto) { final WateringDailyRecord wateringDailyRecord = new WateringDailyRecord( petProfile, member, + dto.lastWateringTime(), null ); wateringDailyRecordRepository.save(wateringDailyRecord); From 88b73aa61388720155179920560570ac164664e4 Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 17:57:33 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20fcm=20=ED=82=A4=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci-cd.yml | 5 +++++ .../java/com/petlog/notification/config/FirebaseConfig.java | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 0c0060a..013a83c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -34,6 +34,11 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew\ + - name: Setup Firebase service key + run: | + mkdir -p src/main/resources/firebase + echo ${{ secrets.FIREBASE_SERVICE_KEY_BASE64_ENCODE }} | base64 -d > src/main/resources/firebase/petlog-firebase-key.json + - name: Build with gradle run: ./gradlew bootJar -Pspring.profiles.active=dev --info diff --git a/src/main/java/com/petlog/notification/config/FirebaseConfig.java b/src/main/java/com/petlog/notification/config/FirebaseConfig.java index 0a9ce1b..bf390b4 100644 --- a/src/main/java/com/petlog/notification/config/FirebaseConfig.java +++ b/src/main/java/com/petlog/notification/config/FirebaseConfig.java @@ -16,7 +16,7 @@ @Configuration public class FirebaseConfig { - @Value("${firebase.service-account.path}") + @Value("${firebase.petlog-firebase-key.path}") private String SERVICE_ACCOUNT_PATH; @PostConstruct From d16d85ce9ad52d6d73f4bc38faa82928c69ddbdf Mon Sep 17 00:00:00 2001 From: Hwangseoeun Date: Mon, 1 Dec 2025 17:58:13 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EB=A7=A4=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=A0=95=EA=B0=81=EC=97=90=20=EC=B2=B4=ED=81=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java b/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java index f448f96..6b4cd21 100644 --- a/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java +++ b/src/main/java/com/petlog/pet/scheduling/PetInfoScheduling.java @@ -30,7 +30,7 @@ public class PetInfoScheduling { private final FeedingDailyRecordRepository feedingDailyRecordRepository; private final WateringDailyRecordRepository wateringDailyRecordRepository; - @Scheduled(cron = "0 0 0/1 * * *") + @Scheduled(cron = "0 0 */1 * * *") public void checkFeedingCycleAndSendNotification() { final List petGroups = petGroupRepository.findAll();