-
Notifications
You must be signed in to change notification settings - Fork 0
feat/#45: 학생회 게시글 작성 시, 학생에게 푸시알림 기능 구현 #51
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
Changes from all commits
ba45b2d
bf3ff03
4eeee56
62ede9c
f7c875e
4cd891a
604495c
cf57384
541c15a
c8f0c6c
812256c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,21 +9,32 @@ 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 | ||||||||||||||||||||
| 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(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+23
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null 안전성 검증 필요
🛡️ Null 안전성을 위한 수정안 @Override public String topic(StudentCouncil writer) {
+ if (writer.getCollege() == null) {
+ throw new IllegalStateException("COLLEGE_COUNCIL writer must have a college");
+ }
return "college_" + writer.getCollege().getCollegeId();
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }, | ||||||||||||||||||||
| MAJOR_COUNCIL { | ||||||||||||||||||||
| @Override | ||||||||||||||||||||
| 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(); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+33
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Null 안전성 검증 필요
🛡️ Null 안전성을 위한 수정안 @Override public String topic(StudentCouncil writer) {
+ if (writer.getMajor() == null) {
+ throw new IllegalStateException("MAJOR_COUNCIL writer must have a major");
+ }
return "major_" + writer.getMajor().getMajorId();
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| public abstract boolean hasAccess(User user, StudentCouncil writer); | ||||||||||||||||||||
| public abstract String topic(StudentCouncil writer); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
1winhyun marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 -> "새 행사글이 등록되었습니다."; | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
1winhyun marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
|
Comment on lines
+6
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 요청 DTO에 Validation(@NotNull/@notblank) 추가 + Schema 설명 정합성 맞추기 권장. Proposed diff package com.campus.campus.global.firebase.application.dto;
import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
public record FcmMessageRequestDto(
@Schema(description = "유저ID", example = "1")
+ @NotNull
Long userId,
- @Schema(description = "메시지 발송인", example = "시스템")
+ @Schema(description = "메시지 제목", example = "시스템")
+ @NotBlank
String title,
@Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.")
+ @NotBlank
String body
) {
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<String, String> 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{}", | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 로그 포맷 오류를 수정하세요. Line 51의 로그 메시지에서 🐛 수정 제안- log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec{}",
+ log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec={}",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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(); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 148
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 145
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 50380
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 1615
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 4230
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 266
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 1682
🏁 Script executed:
Repository: our-campUS/our-campUS-BE
Length of output: 273
필드의 null 안전성 검증 필요 (college, major의 경우)
StudentCouncil의
school필드는 데이터베이스 레벨에서nullable = false제약이 있어 SCHOOL_COUNCIL의 topic() 메서드는 즉시적인 NPE 위험이 낮습니다. 하지만college와major필드는 nullable이 기본값이므로 COLLEGE_COUNCIL과 MAJOR_COUNCIL의 topic() 메서드는 null 체크 없이 이들 필드에 접근하고 있습니다.또한 hasAccess() 메서드는
user의 필드만 null 검증하고writer의 필드는 검증하지 않으므로, 방어적 프로그래밍 관점에서 topic() 메서드에서도 일관되게 null 체크를 수행하는 것이 좋습니다. 특히 lazy-loading 환경에서 예상치 못한 상황이 발생할 수 있습니다.모든 council type의 topic() 메서드에 명시적인 null 체크를 추가하여 일관성과 안정성을 확보하기를 권장합니다.
🤖 Prompt for AI Agents