diff --git a/build.gradle b/build.gradle index 99237b17..4ca40309 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,9 @@ dependencies { //HTTP 클라이언트 implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3' + // Firebase Admin SDK + implementation 'com.google.firebase:firebase-admin:9.7.0' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java b/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java index d4d3a76a..2575622e 100644 --- a/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java +++ b/src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java @@ -9,6 +9,10 @@ public boolean hasAccess(User user, StudentCouncil writer) { return user.getSchool() != null && user.getSchool().getSchoolId().equals(writer.getSchool().getSchoolId()); } + + @Override public String topic(StudentCouncil writer) { + return "school_" + writer.getSchool().getSchoolId(); + } }, COLLEGE_COUNCIL { @Override @@ -16,6 +20,9 @@ public boolean hasAccess(User user, StudentCouncil writer) { return user.getCollege() != null && user.getCollege().getCollegeId().equals(writer.getCollege().getCollegeId()); } + @Override public String topic(StudentCouncil writer) { + return "college_" + writer.getCollege().getCollegeId(); + } }, MAJOR_COUNCIL { @Override @@ -23,7 +30,11 @@ public boolean hasAccess(User user, StudentCouncil writer) { return user.getMajor() != null && user.getMajor().getMajorId().equals(writer.getMajor().getMajorId()); } + @Override public String topic(StudentCouncil writer) { + return "major_" + writer.getMajor().getMajorId(); + } }; public abstract boolean hasAccess(User user, StudentCouncil writer); + public abstract String topic(StudentCouncil writer); } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java b/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java new file mode 100644 index 00000000..e9143360 --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java @@ -0,0 +1,57 @@ +package com.campus.campus.domain.councilpost.application; + +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent; +import com.campus.campus.domain.councilpost.domain.entity.PostCategory; +import com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CouncilPostPushListener { + + private static final String DATA_KEY_TYPE = "type"; + private static final String DATA_TYPE_COUNCIL_POST_CREATED = "COUNCIL_POST_CREATED"; + private static final String DATA_KEY_POST_ID = "postId"; + private static final String DATA_KEY_CATEGORY = "category"; + + private final FirebaseCloudMessageService firebaseCloudMessageService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCouncilPostCreatedEvent(CouncilPostCreatedEvent event) { + + String title = event.councilName(); + String body = resolveBody(event.category()); + + log.info("[PUSH] after_commit event received. topic={}, postId={}, category={}", + event.topic(), event.postId(), event.category()); + + firebaseCloudMessageService.sendToTopic( + event.topic(), + title, + body, + Map.of( + DATA_KEY_TYPE, DATA_TYPE_COUNCIL_POST_CREATED, + DATA_KEY_POST_ID, String.valueOf(event.postId()), + DATA_KEY_CATEGORY, event.category().name() + ) + ); + } + + private String resolveBody(PostCategory category) { + return switch (category) { + case PARTNERSHIP -> "새 제휴 게시글이 등록되었습니다."; + case EVENT -> "새 행사글이 등록되었습니다."; + }; + } +} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java new file mode 100644 index 00000000..aa9540de --- /dev/null +++ b/src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java @@ -0,0 +1,10 @@ +package com.campus.campus.domain.councilpost.application.dto.request; + +import com.campus.campus.domain.councilpost.domain.entity.PostCategory; + +public record CouncilPostCreatedEvent( + Long postId, + String councilName, + PostCategory category, + String topic +) {} diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java index 35404149..8b5c7c1b 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component; import com.campus.campus.domain.council.domain.entity.StudentCouncil; +import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent; import com.campus.campus.domain.councilpost.application.dto.request.PostRequest; import com.campus.campus.domain.councilpost.application.dto.response.GetActivePartnershipListForUserResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse; @@ -171,4 +172,15 @@ public LikePost createLikePost(User user, StudentCouncilPost post) { .user(user) .build(); } + + public CouncilPostCreatedEvent createPostCreatedEvent(StudentCouncilPost post, StudentCouncil writer) { + String topic = writer.getCouncilType().topic(writer); + + return new CouncilPostCreatedEvent( + post.getId(), + writer.getCouncilName(), + post.getCategory(), + topic + ); + } } diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java index 9807c406..e8eae7a2 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java @@ -4,7 +4,6 @@ import java.time.ZoneId; import java.util.HashSet; import java.util.List; -import java.util.Optional; import java.util.Set; import org.springframework.dao.DataIntegrityViolationException; @@ -18,9 +17,9 @@ import com.campus.campus.domain.council.domain.entity.CouncilType; import com.campus.campus.domain.councilpost.application.dto.response.GetActivePartnershipListForUserResponse; import com.campus.campus.domain.councilpost.application.dto.response.GetLikedPostResponse; +import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse; import com.campus.campus.domain.councilpost.application.dto.response.LikePostResponse; import com.campus.campus.domain.councilpost.application.dto.response.PostListItemResponse; -import com.campus.campus.domain.councilpost.application.dto.response.GetPostForUserResponse; import com.campus.campus.domain.councilpost.application.exception.CollegeNotSetException; import com.campus.campus.domain.councilpost.application.exception.MajorNotSetException; import com.campus.campus.domain.councilpost.application.exception.PostNotFoundException; diff --git a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java index 15ffbe11..99ce4b20 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java +++ b/src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -43,14 +44,14 @@ @RequiredArgsConstructor public class StudentCouncilPostService { + private static final int MAX_IMAGE_COUNT = 10; + private static final long UPCOMING_EVENT_WINDOW_HOURS = 72L; private final StudentCouncilPostRepository postRepository; private final StudentCouncilRepository studentCouncilRepository; private final PostImageRepository postImageRepository; private final PresignedUrlService presignedUrlService; private final StudentCouncilPostMapper studentCouncilPostMapper; - - private static final int MAX_IMAGE_COUNT = 10; - private static final long UPCOMING_EVENT_WINDOW_HOURS = 72L; + private final ApplicationEventPublisher eventPublisher; private final PlaceService placeService; private final StudentCouncilPostRepository studentCouncilPostRepository; private final PartnershipService partnershipService; @@ -78,7 +79,7 @@ public GetPostResponse create(Long councilId, PostRequest dto) { writer, place, dto, normalized.startDateTime(), normalized.endDateTime() ); - postRepository.save(post); + StudentCouncilPost saved = postRepository.save(post); if (dto.imageUrls() != null) { for (String imageUrl : dto.imageUrls()) { @@ -86,6 +87,8 @@ public GetPostResponse create(Long councilId, PostRequest dto) { } } + eventPublisher.publishEvent(studentCouncilPostMapper.createPostCreatedEvent(saved, writer)); + List imageUrls = postImageRepository .findAllByPostOrderByIdAsc(post) .stream() diff --git a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java index 4c2e9c1e..df91182c 100644 --- a/src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java +++ b/src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java @@ -1,7 +1,6 @@ package com.campus.campus.domain.councilpost.domain.repository; import java.util.List; -import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java index ef47fd0c..f2ff9353 100644 --- a/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java +++ b/src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java @@ -32,7 +32,6 @@ public record SavedPlaceInfo( String telephone, @Schema(description = "위도/경도") - @NotBlank Coordinate coordinate, @Schema(description = "이미지 url") diff --git a/src/main/java/com/campus/campus/global/config/AsyncConfig.java b/src/main/java/com/campus/campus/global/config/AsyncConfig.java new file mode 100644 index 00000000..77eeb7c3 --- /dev/null +++ b/src/main/java/com/campus/campus/global/config/AsyncConfig.java @@ -0,0 +1,30 @@ +package com.campus.campus.global.config; + +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "fcmTaskExecutor") + public ThreadPoolTaskExecutor fcmTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + + executor.setQueueCapacity(1000); + + executor.setThreadNamePrefix("fcm-"); + + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java b/src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java new file mode 100644 index 00000000..a8da5ac6 --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java @@ -0,0 +1,17 @@ +package com.campus.campus.global.firebase.application.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + + +public record FcmMessageRequestDto( + + @Schema(description = "유저ID", example = "1") + Long userId, + + @Schema(description = "메시지 발송인", example = "시스템") + String title, + + @Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.") + String body +) { +} diff --git a/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java b/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java new file mode 100644 index 00000000..c8a75d75 --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java @@ -0,0 +1,71 @@ +package com.campus.campus.global.firebase.application.service; + +import java.util.Map; +import java.util.concurrent.ThreadPoolExecutor; + +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Service; + +import com.campus.campus.global.firebase.exception.FcmTopicSendFailedException; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FirebaseCloudMessageService { + + private final ThreadPoolTaskExecutor fcmTaskExecutor; + + public void sendToTopic(String topic, String title, String body, Map data) { + Message.Builder builder = Message.builder() + .setTopic(topic) + .setNotification(Notification.builder() + .setTitle(title) + .setBody(body) + .build()); + + if (data != null) { + data.forEach(builder::putData); + } + + try { + String messageId = FirebaseMessaging.getInstance().send(builder.build()); + + log.info("[FCM] sent. topic={}, messageId={}, title={}, body={}, dataKeys={}, data={}, exec={}", + topic, + messageId, + title, + body, + (data == null ? "[]" : data.keySet()), + (data == null ? "{}" : data), + execSnapshot() + ); + + } catch (FirebaseMessagingException e) { + log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec{}", + topic, + e.getErrorCode(), + e.getMessage(), + execSnapshot(), + e + ); + throw new FcmTopicSendFailedException(e); + } + } + + //각각 스레드풀 로그 보기 위한 모니터링 메서드 + private String execSnapshot() { + ThreadPoolExecutor tp = fcmTaskExecutor.getThreadPoolExecutor(); + + return "pool=" + tp.getPoolSize() + "/" + tp.getMaximumPoolSize() + + ",active=" + tp.getActiveCount() + + ",queue=" + tp.getQueue().size() + + ",remain=" + tp.getQueue().remainingCapacity(); + } +} diff --git a/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java b/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java new file mode 100644 index 00000000..85423347 --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java @@ -0,0 +1,42 @@ +package com.campus.campus.global.firebase.application.service; + +import java.io.IOException; +import java.io.InputStream; + +import javax.annotation.PostConstruct; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import com.campus.campus.global.firebase.exception.FirebaseInitializationFailedException; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; + +@Component +@ConditionalOnProperty(prefix = "firebase", name = "enabled", havingValue = "true", matchIfMissing = true) +public class FirebaseInitializer { + + @Value("${firebase.credentials.path}") + private String firebaseCredentialsPath; + + @PostConstruct + public void initialize() { + if (!FirebaseApp.getApps().isEmpty()) { + return; + } + + try (InputStream serviceAccount = new ClassPathResource(firebaseCredentialsPath).getInputStream()) { + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + FirebaseApp.initializeApp(options); + + } catch (IOException e) { + throw new FirebaseInitializationFailedException(); + } + } +} diff --git a/src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java b/src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java new file mode 100644 index 00000000..f40b838b --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.campus.campus.global.firebase.exception; + +import org.springframework.http.HttpStatus; + +import com.campus.campus.global.common.exception.ErrorCodeInterface; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode implements ErrorCodeInterface { + + FIREBASE_INITIALIZATION_FAILED(6201, HttpStatus.INTERNAL_SERVER_ERROR, "Firebase 초기화에 실패했습니다."), + FCM_TOPIC_SEND_FAILED(6202, HttpStatus.INTERNAL_SERVER_ERROR, "푸시 알림 전송에 실패했습니다."); + + private final int code; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java b/src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java new file mode 100644 index 00000000..3a898738 --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java @@ -0,0 +1,7 @@ +package com.campus.campus.global.firebase.exception; + +public class FcmTopicSendFailedException extends RuntimeException { + public FcmTopicSendFailedException(Throwable cause) { + super(ErrorCode.FCM_TOPIC_SEND_FAILED.getMessage(), cause); + } +} diff --git a/src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java b/src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java new file mode 100644 index 00000000..9b6f0cc8 --- /dev/null +++ b/src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java @@ -0,0 +1,7 @@ +package com.campus.campus.global.firebase.exception; + +public class FirebaseInitializationFailedException extends RuntimeException { + public FirebaseInitializationFailedException() { + super(ErrorCode.FIREBASE_INITIALIZATION_FAILED.getMessage()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4ee5fa58..d4f53b65 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -65,4 +65,7 @@ map: geocoder: api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) -server-uri: ENC(ZrgPccnQ3mEqVQTFEvGn6hzhP4xcNn6ISnp3TbBcd1J3jpZPb3hlzQ==) \ No newline at end of file +server-uri: ENC(ZrgPccnQ3mEqVQTFEvGn6hzhP4xcNn6ISnp3TbBcd1J3jpZPb3hlzQ==) +firebase: + credentials: + path: ENC(hyLhnIpYz4qRgbIf++DZqC75HBgKs2lQYpbOVXZit5y/vdJGwIqB8J2TxkgI/7qN) \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index d41798f3..2943e5ee 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -65,4 +65,8 @@ map: geocoder: api-key: ${GEOCODER_API_KEY} -server-uri: http://localhost:8080 \ No newline at end of file +server-uri: http://localhost:8080 + +firebase: + credentials: + path: ${FIREBASE_CREDENTIALS_PATH} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 337d4215..f5ce2ba8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -65,4 +65,7 @@ map: geocoder: api-key: ENC(xgLHhhUSoE7yyS+SzF/KNmjs9swnSwQ2M1xKqWeJoXxrmhH/1rVOal2l5mZ9MP5E) -server-uri: ENC(pitqB0FTjbgREG33LepbDH3Pobg/8eTTzP882D0V14EEt9fTr1cv+w==) \ No newline at end of file +server-uri: ENC(pitqB0FTjbgREG33LepbDH3Pobg/8eTTzP882D0V14EEt9fTr1cv+w==) +firebase: + credentials: + path: ENC(hyLhnIpYz4qRgbIf++DZqC75HBgKs2lQYpbOVXZit5y/vdJGwIqB8J2TxkgI/7qN) \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 25689b8e..ecfb2623 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -69,3 +69,7 @@ management: enabled: false redis: enabled: false + +firebase: + enabled: false +