Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dependencies {
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:6.9.2'
implementation 'org.redisson:redisson-spring-boot-starter:3.46.0'
implementation 'org.apache.commons:commons-text:1.14.0'
implementation 'com.google.firebase:firebase-admin:9.5.0'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
package project.flipnote.auth.listener;

import org.springframework.context.event.EventListener;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.auth.entity.AccountStatus;
import project.flipnote.auth.repository.UserAuthRepository;
import project.flipnote.common.event.UserWithdrawnEvent;
import project.flipnote.common.model.event.UserWithdrawnEvent;

@Slf4j
@RequiredArgsConstructor
Expand All @@ -25,7 +26,7 @@ public class UserWithdrawnEventListener {
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@EventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleUserWithdrawnEvent(UserWithdrawnEvent event) {
userAuthRepository.findByUserIdAndStatus(event.userId(), AccountStatus.ACTIVE)
.ifPresent(userAuth -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import project.flipnote.common.dto.UserCreateCommand;
import project.flipnote.common.model.request.UserCreateCommand;
import project.flipnote.common.util.PhoneUtil;
import project.flipnote.common.validation.annotation.ValidPassword;
import project.flipnote.common.validation.annotation.ValidPhone;
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/project/flipnote/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
import project.flipnote.auth.util.PasswordResetTokenGenerator;
import project.flipnote.auth.util.VerificationCodeGenerator;
import project.flipnote.common.config.ClientProperties;
import project.flipnote.common.dto.UserCreateCommand;
import project.flipnote.common.event.UserRegisteredEvent;
import project.flipnote.common.model.request.UserCreateCommand;
import project.flipnote.common.model.event.UserRegisteredEvent;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.common.security.jwt.JwtComponent;
Expand Down
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
Expand Up @@ -9,7 +9,7 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;

import lombok.extern.slf4j.Slf4j;
import project.flipnote.common.response.ApiResponse;
import project.flipnote.common.model.response.ApiResponse;

@Slf4j
@RestControllerAdvice
Expand Down
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
@@ -1,4 +1,4 @@
package project.flipnote.common.event;
package project.flipnote.common.model.event;

public record UserRegisteredEvent(
String email
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package project.flipnote.common.event;
package project.flipnote.common.model.event;

public record UserWithdrawnEvent(
Long userId
Expand Down
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)
Comment on lines +15 to +17
Copy link

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을 반환할 수 있습니다.

-	private String cursor;
+	@Pattern(regexp = "^\\s*\\d*\\s*$", message = "cursor는 숫자 문자열이어야 합니다.")
+	private String cursor;

상단 import 추가(파일 상단에 별도 반영 필요):

import jakarta.validation.constraints.Pattern;
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/common/model/request/CursorPageRequest.java
around lines 14 to 16, the cursor field needs pre-validation to force a 400 for
non-numeric values while still allowing empty/unspecified cursors; add the
jakarta.validation.constraints.Pattern import at the top and annotate the cursor
String with a Pattern that permits either empty string or only digits (e.g.
regexp "^$|^\\d+$"), ensuring controller-level validation returns 400 for
non-numeric inputs.

@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.dto;
package project.flipnote.common.model.request;

public record UserCreateCommand(
String email,
Expand Down
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;

import java.util.List;
import java.util.stream.Collectors;
Expand Down
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;
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

바이너리/스트리밍/다운로드 응답은 래핑에서 제외하세요.
현재 로직은 ApiResponse 인스턴스가 아닌 모든 바디를 래핑합니다. 파일 다운로드(byte[], Resource, StreamingResponseBody)나 SSE(text/event-stream) 같은 케이스까지 래핑되면 클라이언트가 바이너리로 기대하는 응답이 JSON으로 변형되어 깨질 수 있습니다.

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
In src/main/java/project/flipnote/common/model/response/ApiResponseAdvice.java
around lines 1 to 1, update beforeBodyWrite so it only wraps non-null,
non-ApiResponse bodies and explicitly skips binary/streaming/download responses:
return body unchanged when body is null or an instance of ApiResponse,
org.springframework.core.io.Resource, byte[], or
org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody, or
when selectedContentType indicates application/octet-stream or
text/event-stream; otherwise get the HTTP status from the ServerHttpResponse
(extracting it from ServletServerHttpResponse when available) and return
ApiResponse.success(status, body).


import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
Expand Down
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
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

널/불변성 안전성 강화 및 직렬화 최적화(@JsonInclude) 제안

  • content가 null일 때 NPE가 발생합니다. 방어적 복사와 null-세이프 처리를 권장합니다.
  • nextCursor가 null일 때 응답에서 키 자체를 숨기려면 @JsonInclude(Include.NON_NULL)를 적용하세요.

아래 변경을 제안합니다.

  1. 직렬화 최적화 import와 애노테이션 추가:
 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>(
  1. null-세이프 및 불변 List로 변환:
-	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());
+	}

참고: 필요하다면 빈 페이지를 쉽게 만들 수 있도록 public static <T> CursorPageResponse<T> empty() 팩토리도 추가 가능합니다.

Also applies to: 6-11, 13-20

🤖 Prompt for AI Agents
In src/main/java/project/flipnote/common/model/response/CursorPageResponse.java
around lines 3-5, add Jackson non-null include import and annotate the class
with @JsonInclude(Include.NON_NULL) to omit nextCursor when it is null;
additionally make the content field null-safe and immutable by converting any
incoming null to Collections.emptyList() and wrapping the list with
Collections.unmodifiableList (perform a defensive copy via new
ArrayList<>(content) before wrapping), and consider adding a public static <T>
CursorPageResponse<T> empty() factory returning an instance with an empty
content list and null nextCursor.

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,4 +1,4 @@
package project.flipnote.common.response;
package project.flipnote.common.model.response;

import java.util.List;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.response.ApiResponse;
import project.flipnote.common.model.response.ApiResponse;
import project.flipnote.common.security.exception.CustomAuthenticationEntryPoint;
import project.flipnote.common.security.exception.SecurityErrorCode;
import project.flipnote.common.security.filter.ExceptionHandlerFilter;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.response.ApiResponse;
import project.flipnote.common.model.response.ApiResponse;

@Component
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.common.response.ApiResponse;
import project.flipnote.common.model.response.ApiResponse;
import project.flipnote.common.security.exception.CustomSecurityException;

@Slf4j
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import project.flipnote.common.response.PageResponse;
import project.flipnote.common.model.response.PageResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.controller.docs.GroupInvitationQueryControllerDocs;
import project.flipnote.group.model.IncomingGroupInvitationResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import project.flipnote.common.response.PageResponse;
import project.flipnote.common.model.response.PageResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.model.IncomingGroupInvitationResponse;
import project.flipnote.group.model.OutgoingGroupInvitationResponse;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.common.event.UserRegisteredEvent;
import project.flipnote.common.model.event.UserRegisteredEvent;
import project.flipnote.group.service.GroupInvitationService;

@Slf4j
Expand All @@ -26,7 +26,7 @@ public class UserRegisteredEventListener {
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void HandleUserRegisteredEvent(UserRegisteredEvent event) {
public void handleUserRegisteredEvent(UserRegisteredEvent event) {
groupInvitationService.acceptPendingInvitationsOnRegister(event.email());
}

Expand Down
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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

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
In src/main/java/project/flipnote/group/model/GroupInvitationStatus.java around
lines 8 to 10, the method does not guard against a null invitation causing an
NPE; add a defensive null check using Objects.requireNonNull(invitation,
"invitation must not be null") at the start of the method and add the import
java.util.Objects so the method fails fast with a clear message before calling
invitation.getStatus().name().

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static IncomingGroupInvitationResponse from(GroupInvitation invitation) {
return new IncomingGroupInvitationResponse(
invitation.getId(),
invitation.getGroup().getId(),
GroupInvitationStatus.from(invitation.getStatus()),
GroupInvitationStatus.from(invitation),
invitation.getCreatedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public static OutgoingGroupInvitationResponse from(GroupInvitation invitation, S
invitation.getInviteeUserId(),
invitation.getInviteeEmail(),
inviteeNickname,
GroupInvitationStatus.from(invitation.getStatus()),
GroupInvitationStatus.from(invitation),
invitation.getCreatedAt()
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

소프트 삭제 필터 누락: 그룹명이 삭제된 그룹에서도 조회될 수 있음

다른 조회 메서드들이 deletedAt IS NULL 조건을 포함하는 것과 달리, 신규 메서드는 해당 조건이 없어 소프트 삭제된 그룹의 이름이 노출될 수 있습니다. 동일 기준으로 필터링하세요.

최소 수정안:

-	@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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Query("SELECT g.name FROM Group g WHERE g.id = :id")
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);
🤖 Prompt for AI Agents
In src/main/java/project/flipnote/group/repository/GroupRepository.java around
lines 23 to 24, the custom query for findGroupNameById lacks the soft-delete
filter so it may return names of soft-deleted groups; update the query to
include "AND g.deletedAt IS NULL" (or equivalent) to match other repository
methods, or alternatively replace the @Query with a derived query method like
Optional<String> findNameByIdAndDeletedAtIsNull(Long id) to avoid string-based
queries.

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
import java.util.Map;
import java.util.Objects;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.response.PageResponse;
import project.flipnote.common.model.event.GroupInvitationCreatedEvent;
import project.flipnote.common.model.response.PageResponse;
import project.flipnote.common.security.dto.AuthPrinciple;
import project.flipnote.group.entity.GroupInvitation;
import project.flipnote.group.entity.GroupInvitationStatus;
Expand Down Expand Up @@ -40,6 +42,7 @@ public class GroupInvitationService {
private final GroupRepository groupRepository;
private final GroupMemberRepository groupMemberRepository;
private final GroupMemberPolicyService groupMemberPolicyService;
private final ApplicationEventPublisher eventPublisher;

/**
* 그룹에 회원 혹은 비회원 초대
Expand Down Expand Up @@ -248,7 +251,7 @@ private Long createUserInvitation(Long inviterUserId, Long groupId, UserProfile
.build();
groupInvitationRepository.save(invitation);

// TODO: 초대받은 회원한테 알림 전송
eventPublisher.publishEvent(new GroupInvitationCreatedEvent(groupId, inviteeUser.getId()));

return invitation.getId();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

소프트 삭제 그룹까지 노출 가능성: 삭제되지 않은 그룹만 대상으로 제한 필요

findGroupNamegroupRepository.findGroupNameById(groupId)에 위임하는데, 현재 리포지토리 쿼리(아래 파일 참조)가 deletedAt IS NULL 조건을 포함하지 않아 소프트 삭제된 그룹의 이름도 반환될 수 있습니다. 다른 조회 메서드(validateGroup)는 findByIdAndDeletedAtIsNull로 일관되게 필터링하고 있으므로 기준을 맞추는 게 안전합니다.

두 가지 중 하나로 정리해 주세요.

  • 옵션 A(권장): 리포지토리 쿼리에 deletedAt 조건 추가
  • 옵션 B: 서비스에서 기존 validateGroup 재사용

옵션 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();
+	}

추가 제안:

  • 초대 알림 등 읽기 트래픽이 많다면 그룹명 조회에 캐싱 적용(예: Caffeine/Redis)도 고려할 수 있습니다.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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();
}

}
21 changes: 21 additions & 0 deletions src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java
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
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

FCM 에러 코드 커버리지 확인 및 보강 권고

현재 열거형이 주요 코드들을 포함하지만, 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' || true

Length 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 || true

Length of output: 7931


🌐 Web query:

com.google.firebase.messaging.MessagingErrorCode enum values

💡 Result:

The enum com.google.firebase.messaging.MessagingErrorCode defines error codes that can be raised by the Firebase Cloud Messaging APIs. Its documented values are as follows:

  • INTERNAL: Internal server error.
  • INVALID_ARGUMENT: One or more arguments specified in the request were invalid.
  • QUOTA_EXCEEDED: Sending limit exceeded for the message target.
  • SENDER_ID_MISMATCH: The authenticated sender ID is different from the sender ID for the registration token.
  • THIRD_PARTY_AUTH_ERROR: APNs certificate or web push auth key was invalid or missing.
  • UNAVAILABLE: Cloud Messaging service is temporarily unavailable.
  • UNREGISTERED: App instance was unregistered from FCM; the token is no longer valid and must be replaced[9].

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:

  • The list above comes from the official Firebase Admin SDK Java API reference[9], making it authoritative for Java backend development.
  • Some SDKs or Firebase libraries (e.g., in JS, PHP, or community repos) offer similar but sometimes differently named error codes, and the exact mapping can vary between platforms[6][7].
  • It's possible for new error values to be added in future releases, as the Firebase repo shows ongoing updates to error enums[3].

For the most accurate and current set of MessagingErrorCode enum values, refer to the [official documentation for the Java Admin SDK][9].

Citations:


FCM 에러 코드에 THIRD_PARTY_AUTH_ERROR 누락 — 상수 추가 필요

공식 Firebase Admin Java의 com.google.firebase.messaging.MessagingErrorCode에 THIRD_PARTY_AUTH_ERROR(예: APNs/서드파티 인증 관련)가 정의되어 있습니다. 현재 프로젝트의 FcmErrorCode 열거형에 해당 상수가 없어 해당 에러가 UNKNOWN으로 처리될 수 있으니 추가해주세요.

수정 대상

  • src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java — enum에 THIRD_PARTY_AUTH_ERROR 추가
  • src/main/java/project/flipnote/notification/service/NotificationService.java — MessagingErrorCode → FcmErrorCode 매핑 부분 확인 (라인 약 115–132)

권장 변경 예시:

public enum FcmErrorCode {
	INVALID_ARGUMENT,
	UNREGISTERED,
	SENDER_ID_MISMATCH,
	QUOTA_EXCEEDED,
	DEVICE_MESSAGE_RATE_EXCEEDED,
	TOPIC_MESSAGE_RATE_EXCEEDED,
	THIRD_PARTY_AUTH_ERROR, // 추가
	UNAVAILABLE,
	INTERNAL,
	UNKNOWN;
	...
}

대안으로는 com.google.firebase.messaging.MessagingErrorCode를 직접 사용하거나, SDK 업데이트 시 누락 항목을 주기적으로 동기화하는 방법도 고려하세요.

🤖 Prompt for AI Agents
In src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java around lines
3–12, add the missing THIRD_PARTY_AUTH_ERROR enum constant so FCM errors from
the Firebase SDK (e.g., APNs/third-party auth failures) are not mapped to
UNKNOWN; also review and update the mapping in
src/main/java/project/flipnote/notification/service/NotificationService.java
(approx. lines 115–132) to handle MessagingErrorCode.THIRD_PARTY_AUTH_ERROR →
FcmErrorCode.THIRD_PARTY_AUTH_ERROR (or explicitly fall back as needed),
ensuring the new constant is included wherever the enum-to-SDK mapping is
implemented.


public static FcmErrorCode from(String code) {
try {
return FcmErrorCode.valueOf(code);
} catch (Exception e) {
return UNKNOWN;
}
}
Comment on lines +14 to +20
Copy link

Choose a reason for hiding this comment

The 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
In src/main/java/project/flipnote/infra/firebase/FcmErrorCode.java around lines
14 to 20, normalize the incoming code string (e.g., convert to upper case with
Locale.ROOT and replace hyphens with underscores) before calling
FcmErrorCode.valueOf, and narrow the catch to expected exceptions
(IllegalArgumentException and NullPointerException) instead of catching
Exception; return UNKNOWN on those cases. Optionally add an overload that
accepts FirebaseMessagingException and maps e.getMessagingErrorCode().name()
with the same defensive handling, and add import java.util.Locale if you use
Locale.ROOT.

}
Loading
Loading