-
Notifications
You must be signed in to change notification settings - Fork 0
Feat: [FN-124][FN-125] 알림 생성 및 목록 조회 기능 #30
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
6591c61
8ac54a7
7286e0b
59f2b25
02057f9
0a6181c
b265023
33e955b
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 |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package project.flipnote.common.entity; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
|
|
||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.core.type.TypeReference; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
|
|
||
| import jakarta.persistence.AttributeConverter; | ||
| import jakarta.persistence.Converter; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Converter | ||
| @Component | ||
| public class MapToJsonConverter implements AttributeConverter<Map<String, Object>, String> { | ||
|
|
||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @Override | ||
| public String convertToDatabaseColumn(Map<String, Object> attribute) { | ||
| if (attribute == null || attribute.isEmpty()) { | ||
| return "{}"; | ||
| } | ||
| try { | ||
| return objectMapper.writeValueAsString(attribute); | ||
| } catch (JsonProcessingException ex) { | ||
| throw new IllegalArgumentException("JSON 변환 실패", ex); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, Object> convertToEntityAttribute(String dbData) { | ||
| if (dbData == null || dbData.isBlank()) { | ||
| return new HashMap<>(); | ||
| } | ||
| try { | ||
| return objectMapper.readValue(dbData, new TypeReference<Map<String, Object>>() { | ||
| }); | ||
| } catch (IOException ex) { | ||
| throw new IllegalArgumentException("JSON 파싱 실패", ex); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package project.flipnote.common.model.event; | ||
|
|
||
| public record GroupInvitationCreatedEvent( | ||
| Long groupId, | ||
| Long inviteeId | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package project.flipnote.common.model.request; | ||
|
|
||
| import org.springframework.util.StringUtils; | ||
|
|
||
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| import jakarta.validation.constraints.Max; | ||
| import jakarta.validation.constraints.Min; | ||
| import lombok.Getter; | ||
| import lombok.Setter; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| public class CursorPageRequest { | ||
|
|
||
| private String cursor; | ||
|
|
||
| @Min(1) | ||
| @Max(30) | ||
| private Integer size = 10; | ||
|
|
||
| @Schema(hidden = true) | ||
| public Long getCursorId() { | ||
| if (!StringUtils.hasText(cursor)) { | ||
| return null; | ||
| } | ||
|
|
||
| final String normalized = cursor.trim(); | ||
| if (normalized.isEmpty()) { | ||
| return null; | ||
| } | ||
|
|
||
| return Long.valueOf(normalized); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| package project.flipnote.common.response; | ||
| package project.flipnote.common.model.response; | ||
|
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. 🛠️ Refactor suggestion 바이너리/스트리밍/다운로드 응답은 래핑에서 제외하세요. beforeBodyWrite에 아래 가드를 추가해 주세요. @Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response
) {
if (body == null || body instanceof ApiResponse) {
return body;
}
// 바이너리/스트리밍/다운로드 응답은 패스
if (body instanceof org.springframework.core.io.Resource
|| body instanceof byte[]
|| body instanceof org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
|| MediaType.APPLICATION_OCTET_STREAM.includes(selectedContentType)
|| MediaType.TEXT_EVENT_STREAM.includes(selectedContentType)) {
return body;
}
int status = 200;
if (response instanceof ServletServerHttpResponse servletResponse) {
status = servletResponse.getServletResponse().getStatus();
}
return ApiResponse.success(status, body);
}🤖 Prompt for AI Agents |
||
|
|
||
| import org.springframework.core.MethodParameter; | ||
| import org.springframework.http.MediaType; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package project.flipnote.common.model.response; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Objects; | ||
|
|
||
|
Comment on lines
+3
to
+5
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. 🛠️ Refactor suggestion 널/불변성 안전성 강화 및 직렬화 최적화(@JsonInclude) 제안
아래 변경을 제안합니다.
import java.util.List;
import java.util.Objects;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
- public record CursorPageResponse<T>(
+@JsonInclude(Include.NON_NULL)
+public record CursorPageResponse<T>(
- public static <T> CursorPageResponse<T> of(List<T> content, boolean hasNext, String nextCursor) {
- return new CursorPageResponse<>(content, hasNext, hasNext ? nextCursor : null, content.size());
- }
+ public static <T> CursorPageResponse<T> of(List<T> content, boolean hasNext, String nextCursor) {
+ List<T> safeContent = content == null ? List.of() : List.copyOf(content);
+ return new CursorPageResponse<>(safeContent, hasNext, hasNext ? nextCursor : null, safeContent.size());
+ }참고: 필요하다면 빈 페이지를 쉽게 만들 수 있도록 Also applies to: 6-11, 13-20 🤖 Prompt for AI Agents |
||
| public record CursorPageResponse<T>( | ||
| List<T> content, | ||
| boolean hasNext, | ||
| String nextCursor, | ||
| int size | ||
| ) { | ||
|
|
||
| public static <T> CursorPageResponse<T> of(List<T> content, boolean hasNext, String nextCursor) { | ||
| return new CursorPageResponse<>(content, hasNext, hasNext ? nextCursor : null, content.size()); | ||
| } | ||
|
|
||
| public static <T> CursorPageResponse<T> of(List<T> content, boolean hasNext, Long nextCursorId) { | ||
| String nextCursor = Objects.toString(nextCursorId, null); | ||
| return of(content, hasNext, nextCursor); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,11 @@ | ||
| package project.flipnote.group.model; | ||
|
|
||
| import project.flipnote.group.entity.GroupInvitation; | ||
|
|
||
| public enum GroupInvitationStatus { | ||
| PENDING, ACCEPTED, REJECTED, EXPIRED; | ||
|
|
||
| public static GroupInvitationStatus from(project.flipnote.group.entity.GroupInvitationStatus status) { | ||
| if (status == null) { | ||
| throw new IllegalArgumentException("GroupInvitationStatus is null"); | ||
| } | ||
|
|
||
| return switch (status) { | ||
| case PENDING -> PENDING; | ||
| case ACCEPTED -> ACCEPTED; | ||
| case REJECTED -> REJECTED; | ||
| case EXPIRED -> EXPIRED; | ||
| default -> throw new IllegalArgumentException("Unknown GroupInvitationStatus: " + status); | ||
| }; | ||
| public static GroupInvitationStatus from(GroupInvitation invitation) { | ||
| return GroupInvitationStatus.valueOf(invitation.getStatus().name()); | ||
| } | ||
|
Comment on lines
+8
to
10
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. NPE 가능성: 파라미터 null 처리 누락 invitation이 null이면 NullPointerException이 발생합니다. 방어 로직을 추가해 주세요. 아래처럼 보완을 제안합니다. public static GroupInvitationStatus from(GroupInvitation invitation) {
- return GroupInvitationStatus.valueOf(invitation.getStatus().name());
+ Objects.requireNonNull(invitation, "invitation must not be null");
+ return GroupInvitationStatus.valueOf(invitation.getStatus().name());
}그리고 import 추가가 필요합니다(선택된 범위 외 추가): import java.util.Objects;🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -20,4 +20,6 @@ public interface GroupRepository extends JpaRepository<Group, Long> { | |||||||||
| @Query("select g from Group g where g.id = :id") | ||||||||||
| Optional<Group> findByIdForUpdate(@Param("id") Long id); | ||||||||||
|
|
||||||||||
| @Query("SELECT g.name FROM Group g WHERE g.id = :id") | ||||||||||
| Optional<String> findGroupNameById(@Param("id") Long id); | ||||||||||
|
Comment on lines
+23
to
+24
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. 🛠️ Refactor suggestion 소프트 삭제 필터 누락: 그룹명이 삭제된 그룹에서도 조회될 수 있음 다른 조회 메서드들이 최소 수정안: - @Query("SELECT g.name FROM Group g WHERE g.id = :id")
+ @Query("SELECT g.name FROM Group g WHERE g.id = :id AND g.deletedAt IS NULL")
Optional<String> findGroupNameById(@Param("id") Long id);대안(선택): 파생 쿼리 메서드로 전환해 쿼리 문자열 의존도를 줄일 수 있습니다. - @Query("SELECT g.name FROM Group g WHERE g.id = :id AND g.deletedAt IS NULL")
- Optional<String> findGroupNameById(@Param("id") Long id);
+ Optional<String> findNameByIdAndDeletedAtIsNull(Long id);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -189,4 +189,9 @@ public GroupDetailResponse findGroupDetail(AuthPrinciple authPrinciple, Long gro | |||||||||||||||
| public void deleteGroup(AuthPrinciple authPrinciple, Long groupId) { | ||||||||||||||||
|
|
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| public String findGroupName(Long groupId) { | ||||||||||||||||
| return groupRepository.findGroupNameById(groupId) | ||||||||||||||||
| .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND)); | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+193
to
+196
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. 🛠️ Refactor suggestion 소프트 삭제 그룹까지 노출 가능성: 삭제되지 않은 그룹만 대상으로 제한 필요
두 가지 중 하나로 정리해 주세요.
옵션 A를 위한 리포지토리 수정(다른 파일 변경 필요): - @Query("SELECT g.name FROM Group g WHERE g.id = :id")
+ @Query("SELECT g.name FROM Group g WHERE g.id = :id AND g.deletedAt IS NULL")
Optional<String> findGroupNameById(@Param("id") Long id);옵션 B: 서비스에서 엔티티 조회 재사용(성능상 이름만 조회보다 무겁지만, 도메인 규칙 일관성 확보) - public String findGroupName(Long groupId) {
- return groupRepository.findGroupNameById(groupId)
- .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND));
- }
+ public String findGroupName(Long groupId) {
+ return validateGroup(groupId).getName();
+ }추가 제안:
📝 Committable suggestion
Suggested change
|
||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package project.flipnote.infra.firebase; | ||
|
|
||
| public enum FcmErrorCode { | ||
| INVALID_ARGUMENT, | ||
| UNREGISTERED, | ||
| SENDER_ID_MISMATCH, | ||
| QUOTA_EXCEEDED, | ||
| DEVICE_MESSAGE_RATE_EXCEEDED, | ||
| TOPIC_MESSAGE_RATE_EXCEEDED, | ||
| UNAVAILABLE, | ||
| INTERNAL, | ||
| UNKNOWN; | ||
|
Comment on lines
+3
to
+12
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. 💡 Verification agent 🧩 Analysis chainFCM 에러 코드 커버리지 확인 및 보강 권고 현재 열거형이 주요 코드들을 포함하지만, Firebase Admin SDK가 반환할 수 있는 일부 코드(APNS/서드파티 인증 관련 등)가 누락되었을 가능성이 있습니다. 실제로 사용하는 SDK/HTTP 응답이 어떤 코드 집합을 반환하는지 점검 후 필요 시 상수를 보강해주세요. 다음 스크립트로 Admin SDK에서 사용하는 에러 코드 집합을 빠르게 확인해보세요: 원한다면 제가 최신 레퍼런스를 기반으로 누락 상수 후보를 정리해 드리겠습니다. 🏁 Script executed: #!/bin/bash
# MessagingErrorCode 상수 목록 점검
rg -n "enum MessagingErrorCode" -A 200 $(fd MessagingErrorCode.java)
# 또는 공개 문서/레퍼런스 확인이 필요하다면 웹 검색을 사용해주세요.Length of output: 70 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== Show target enum file (first 300 lines) ==="
sed -n '1,300p' src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java || true
echo
echo "=== Search for MessagingErrorCode occurrences ==="
rg -n --hidden --no-ignore-vcs 'MessagingErrorCode' || true
echo
echo "=== Search for FcmErrorCode usages ==="
rg -n --hidden --no-ignore-vcs 'FcmErrorCode' || true
echo
echo "=== Search for Firebase Messaging imports in Java files ==="
rg -n --hidden --no-ignore-vcs 'com\.google\.firebase\.messaging' -g '**/*.java' || true
echo
echo "=== Search for enums mentioning 'Error' in Java files (snippets) ==="
rg -n --hidden --no-ignore-vcs -g '**/*.java' -S 'enum .*Error' -n -C 2 || true
echo
echo "=== List files referencing 'firebase' in Java sources (top 50) ==="
rg -n --hidden --no-ignore-vcs 'firebase' -g '**/*.java' | sed -n '1,50p' || trueLength of output: 12709 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "=== NotificationService.java (1..240) ==="
sed -n '1,240p' src/main/java/project/flipnote/notification/service/NotificationService.java || true
echo
echo "=== FirebaseService.java (1..240) ==="
sed -n '1,240p' src/main/java/project/flipnote/infra/firebase/FirebaseService.java || trueLength of output: 7931 🌐 Web query: 💡 Result: The enum
Each of these enum values represents a specific class of error you may encounter when sending or validating messages via the Firebase Admin SDK for Java. The documented values enable precise error handling and logic for fallback or remediation. Additional context:
For the most accurate and current set of Citations:
FCM 에러 코드에 THIRD_PARTY_AUTH_ERROR 누락 — 상수 추가 필요 공식 Firebase Admin Java의 com.google.firebase.messaging.MessagingErrorCode에 THIRD_PARTY_AUTH_ERROR(예: APNs/서드파티 인증 관련)가 정의되어 있습니다. 현재 프로젝트의 FcmErrorCode 열거형에 해당 상수가 없어 해당 에러가 UNKNOWN으로 처리될 수 있으니 추가해주세요. 수정 대상
권장 변경 예시: 대안으로는 com.google.firebase.messaging.MessagingErrorCode를 직접 사용하거나, SDK 업데이트 시 누락 항목을 주기적으로 동기화하는 방법도 고려하세요. 🤖 Prompt for AI Agents |
||
|
|
||
| public static FcmErrorCode from(String code) { | ||
| try { | ||
| return FcmErrorCode.valueOf(code); | ||
| } catch (Exception e) { | ||
| return UNKNOWN; | ||
| } | ||
| } | ||
|
Comment on lines
+14
to
+20
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. 🛠️ Refactor suggestion from(String) 정상화 처리(대소문자/하이픈) 및 과도한 예외 캐치 축소 외부 소스가 전달하는 코드 문자열 포맷이 제각각일 수 있어 대소문자/하이픈을 정규화하는 것이 안전합니다. 또한 Exception 전체를 캐치하기보다 예상 예외로 한정하는 편이 좋습니다. 아래와 같이 갱신을 권장합니다: - public static FcmErrorCode from(String code) {
- try {
- return FcmErrorCode.valueOf(code);
- } catch (Exception e) {
- return UNKNOWN;
- }
- }
+ public static FcmErrorCode from(String code) {
+ if (code == null || code.isBlank()) {
+ return UNKNOWN;
+ }
+ String normalized = code.trim().toUpperCase(java.util.Locale.ROOT).replace('-', '_');
+ try {
+ return FcmErrorCode.valueOf(normalized);
+ } catch (IllegalArgumentException e) {
+ return UNKNOWN;
+ }
+ }추가로, 파일 상단에 Locale 사용을 위해 다음 import가 필요합니다(선택 적용): // (이 변경은 선택 영역 밖에 적용이 필요합니다)
import java.util.Locale;또한 Admin SDK 예외를 바로 수용할 수 있는 오버로드를 추가하면 문자열 파싱을 피할 수 있습니다(선택): // (이 변경은 선택 영역 밖에 적용이 필요합니다)
import com.google.firebase.messaging.FirebaseMessagingException;
public static FcmErrorCode from(FirebaseMessagingException e) {
var mec = e.getMessagingErrorCode();
if (mec == null) return UNKNOWN;
try {
return FcmErrorCode.valueOf(mec.name());
} catch (IllegalArgumentException ex) {
return UNKNOWN;
}
}🤖 Prompt for AI Agents |
||
| } | ||
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.
🛠️ Refactor suggestion
검증 강화: cursor 형식(숫자) 사전 검증으로 400 처리 유도
빈 값은 허용하되(커서 미지정), 공백 포함을 허용하고 비숫자는 차단하는 패턴을 적용하면 컨트롤러 레벨에서 명확히 400을 반환할 수 있습니다.
상단 import 추가(파일 상단에 별도 반영 필요):
🤖 Prompt for AI Agents