From f0ed32c1b3e1a5e0348a95a0feef4700ec2c3560 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 00:35:24 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=E2=9C=A8feat:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=83=89=EC=9E=A5=EA=B3=A0=20=EC=82=AC=EC=9A=A9=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=9B=B9=EC=86=8C=EC=BC=93=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 23e0955..718a66c 100644 --- a/build.gradle +++ b/build.gradle @@ -78,6 +78,9 @@ dependencies { implementation 'com.google.api-client:google-api-client' implementation 'com.google.oauth-client:google-oauth-client' implementation 'com.google.http-client:google-http-client-gson' + + // ============= WebSocket ============= + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { From ad5a6cb9c77ae03cf00ee6176791734ce1a4c35c Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 00:35:37 +0900 Subject: [PATCH 02/12] =?UTF-8?q?=E2=9C=A8feat:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=83=89=EC=9E=A5=EA=B3=A0=20=EC=82=AC=EC=9A=A9=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=83=89=EC=9E=A5=EA=B3=A0=EC=99=80=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=EC=9D=98=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradproj/domain/member/entity/Member.java | 17 +++++++++-------- .../service/MemberOnboardingService.java | 4 +--- .../refrigerator/entity/Refrigerator.java | 19 ++++++++++++------- .../repository/RefrigeratorRepository.java | 2 -- .../command/RefrigeratorCommandService.java | 1 - 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/main/java/novaminds/gradproj/domain/member/entity/Member.java b/src/main/java/novaminds/gradproj/domain/member/entity/Member.java index 1b6d50e..80bb22c 100644 --- a/src/main/java/novaminds/gradproj/domain/member/entity/Member.java +++ b/src/main/java/novaminds/gradproj/domain/member/entity/Member.java @@ -66,8 +66,9 @@ public class Member extends BaseEntity { @Builder.Default private Integer point = 0; - @OneToOne(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) - private Refrigerator refrigerator; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "refrigerator_id", nullable = false) + private Refrigerator refrigerator; @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default @@ -155,17 +156,17 @@ public String getRoleKey() { } public void setRefrigerator(Refrigerator refrigerator) { - // 기존 냉장고가 있고 새 냉장고와 다르면 기존 냉장고의 member 참조 해제 + // 기존 냉장고가 있고 새 냉장고와 다르면 기존 냉장고의 memberList에서 제거 if (this.refrigerator != null && this.refrigerator != refrigerator) { - this.refrigerator.setMember(null); + this.refrigerator.removeMember(this); } - + // 새 냉장고 할당 this.refrigerator = refrigerator; - - // 새 냉장고가 null이 아니면 양방향 연관관계 설정 + + // 새 냉장고가 null이 아니면 양방향 연관관계 설정 (memberList에 추가) if (refrigerator != null) { - refrigerator.setMember(this); + refrigerator.addMember(this); } } } \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/member/service/MemberOnboardingService.java b/src/main/java/novaminds/gradproj/domain/member/service/MemberOnboardingService.java index 40fab85..8391a7e 100644 --- a/src/main/java/novaminds/gradproj/domain/member/service/MemberOnboardingService.java +++ b/src/main/java/novaminds/gradproj/domain/member/service/MemberOnboardingService.java @@ -7,7 +7,6 @@ import novaminds.gradproj.domain.refrigerator.entity.MemberRefrigeratorSkin; import novaminds.gradproj.domain.refrigerator.repository.MemberRefrigeratorSkinRepository; import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorSkin; -import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorRepository; import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorSkinRepository; import novaminds.gradproj.domain.refrigerator.service.command.RefrigeratorCommandService; import org.springframework.stereotype.Service; @@ -18,7 +17,6 @@ @Transactional(readOnly = true) public class MemberOnboardingService { - private final RefrigeratorRepository refrigeratorRepository; private final RefrigeratorSkinRepository refrigeratorSkinRepository; private final MemberRefrigeratorSkinRepository memberRefrigeratorSkinRepository; private final RefrigeratorCommandService refrigeratorCommandService; @@ -30,7 +28,7 @@ public class MemberOnboardingService { */ @Transactional public void setupDefaultResources(Member member) { - if (refrigeratorRepository.existsByMember(member)) { + if (member.getRefrigerator() != null) { return; } diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java index 91ffd50..9b271c7 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java @@ -16,24 +16,29 @@ @NoArgsConstructor @Builder @Entity -@Table(name = "refrigerators", - uniqueConstraints = @UniqueConstraint(columnNames = "member_id")) +@Table(name = "refrigerators") public class Refrigerator extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id") - private Member member; + @OneToMany(mappedBy = "refrigerator", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List memberList = new ArrayList<>(); @OneToMany(mappedBy = "refrigerator", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List storedItems = new ArrayList<>(); - public void setMember(Member member) { - this.member = member; + public void addMember(Member member) { + if (!memberList.contains(member)) { + memberList.add(member); + } + } + + public void removeMember(Member member) { + memberList.remove(member); } public void addStoredItem(StoredItem storedItem) { diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorRepository.java b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorRepository.java index abcf789..f4122ff 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorRepository.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorRepository.java @@ -1,10 +1,8 @@ package novaminds.gradproj.domain.refrigerator.repository; -import novaminds.gradproj.domain.member.entity.Member; import novaminds.gradproj.domain.refrigerator.entity.Refrigerator; import org.springframework.data.jpa.repository.JpaRepository; public interface RefrigeratorRepository extends JpaRepository { - boolean existsByMember(Member member); } diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java index 369590d..a180552 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java @@ -39,7 +39,6 @@ public void createRefrigerator(Member member) { // 냉장고 생성 Refrigerator refrigerator = Refrigerator.builder() - .member(member) .build(); // 냉장고 저장 From 71f9ab0bd999a568a8bfed85658d0a8105c0214c Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 10:45:11 +0900 Subject: [PATCH 03/12] =?UTF-8?q?=E2=9C=A8feat:=20=EB=83=89=EC=9E=A5?= =?UTF-8?q?=EA=B3=A0=20=EC=B4=88=EB=8C=80=20=EC=97=90=EB=9F=AC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradproj/apiPayload/code/status/ErrorStatus.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/novaminds/gradproj/apiPayload/code/status/ErrorStatus.java b/src/main/java/novaminds/gradproj/apiPayload/code/status/ErrorStatus.java index a00c6a7..1e3897d 100644 --- a/src/main/java/novaminds/gradproj/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/novaminds/gradproj/apiPayload/code/status/ErrorStatus.java @@ -81,6 +81,14 @@ public enum ErrorStatus implements BaseErrorCode { STORED_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "REFRIGERATOR403", "냉장고 재료를 찾을 수 없습니다."), STORED_ITEM_ACCESS_DENIED(HttpStatus.FORBIDDEN, "REFRIGERATOR404", "해당 냉장고 재료에 대한 접근 권한이 없습니다."), + // 냉장고 초대 관련 에러 + INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "INVITATION401", "초대를 찾을 수 없습니다."), + INVITATION_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "INVITATION402", "이미 초대를 보낸 사용자입니다."), + INVITATION_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "INVITATION403", "이미 처리된 초대입니다."), + INVITATION_NOT_AUTHORIZED(HttpStatus.FORBIDDEN, "INVITATION404", "초대에 대한 권한이 없습니다."), + CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "INVITATION405", "자기 자신을 초대할 수 없습니다."), + ALREADY_IN_SAME_REFRIGERATOR(HttpStatus.BAD_REQUEST, "INVITATION406", "이미 같은 냉장고를 사용 중입니다."), + //레시피 관련 에러 RECIPE_NOT_FOUND(HttpStatus.NOT_FOUND, "RECIPE401", "해당 레시피를 찾을 수 없습니다."), RECIPE_NOT_AUTHORIZED(HttpStatus.FORBIDDEN, "RECIPE_402", "레시피 수정/삭제 권한이 없습니다."), From 5ae8701bf06e276014beed1169278f46efeba12b Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 11:30:31 +0900 Subject: [PATCH 04/12] =?UTF-8?q?=E2=9C=A8feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradproj/config/WebSocketConfig.java | 41 ++++++++++++++++++ .../global/websocket/StompHandler.java | 42 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/main/java/novaminds/gradproj/config/WebSocketConfig.java create mode 100644 src/main/java/novaminds/gradproj/global/websocket/StompHandler.java diff --git a/src/main/java/novaminds/gradproj/config/WebSocketConfig.java b/src/main/java/novaminds/gradproj/config/WebSocketConfig.java new file mode 100644 index 0000000..74b9872 --- /dev/null +++ b/src/main/java/novaminds/gradproj/config/WebSocketConfig.java @@ -0,0 +1,41 @@ +package novaminds.gradproj.config; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.global.websocket.StompHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 구독(sub) 경로 설정: 클라이언트가 메시지를 받을 경로의 prefix + config.enableSimpleBroker("/sub"); + + // 발행(pub) 경로 설정: 클라이언트가 메시지를 보낼 때 사용할 prefix + config.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 웹소켓 연결 주소: ws://localhost:8080/ws-stomp + registry.addEndpoint("/ws-stomp") + .setAllowedOriginPatterns("*") // CORS 허용 + .withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // JWT 인증을 위한 인터셉터 추가 + registration.interceptors(stompHandler); + } +} diff --git a/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java new file mode 100644 index 0000000..4badf84 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java @@ -0,0 +1,42 @@ +package novaminds.gradproj.global.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import novaminds.gradproj.domain.member.service.security.jwt.JwtTokenProvider; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompHandler implements ChannelInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + // websocket 연결 시 (CONNECT) 헤더의 jwt token 검증 + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String jwtToken = accessor.getFirstNativeHeader("Authorization"); + + if (jwtToken != null && jwtToken.startsWith("Bearer ")) { + jwtToken = jwtToken.substring(7); + } + + log.info("WebSocket 연결 요청: token 존재 여부 = {}", jwtToken != null); + + // 유효성 검증 (예외 발생 시 연결 거부됨) + // 실제 구현 시에는 예외 처리를 꼼꼼히 하거나, 유효하지 않으면 메시지를 null로 리턴하여 차단 + if (jwtToken == null || !jwtTokenProvider.validateToken(jwtToken)) { + throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다."); + } + } + return message; + } +} From 5bf20221cae01c5c37a477f50be8adff3ae4fda4 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 11:30:59 +0900 Subject: [PATCH 05/12] =?UTF-8?q?=E2=9C=A8feat:=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EB=83=89=EC=9E=A5=EA=B3=A0=20=EC=B4=88=EB=8C=80=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/RefrigeratorConverter.java | 36 +++++ .../entity/RefrigeratorInvitation.java | 56 +++++++ .../RefrigeratorInvitationRepository.java | 28 ++++ .../RefrigeratorInvitationCommandService.java | 151 ++++++++++++++++++ .../RefrigeratorInvitationQueryService.java | 47 ++++++ .../RefrigeratorInvitationController.java | 113 +++++++++++++ .../web/dto/RefrigeratorRequestDTO.java | 10 ++ .../web/dto/RefrigeratorResponseDTO.java | 37 +++++ 8 files changed, 478 insertions(+) create mode 100644 src/main/java/novaminds/gradproj/domain/refrigerator/entity/RefrigeratorInvitation.java create mode 100644 src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java create mode 100644 src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java create mode 100644 src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorInvitationQueryService.java create mode 100644 src/main/java/novaminds/gradproj/domain/refrigerator/web/controller/RefrigeratorInvitationController.java diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java b/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java index d43e8ac..f47442f 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java @@ -12,6 +12,7 @@ import novaminds.gradproj.domain.refrigerator.repository.projection.StorageTypeCount; import novaminds.gradproj.domain.refrigerator.web.dto.RefrigeratorResponseDTO; import novaminds.gradproj.domain.ingredient.entity.Ingredient; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation; import java.time.LocalDate; import java.time.temporal.ChronoUnit; @@ -199,4 +200,39 @@ private static String calculateDDay(LocalDate expirationDate) { } } + public static RefrigeratorInvitation toRefrigeratorInvitation( + Refrigerator refrigerator, + Member inviter, + Member invitee + ) { + return RefrigeratorInvitation.builder() + .refrigerator(refrigerator) + .inviter(inviter) + .invitee(invitee) + .status(RefrigeratorInvitation.InvitationStatus.PENDING) + .build(); + } + + public static RefrigeratorResponseDTO.InvitationListResponse toInvitationListResponse( + List invitations + ) { + List invitationResponses = invitations.stream() + .map(RefrigeratorConverter::toInvitationResponse) + .toList(); + + return RefrigeratorResponseDTO.InvitationListResponse.builder() + .invitations(invitationResponses) + .build(); + } + + private static RefrigeratorResponseDTO.InvitationResponse toInvitationResponse(RefrigeratorInvitation invitation) { + return RefrigeratorResponseDTO.InvitationResponse.builder() + .id(invitation.getId()) + .inviterNickname(invitation.getInviter().getNickname()) + .inviterProfileImage(invitation.getInviter().getProfileImage()) + .inviteeNickname(invitation.getInvitee().getNickname()) + .inviteeProfileImage(invitation.getInvitee().getProfileImage()) + .status(invitation.getStatus().name()) + .build(); + } } diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/RefrigeratorInvitation.java b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/RefrigeratorInvitation.java new file mode 100644 index 0000000..6e78a15 --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/RefrigeratorInvitation.java @@ -0,0 +1,56 @@ +package novaminds.gradproj.domain.refrigerator.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.global.BaseEntity; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "refrigerator_invitations") +public class RefrigeratorInvitation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "refrigerator_id", nullable = false) + private Refrigerator refrigerator; + + // 초대한 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "inviter_id", nullable = false) + private Member inviter; + + // 초대받은 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invitee_id", nullable = false) + private Member invitee; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private InvitationStatus status; + + public enum InvitationStatus { + PENDING, ACCEPTED, REJECTED, CANCELED + } + + public void accept() { + this.status = InvitationStatus.ACCEPTED; + } + + public void reject() { + this.status = InvitationStatus.REJECTED; + } + + public void cancel() { + this.status = InvitationStatus.CANCELED; + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java new file mode 100644 index 0000000..44d100a --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java @@ -0,0 +1,28 @@ +package novaminds.gradproj.domain.refrigerator.repository; + +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation.InvitationStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RefrigeratorInvitationRepository extends JpaRepository { + + Optional findByInviterAndInviteeAndStatus( + Member inviter, + Member invitee, + InvitationStatus status + ); + + List findByInviteeAndStatusOrderByCreatedAtDesc( + Member invitee, + InvitationStatus status + ); + + List findByInviterAndStatusOrderByCreatedAtDesc( + Member inviter, + InvitationStatus status + ); +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java new file mode 100644 index 0000000..f883dee --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java @@ -0,0 +1,151 @@ +package novaminds.gradproj.domain.refrigerator.service.command; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.apiPayload.code.status.ErrorStatus; +import novaminds.gradproj.apiPayload.exception.GeneralException; +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.domain.member.repository.MemberRepository; +import novaminds.gradproj.domain.refrigerator.converter.RefrigeratorConverter; +import novaminds.gradproj.domain.refrigerator.entity.Refrigerator; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation.InvitationStatus; +import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorInvitationRepository; +import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class RefrigeratorInvitationCommandService { + + private final RefrigeratorInvitationRepository invitationRepository; + private final RefrigeratorRepository refrigeratorRepository; + private final MemberRepository memberRepository; + + /** + * 냉장고 초대 보내기 + * + * @param inviter 초대하는 사람 + * @param inviteeNickname 초대받을 사람의 닉네임 + */ + public void sendInvitation(Member inviter, String inviteeNickname) { + // 초대받을 사람 조회 + Member invitee = memberRepository.findByNickname(inviteeNickname) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + // 자기 자신을 초대할 수 없음 + if (inviter.getLoginId().equals(invitee.getLoginId())) { + throw new GeneralException(ErrorStatus.CANNOT_INVITE_SELF); + } + + // 이미 같은 냉장고를 사용 중인지 확인 + Refrigerator inviterRefrigerator = inviter.getRefrigerator(); + Refrigerator inviteeRefrigerator = invitee.getRefrigerator(); + + if (inviterRefrigerator == null) { + throw new GeneralException(ErrorStatus.REFRIGERATOR_NOT_FOUND); + } + + if (inviterRefrigerator.getId().equals(inviteeRefrigerator.getId())) { + throw new GeneralException(ErrorStatus.ALREADY_IN_SAME_REFRIGERATOR); + } + + // 이미 대기 중인 초대가 있는지 확인 + invitationRepository.findByInviterAndInviteeAndStatus(inviter, invitee, InvitationStatus.PENDING) + .ifPresent(invitation -> { + throw new GeneralException(ErrorStatus.INVITATION_ALREADY_EXISTS); + }); + + // 초대 생성 + RefrigeratorInvitation invitation = RefrigeratorConverter.toRefrigeratorInvitation( + inviterRefrigerator, inviter, invitee + ); + invitationRepository.save(invitation); + } + + /** + * 냉장고 초대 수락 + * + * @param invitee 초대받은 사람 + * @param invitationId 초대 ID + */ + public void acceptInvitation(Member invitee, Long invitationId) { + // 초대 조회 + RefrigeratorInvitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVITATION_NOT_FOUND)); + + // 초대받은 사람이 맞는지 확인 + if (!invitation.getInvitee().getLoginId().equals(invitee.getLoginId())) { + throw new GeneralException(ErrorStatus.INVITATION_NOT_AUTHORIZED); + } + + // 이미 처리된 초대인지 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GeneralException(ErrorStatus.INVITATION_ALREADY_PROCESSED); + } + + // 초대 수락 + invitation.accept(); + + // 냉장고 변경 + Refrigerator inviterRefrigerator = invitation.getRefrigerator(); + Refrigerator oldRefrigerator = invitee.getRefrigerator(); + invitee.setRefrigerator(inviterRefrigerator); + + // 기존 냉장고에 아무도 남지 않았으면 삭제 + if (oldRefrigerator != null && oldRefrigerator.getMemberList().isEmpty()) { + refrigeratorRepository.delete(oldRefrigerator); + } + } + + /** + * 냉장고 초대 거절 + * + * @param invitee 초대받은 사람 + * @param invitationId 초대 ID + */ + public void rejectInvitation(Member invitee, Long invitationId) { + // 초대 조회 + RefrigeratorInvitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVITATION_NOT_FOUND)); + + // 초대받은 사람이 맞는지 확인 + if (!invitation.getInvitee().getLoginId().equals(invitee.getLoginId())) { + throw new GeneralException(ErrorStatus.INVITATION_NOT_AUTHORIZED); + } + + // 이미 처리된 초대인지 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GeneralException(ErrorStatus.INVITATION_ALREADY_PROCESSED); + } + + // 초대 거절 + invitation.reject(); + } + + /** + * 냉장고 초대 취소 + * + * @param inviter 초대한 사람 + * @param invitationId 초대 ID + */ + public void cancelInvitation(Member inviter, Long invitationId) { + // 초대 조회 + RefrigeratorInvitation invitation = invitationRepository.findById(invitationId) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVITATION_NOT_FOUND)); + + // 초대한 사람이 맞는지 확인 + if (!invitation.getInviter().getLoginId().equals(inviter.getLoginId())) { + throw new GeneralException(ErrorStatus.INVITATION_NOT_AUTHORIZED); + } + + // 이미 처리된 초대인지 확인 + if (invitation.getStatus() != InvitationStatus.PENDING) { + throw new GeneralException(ErrorStatus.INVITATION_ALREADY_PROCESSED); + } + + // 초대 취소 + invitation.cancel(); + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorInvitationQueryService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorInvitationQueryService.java new file mode 100644 index 0000000..1310f16 --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorInvitationQueryService.java @@ -0,0 +1,47 @@ +package novaminds.gradproj.domain.refrigerator.service.query; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.domain.refrigerator.converter.RefrigeratorConverter; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation.InvitationStatus; +import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorInvitationRepository; +import novaminds.gradproj.domain.refrigerator.web.dto.RefrigeratorResponseDTO; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RefrigeratorInvitationQueryService { + + private final RefrigeratorInvitationRepository invitationRepository; + + /** + * 받은 초대 목록 조회 (대기 중인 초대만) + * + * @param member 현재 로그인한 사용자 + * @return 받은 초대 목록 + */ + public RefrigeratorResponseDTO.InvitationListResponse getReceivedInvitations(Member member) { + List invitations = invitationRepository + .findByInviteeAndStatusOrderByCreatedAtDesc(member, InvitationStatus.PENDING); + + return RefrigeratorConverter.toInvitationListResponse(invitations); + } + + /** + * 보낸 초대 목록 조회 (대기 중인 초대만) + * + * @param member 현재 로그인한 사용자 + * @return 보낸 초대 목록 + */ + public RefrigeratorResponseDTO.InvitationListResponse getSentInvitations(Member member) { + List invitations = invitationRepository + .findByInviterAndStatusOrderByCreatedAtDesc(member, InvitationStatus.PENDING); + + return RefrigeratorConverter.toInvitationListResponse(invitations); + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/web/controller/RefrigeratorInvitationController.java b/src/main/java/novaminds/gradproj/domain/refrigerator/web/controller/RefrigeratorInvitationController.java new file mode 100644 index 0000000..63ab2f1 --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/web/controller/RefrigeratorInvitationController.java @@ -0,0 +1,113 @@ +package novaminds.gradproj.domain.refrigerator.web.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.apiPayload.ApiResponse; +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.domain.member.service.security.auth.CurrentUser; +import novaminds.gradproj.domain.refrigerator.service.command.RefrigeratorInvitationCommandService; +import novaminds.gradproj.domain.refrigerator.service.query.RefrigeratorInvitationQueryService; +import novaminds.gradproj.domain.refrigerator.web.dto.RefrigeratorResponseDTO; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/refrigerators/invitations") +@RequiredArgsConstructor +@Tag(name = "냉장고 초대 관련 API", description = "냉장고 공유를 위한 초대 API입니다.") +public class RefrigeratorInvitationController { + + private final RefrigeratorInvitationCommandService invitationCommandService; + private final RefrigeratorInvitationQueryService invitationQueryService; + + @Operation(summary = "냉장고 초대 보내기", description = "팔로잉 중인 사용자에게 냉장고 공유 초대를 보냅니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER402", description = "사용자를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "REFRIGERATOR401", description = "냉장고를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION405", description = "자기 자신을 초대할 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION406", description = "이미 같은 냉장고를 사용 중입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION402", description = "이미 초대를 보낸 사용자입니다.") + }) + @PostMapping("/{nickname}/send") + public ApiResponse sendInvitation( + @CurrentUser Member member, + @PathVariable String nickname + ) { + invitationCommandService.sendInvitation(member, nickname); + return ApiResponse.onSuccess("초대를 보냈습니다."); + } + + @Operation(summary = "받은 초대 목록 조회", description = "대기 중인 받은 초대 목록을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") + }) + @GetMapping("/received") + public ApiResponse getReceivedInvitations( + @CurrentUser Member member + ) { + RefrigeratorResponseDTO.InvitationListResponse response = invitationQueryService.getReceivedInvitations(member); + return ApiResponse.onSuccess(response); + } + + @Operation(summary = "보낸 초대 목록 조회", description = "대기 중인 보낸 초대 목록을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") + }) + @GetMapping("/sent") + public ApiResponse getSentInvitations( + @CurrentUser Member member + ) { + RefrigeratorResponseDTO.InvitationListResponse response = invitationQueryService.getSentInvitations(member); + return ApiResponse.onSuccess(response); + } + + @Operation(summary = "냉장고 초대 수락", description = "받은 냉장고 공유 초대를 수락합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION401", description = "초대를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION404", description = "초대에 대한 권한이 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION403", description = "이미 처리된 초대입니다.") + }) + @PostMapping("/{invitationId}/accept") + public ApiResponse acceptInvitation( + @CurrentUser Member member, + @PathVariable Long invitationId + ) { + invitationCommandService.acceptInvitation(member, invitationId); + return ApiResponse.onSuccess("초대를 수락했습니다."); + } + + @Operation(summary = "냉장고 초대 거절", description = "받은 냉장고 공유 초대를 거절합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION401", description = "초대를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION404", description = "초대에 대한 권한이 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION403", description = "이미 처리된 초대입니다.") + }) + @PostMapping("/{invitationId}/reject") + public ApiResponse rejectInvitation( + @CurrentUser Member member, + @PathVariable Long invitationId + ) { + invitationCommandService.rejectInvitation(member, invitationId); + return ApiResponse.onSuccess("초대를 거절했습니다."); + } + + @Operation(summary = "냉장고 초대 취소", description = "보낸 냉장고 공유 초대를 취소합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION401", description = "초대를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION404", description = "초대에 대한 권한이 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "INVITATION403", description = "이미 처리된 초대입니다.") + }) + @DeleteMapping("/{invitationId}/cancel") + public ApiResponse cancelInvitation( + @CurrentUser Member member, + @PathVariable Long invitationId + ) { + invitationCommandService.cancelInvitation(member, invitationId); + return ApiResponse.onSuccess("초대를 취소했습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorRequestDTO.java b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorRequestDTO.java index b590cc0..401f9b8 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorRequestDTO.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorRequestDTO.java @@ -87,4 +87,14 @@ public static class ModifyStoredItemRequest { @FutureOrPresent(message = "유통기한은 현재 날짜 이후여야 합니다.") private LocalDate expirationDate; } + + @Getter + @NoArgsConstructor + @Schema(description = "냉장고 초대 요청") + public static class InvitationRequest { + + @Schema(description = "초대할 사용자 닉네임") + @NotBlank(message = "초대할 사용자 닉네임은 필수입니다.") + private String inviteeNickname; + } } diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java index bc659f8..a0d9cc9 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java @@ -177,4 +177,41 @@ public static class MemberRefrigeratorSummary { @Schema(description = "포인트 등수") private long pointRank; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "냉장고 초대 응답") + public static class InvitationResponse { + + @Schema(description = "초대 ID") + private Long id; + + @Schema(description = "초대한 사람 닉네임") + private String inviterNickname; + + @Schema(description = "초대한 사람 프로필 이미지") + private String inviterProfileImage; + + @Schema(description = "초대받은 사람 닉네임") + private String inviteeNickname; + + @Schema(description = "초대받은 사람 프로필 이미지") + private String inviteeProfileImage; + + @Schema(description = "초대 상태", allowableValues = {"PENDING", "ACCEPTED", "REJECTED", "CANCELED"}) + private String status; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "냉장고 초대 목록 응답") + public static class InvitationListResponse { + + @Schema(description = "초대 목록") + private List invitations; + } } From b9d686fd46feaaf457948d886e6298c6f6b2be61 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 11:31:14 +0900 Subject: [PATCH 06/12] =?UTF-8?q?=E2=9C=A8feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=EC=9D=84=20=ED=86=B5=ED=95=9C=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/RefrigeratorCommandService.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java index a180552..9e78a70 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorCommandService.java @@ -12,6 +12,7 @@ import novaminds.gradproj.domain.refrigerator.repository.StoredItemRepository; import novaminds.gradproj.domain.refrigerator.web.dto.RefrigeratorRequestDTO; import novaminds.gradproj.domain.refrigerator.converter.RefrigeratorConverter; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,8 @@ @Transactional public class RefrigeratorCommandService { + private final SimpMessagingTemplate messagingTemplate; // 웹소켓 메시지 전송 도구 + private final RefrigeratorRepository refrigeratorRepository; private final StoredItemRepository storedItemRepository; private final IngredientRepository ingredientRepository; @@ -68,6 +71,8 @@ public void addIngredientsToRefrigerator( for (RefrigeratorRequestDTO.IngredientItem request : requests) { addSingleIngredientToRefrigerator(refrigerator, request); } + + sendRefreshSignal(refrigerator.getId()); } /** @@ -136,6 +141,7 @@ public Long modifyMyStoredItem(Member member, Long storedItemId, RefrigeratorReq // 변경된 필드만 업데이트 storedItem.updateFieldIfChanged(request); + sendRefreshSignal(refrigerator.getId()); return storedItem.getId(); } @@ -177,6 +183,8 @@ public void removeMyIngredients( // Refrigerator 엔티티의 storedItems 리스트에서 제거 storedItemsToDelete.forEach(refrigerator::removeStoredItem); + + sendRefreshSignal(refrigerator.getId()); } @@ -198,4 +206,18 @@ public LocalDate calculateExpirationDate(Ingredient ingredient, StorageType stor return today.plusDays(shelfLifeDays); } + + /** + * 해당 냉장고를 구독 중인 모든 사용자에게 새로고침을 위한 메시지 전송 + */ + private void sendRefreshSignal(Long refrigeratorId) { + + String destination = "/sub/refrigerator/" + refrigeratorId; + SocketMessage message = new SocketMessage("INGREDIENT_UPDATE", "재료가 변경되었습니다."); + + messagingTemplate.convertAndSend(destination, message); + } + + // 웹소켓 메시지용 내부 클래스 + public record SocketMessage(String type, String message) {} } From 3b63c68f22f88ed7f50b1c15b8f42aec88a1f795 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 11:31:39 +0900 Subject: [PATCH 07/12] =?UTF-8?q?=E2=9C=A8feat:=20Member=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A0=EC=95=84=EA=B0=9D=EC=B2=B4=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gradproj/domain/refrigerator/entity/Refrigerator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java index 9b271c7..48ed2d5 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java @@ -23,7 +23,7 @@ public class Refrigerator extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToMany(mappedBy = "refrigerator", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "refrigerator", cascade = CascadeType.ALL) @Builder.Default private List memberList = new ArrayList<>(); From 262caadf6fab9f8dfad772ebf7a9321474de7079 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 15:43:39 +0900 Subject: [PATCH 08/12] =?UTF-8?q?=E2=9C=A8feat:=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?id=20=EA=B0=92=20=EC=A0=84=EB=8B=AC=20=EB=B0=8F=20SecurityConfi?= =?UTF-8?q?g=EC=97=90=EC=84=9C=20=EA=B2=BD=EB=A1=9C=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../novaminds/gradproj/config/SecurityConfig.java | 1 + .../security/auth/ProfileCompletionFilter.java | 1 + .../converter/RefrigeratorConverter.java | 6 ++++-- .../service/query/RefrigeratorQueryService.java | 4 ++-- .../web/dto/RefrigeratorResponseDTO.java | 5 +++++ .../gradproj/global/websocket/StompHandler.java | 14 ++++++++++---- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/main/java/novaminds/gradproj/config/SecurityConfig.java b/src/main/java/novaminds/gradproj/config/SecurityConfig.java index 7bf3093..4646f86 100644 --- a/src/main/java/novaminds/gradproj/config/SecurityConfig.java +++ b/src/main/java/novaminds/gradproj/config/SecurityConfig.java @@ -62,6 +62,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/auth/login/naver", //TODO : 이건 없애고 프론트에서 바로 직접 접근하는게 나은 구조 "/api/auth/reset-password/**", "/login/oauth2/**", + "/ws-stomp/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" diff --git a/src/main/java/novaminds/gradproj/domain/member/service/security/auth/ProfileCompletionFilter.java b/src/main/java/novaminds/gradproj/domain/member/service/security/auth/ProfileCompletionFilter.java index 8974e4c..0a05819 100644 --- a/src/main/java/novaminds/gradproj/domain/member/service/security/auth/ProfileCompletionFilter.java +++ b/src/main/java/novaminds/gradproj/domain/member/service/security/auth/ProfileCompletionFilter.java @@ -32,6 +32,7 @@ public class ProfileCompletionFilter extends OncePerRequestFilter { "/api/auth/additional-info-part2", "/api/auth/check-email", "/api/s3/image/upload-url", + "/ws-stomp/**", "/swagger-ui/**", "/v3/api-docs/**" ); diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java b/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java index f47442f..7bd63bb 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java @@ -77,12 +77,13 @@ public static MemberRefrigeratorSkin toMemberRefrigeratorSkin(Member member, Ref .build(); } - public static RefrigeratorResponseDTO.IngredientResponse toIngredientResponse(List storedItems) { + public static RefrigeratorResponseDTO.IngredientResponse toIngredientResponse(Long refrigeratorId, List storedItems) { var storedIngredientResponses = storedItems.stream() .map(RefrigeratorConverter::toStoredIngredientResponse) .toList(); return RefrigeratorResponseDTO.IngredientResponse.builder() + .refrigeratorId(refrigeratorId) .addedCount(storedItems.size()) .storedIngredients(storedIngredientResponses) .build(); @@ -143,8 +144,9 @@ public static StoredItem toStoredItem( .build(); } - public static RefrigeratorResponseDTO.StoredIngredientCount toStoredIngredientCount(StorageTypeCount storageTypeCount) { + public static RefrigeratorResponseDTO.StoredIngredientCount toStoredIngredientCount(Long refrigeratorId, StorageTypeCount storageTypeCount) { return RefrigeratorResponseDTO.StoredIngredientCount.builder() + .refrigeratorId(refrigeratorId) .refrigeratorCount(storageTypeCount.getRefrigeratorCount().intValue()) .freezerCount(storageTypeCount.getFreezerCount().intValue()) .roomTempCount(storageTypeCount.getRoomTempCount().intValue()) diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorQueryService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorQueryService.java index 6c44068..f3deeb4 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorQueryService.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/query/RefrigeratorQueryService.java @@ -60,7 +60,7 @@ public RefrigeratorResponseDTO.IngredientResponse getMyStoredItems( ); // DTO 변환 - return RefrigeratorConverter.toIngredientResponse(storedItems); + return RefrigeratorConverter.toIngredientResponse(refrigerator.getId(), storedItems); } /** @@ -79,7 +79,7 @@ public RefrigeratorResponseDTO.StoredIngredientCount getMyStoredItemsCount(Membe StorageTypeCount storageTypeCount = storedItemRepository.countByStorageTypes(refrigerator.getId()); - return RefrigeratorConverter.toStoredIngredientCount(storageTypeCount); + return RefrigeratorConverter.toStoredIngredientCount(refrigerator.getId(), storageTypeCount); } /** diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java index a0d9cc9..a125367 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/web/dto/RefrigeratorResponseDTO.java @@ -63,6 +63,9 @@ public static class RefrigeratorSkinListResponse { @AllArgsConstructor @Builder public static class IngredientResponse { + + private Long refrigeratorId; + @Schema(description = "보관 중인 재료 개수") private int addedCount; @@ -110,6 +113,8 @@ public static class StoredIngredientResponse { @Builder public static class StoredIngredientCount { + private Long refrigeratorId; + @Schema(description = "냉장 보관 개수") private int refrigeratorCount; diff --git a/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java index 4badf84..2eacc5d 100644 --- a/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java +++ b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java @@ -31,10 +31,16 @@ public Message preSend(Message message, MessageChannel channel) { log.info("WebSocket 연결 요청: token 존재 여부 = {}", jwtToken != null); - // 유효성 검증 (예외 발생 시 연결 거부됨) - // 실제 구현 시에는 예외 처리를 꼼꼼히 하거나, 유효하지 않으면 메시지를 null로 리턴하여 차단 - if (jwtToken == null || !jwtTokenProvider.validateToken(jwtToken)) { - throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다."); + // 토큰 검증 with 예외 처리 + try { + if (jwtToken == null || !jwtTokenProvider.validateToken(jwtToken)) { + log.warn("❌ WebSocket 연결 실패: 유효하지 않은 토큰"); + throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다."); + } + log.info("✅ WebSocket 연결 성공: 토큰 검증 완료"); + } catch (Exception e) { + log.error("❌ WebSocket 토큰 검증 실패: {}", e.getMessage()); + throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다.", e); } } return message; From 7af4e89ac6571e7446d6aa685c2d118185124a73 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 15:44:18 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/novaminds/gradproj/global/websocket/StompHandler.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java index 2eacc5d..7b466f1 100644 --- a/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java +++ b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java @@ -34,12 +34,9 @@ public Message preSend(Message message, MessageChannel channel) { // 토큰 검증 with 예외 처리 try { if (jwtToken == null || !jwtTokenProvider.validateToken(jwtToken)) { - log.warn("❌ WebSocket 연결 실패: 유효하지 않은 토큰"); throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다."); } - log.info("✅ WebSocket 연결 성공: 토큰 검증 완료"); } catch (Exception e) { - log.error("❌ WebSocket 토큰 검증 실패: {}", e.getMessage()); throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다.", e); } } From f965ae7627aec1fc3b7652b52fb39ec42d6a71c0 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 15 Dec 2025 18:33:06 +0900 Subject: [PATCH 10/12] =?UTF-8?q?=E2=9C=A8feat:=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EB=AA=A9=EB=A1=9D=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repository/FollowRepository.java | 2 +- .../repository/FollowRepositoryCustom.java | 14 ++ .../FollowRepositoryCustomImpl.java | 39 ++++ .../service/query/FollowQueryService.java | 172 ++++++++++++++++++ .../web/controller/MemberController.java | 42 +++++ .../member/web/dto/MemberResponseDTO.java | 33 ++++ .../RefrigeratorInvitationRepository.java | 6 + 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustom.java create mode 100644 src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustomImpl.java create mode 100644 src/main/java/novaminds/gradproj/domain/member/service/query/FollowQueryService.java diff --git a/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepository.java b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepository.java index 8734a9f..21e6ac8 100644 --- a/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepository.java +++ b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepository.java @@ -7,7 +7,7 @@ import novaminds.gradproj.domain.member.entity.Follow; import org.springframework.data.jpa.repository.Modifying; -public interface FollowRepository extends JpaRepository { +public interface FollowRepository extends JpaRepository, FollowRepositoryCustom { // 특정 유저'가' 팔로우하는 사람들 List findByFollowerLoginId(String followerLoginId); diff --git a/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustom.java b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustom.java new file mode 100644 index 0000000..21d4acf --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustom.java @@ -0,0 +1,14 @@ +package novaminds.gradproj.domain.member.repository; + +import novaminds.gradproj.domain.member.entity.Follow; + +import java.util.List; + +public interface FollowRepositoryCustom { + + // 팔로워 목록 조회 (N+1 방지를 위한 fetch join) + List findFollowersWithMemberByLoginId(String loginId); + + // 팔로잉 목록 조회 (N+1 방지를 위한 fetch join) + List findFollowingsWithMemberByLoginId(String loginId); +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustomImpl.java b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustomImpl.java new file mode 100644 index 0000000..14471d0 --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/member/repository/FollowRepositoryCustomImpl.java @@ -0,0 +1,39 @@ +package novaminds.gradproj.domain.member.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.domain.member.entity.Follow; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static novaminds.gradproj.domain.member.entity.QFollow.follow; +import static novaminds.gradproj.domain.member.entity.QMember.member; +import static novaminds.gradproj.domain.refrigerator.entity.QRefrigerator.refrigerator; + +@Repository +@RequiredArgsConstructor +public class FollowRepositoryCustomImpl implements FollowRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findFollowersWithMemberByLoginId(String loginId) { + return queryFactory + .selectFrom(follow) + .join(follow.follower, member).fetchJoin() + .join(member.refrigerator, refrigerator).fetchJoin() + .where(follow.following.loginId.eq(loginId)) + .fetch(); + } + + @Override + public List findFollowingsWithMemberByLoginId(String loginId) { + return queryFactory + .selectFrom(follow) + .join(follow.following, member).fetchJoin() + .join(member.refrigerator, refrigerator).fetchJoin() + .where(follow.follower.loginId.eq(loginId)) + .fetch(); + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/member/service/query/FollowQueryService.java b/src/main/java/novaminds/gradproj/domain/member/service/query/FollowQueryService.java new file mode 100644 index 0000000..d915a9e --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/member/service/query/FollowQueryService.java @@ -0,0 +1,172 @@ +package novaminds.gradproj.domain.member.service.query; + +import lombok.RequiredArgsConstructor; +import novaminds.gradproj.domain.member.entity.Follow; +import novaminds.gradproj.domain.member.entity.Member; +import novaminds.gradproj.domain.member.repository.FollowRepository; +import novaminds.gradproj.domain.member.web.dto.MemberResponseDTO; +import novaminds.gradproj.domain.refrigerator.entity.RefrigeratorInvitation; +import novaminds.gradproj.domain.refrigerator.repository.RefrigeratorInvitationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class FollowQueryService { + + private final FollowRepository followRepository; + private final RefrigeratorInvitationRepository refrigeratorInvitationRepository; + + public MemberResponseDTO.FollowersResponse getFollowers(Member currentMember) { + // 나를 팔로우하는 사람들 조회 (fetch join으로 N+1 방지) + List followers = followRepository.findFollowersWithMemberByLoginId(currentMember.getLoginId()); + + // 내가 팔로우하는 사람들의 loginId를 Set으로 (맞팔 확인용) + Set myFollowingIds = followRepository.findByFollowerLoginId(currentMember.getLoginId()) + .stream() + .map(follow -> follow.getFollowing().getLoginId()) + .collect(Collectors.toSet()); + + // 나의 냉장고 ID + Long myRefrigeratorId = currentMember.getRefrigerator().getId(); + + // 팔로워 멤버 리스트 + List followerMembers = followers.stream() + .map(Follow::getFollower) + .collect(Collectors.toList()); + + // 내가 보낸 PENDING 초대 조회 (batch) + List pendingInvitations = refrigeratorInvitationRepository + .findByInviterAndInviteeInAndStatus( + currentMember, + followerMembers, + RefrigeratorInvitation.InvitationStatus.PENDING + ); + + // PENDING 초대를 받은 사람들의 loginId를 Set으로 + Set pendingInviteeIds = pendingInvitations.stream() + .map(invitation -> invitation.getInvitee().getLoginId()) + .collect(Collectors.toSet()); + + // DTO 변환 + List followerInfos = followers.stream() + .map(follow -> { + Member followerMember = follow.getFollower(); + MemberResponseDTO.FollowMemberInfo.InvitationStatus status = + determineInvitationStatus( + followerMember, + myFollowingIds, + myRefrigeratorId, + pendingInviteeIds + ); + + return MemberResponseDTO.FollowMemberInfo.builder() + .nickname(followerMember.getNickname()) + .profileImgUrl(followerMember.getProfileImage()) + .invitationStatus(status) + .build(); + }) + .collect(Collectors.toList()); + + return MemberResponseDTO.FollowersResponse.builder() + .followers(followerInfos) + .build(); + } + + public MemberResponseDTO.FollowingsResponse getFollowings(Member currentMember) { + // 내가 팔로우하는 사람들 조회 (fetch join으로 N+1 방지) + List followings = followRepository.findFollowingsWithMemberByLoginId(currentMember.getLoginId()); + + // 나를 팔로우하는 사람들의 loginId를 Set으로 (맞팔 확인용) + Set myFollowerIds = followRepository.findByFollowingLoginId(currentMember.getLoginId()) + .stream() + .map(follow -> follow.getFollower().getLoginId()) + .collect(Collectors.toSet()); + + // 나의 냉장고 ID + Long myRefrigeratorId = currentMember.getRefrigerator().getId(); + + // 팔로잉 멤버 리스트 + List followingMembers = followings.stream() + .map(Follow::getFollowing) + .collect(Collectors.toList()); + + // 내가 보낸 PENDING 초대 조회 (batch) + List pendingInvitations = refrigeratorInvitationRepository + .findByInviterAndInviteeInAndStatus( + currentMember, + followingMembers, + RefrigeratorInvitation.InvitationStatus.PENDING + ); + + // PENDING 초대를 받은 사람들의 loginId를 Set으로 + Set pendingInviteeIds = pendingInvitations.stream() + .map(invitation -> invitation.getInvitee().getLoginId()) + .collect(Collectors.toSet()); + + // DTO 변환 + List followingInfos = followings.stream() + .map(follow -> { + Member followingMember = follow.getFollowing(); + MemberResponseDTO.FollowMemberInfo.InvitationStatus status = + determineInvitationStatus( + followingMember, + myFollowerIds, + myRefrigeratorId, + pendingInviteeIds + ); + + return MemberResponseDTO.FollowMemberInfo.builder() + .nickname(followingMember.getNickname()) + .profileImgUrl(followingMember.getProfileImage()) + .invitationStatus(status) + .build(); + }) + .collect(Collectors.toList()); + + return MemberResponseDTO.FollowingsResponse.builder() + .followings(followingInfos) + .build(); + } + + /** + * 초대 상태를 결정하는 메서드 + * + * @param targetMember 대상 멤버 + * @param mutualCheckSet 맞팔 확인을 위한 Set (팔로워 목록이면 내 팔로잉 Set, 팔로잉 목록이면 내 팔로워 Set) + * @param myRefrigeratorId 나의 냉장고 ID + * @param pendingInviteeIds PENDING 상태 초대를 받은 사람들의 loginId Set + * @return InvitationStatus + */ + private MemberResponseDTO.FollowMemberInfo.InvitationStatus determineInvitationStatus( + Member targetMember, + Set mutualCheckSet, + Long myRefrigeratorId, + Set pendingInviteeIds + ) { + // 맞팔이 아니면 NOT_MUTUAL + if (!mutualCheckSet.contains(targetMember.getLoginId())) { + return MemberResponseDTO.FollowMemberInfo.InvitationStatus.NOT_MUTUAL; + } + + // 맞팔이면 추가 조건 확인 + // 1. 같은 냉장고 사용 중인지 확인 + if (targetMember.getRefrigerator().getId().equals(myRefrigeratorId)) { + return MemberResponseDTO.FollowMemberInfo.InvitationStatus.ALREADY_SAME_REFRIGERATOR; + } + + // 2. 이미 초대장 보냈는지 확인 + if (pendingInviteeIds.contains(targetMember.getLoginId())) { + return MemberResponseDTO.FollowMemberInfo.InvitationStatus.INVITATION_PENDING; + } + + // 3. 둘 다 아니면 초대 가능 + return MemberResponseDTO.FollowMemberInfo.InvitationStatus.MUTUAL_FOLLOW_INVITE; + } +} \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/member/web/controller/MemberController.java b/src/main/java/novaminds/gradproj/domain/member/web/controller/MemberController.java index 16aa84e..f63cc6b 100644 --- a/src/main/java/novaminds/gradproj/domain/member/web/controller/MemberController.java +++ b/src/main/java/novaminds/gradproj/domain/member/web/controller/MemberController.java @@ -8,6 +8,7 @@ import novaminds.gradproj.apiPayload.ApiResponse; import novaminds.gradproj.domain.member.entity.Member; import novaminds.gradproj.domain.member.service.command.FollowCommandService; +import novaminds.gradproj.domain.member.service.query.FollowQueryService; import novaminds.gradproj.domain.member.service.query.MemberQueryService; import novaminds.gradproj.domain.member.service.security.auth.CurrentLoginId; import novaminds.gradproj.domain.member.service.security.auth.CurrentUser; @@ -26,6 +27,7 @@ public class MemberController { private final FollowCommandService followCommandService; private final MemberQueryService memberQueryService; + private final FollowQueryService followQueryService; @Operation(summary = "팔로잉", description = "특정 회원을 팔로잉합니다.") @@ -95,4 +97,44 @@ public ApiResponse getAllRanking( MemberResponseDTO.AllRankingResponse response = memberQueryService.getAllRanking(cursor, size); return ApiResponse.onSuccess(response); } + + @Operation(summary = "내 팔로워 목록 조회 API", + description = """ + 나를 팔로우하는 사람들의 목록을 조회합니다. + 각 팔로워에 대해 냉장고 초대 가능 여부를 판단하여 반환합니다. + - MUTUAL_FOLLOW_INVITE: 맞팔이고 초대 가능 + - ALREADY_SAME_REFRIGERATOR: 이미 같은 냉장고 사용 중 + - INVITATION_PENDING: 이미 초대장 보냄 (대기 중) + - NOT_MUTUAL: 맞팔 아님 (버튼 없음)""") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "로그인이 필요한 서비스 입니다.") + }) + @GetMapping("/followers") + public ApiResponse getFollowers( + @CurrentUser Member currentMember + ) { + MemberResponseDTO.FollowersResponse response = followQueryService.getFollowers(currentMember); + return ApiResponse.onSuccess(response); + } + + @Operation(summary = "내 팔로잉 목록 조회 API", + description = """ + 내가 팔로우하는 사람들의 목록을 조회합니다. + 각 팔로잉에 대해 냉장고 초대 가능 여부를 판단하여 반환합니다. + - MUTUAL_FOLLOW_INVITE: 맞팔이고 초대 가능 + - ALREADY_SAME_REFRIGERATOR: 이미 같은 냉장고 사용 중 + - INVITATION_PENDING: 이미 초대장 보냄 (대기 중) + - NOT_MUTUAL: 맞팔 아님 (버튼 없음)""") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "로그인이 필요한 서비스 입니다.") + }) + @GetMapping("/followings") + public ApiResponse getFollowings( + @CurrentUser Member currentMember + ) { + MemberResponseDTO.FollowingsResponse response = followQueryService.getFollowings(currentMember); + return ApiResponse.onSuccess(response); + } } diff --git a/src/main/java/novaminds/gradproj/domain/member/web/dto/MemberResponseDTO.java b/src/main/java/novaminds/gradproj/domain/member/web/dto/MemberResponseDTO.java index 0cd8a8c..7134cca 100644 --- a/src/main/java/novaminds/gradproj/domain/member/web/dto/MemberResponseDTO.java +++ b/src/main/java/novaminds/gradproj/domain/member/web/dto/MemberResponseDTO.java @@ -180,4 +180,37 @@ public static class AllRankingResponse { private String nextCursor; private Boolean hasNext; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class FollowMemberInfo { + private String nickname; + private String profileImgUrl; + private InvitationStatus invitationStatus; + + public enum InvitationStatus { + MUTUAL_FOLLOW_INVITE, // 맞팔이고 초대 가능 + ALREADY_SAME_REFRIGERATOR, // 이미 같은 냉장고 사용 중 + INVITATION_PENDING, // 이미 초대장 보냄 (대기 중) + NOT_MUTUAL // 맞팔 아님 (버튼 없음) + } + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class FollowersResponse { + private List followers; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class FollowingsResponse { + private List followings; + } } \ No newline at end of file diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java index 44d100a..804483d 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java @@ -25,4 +25,10 @@ List findByInviterAndStatusOrderByCreatedAtDesc( Member inviter, InvitationStatus status ); + + List findByInviterAndInviteeInAndStatus( + Member inviter, + List invitees, + InvitationStatus status + ); } \ No newline at end of file From 2e49f64d176809be753dffb76c82fc435eb59bd1 Mon Sep 17 00:00:00 2001 From: chan0831 <116000778+chan0831@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:36:26 +0900 Subject: [PATCH 11/12] Update src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../service/command/RefrigeratorInvitationCommandService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java index f883dee..71ff030 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/service/command/RefrigeratorInvitationCommandService.java @@ -47,7 +47,7 @@ public void sendInvitation(Member inviter, String inviteeNickname) { throw new GeneralException(ErrorStatus.REFRIGERATOR_NOT_FOUND); } - if (inviterRefrigerator.getId().equals(inviteeRefrigerator.getId())) { + if (inviteeRefrigerator != null && inviterRefrigerator.getId().equals(inviteeRefrigerator.getId())) { throw new GeneralException(ErrorStatus.ALREADY_IN_SAME_REFRIGERATOR); } From 9871677b7ffb3bbf0672b17e1f332587ca2e8744 Mon Sep 17 00:00:00 2001 From: chan0831 <116000778+chan0831@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:36:41 +0900 Subject: [PATCH 12/12] Update src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../gradproj/domain/refrigerator/entity/Refrigerator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java index 48ed2d5..aeec9cb 100644 --- a/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java @@ -23,7 +23,7 @@ public class Refrigerator extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @OneToMany(mappedBy = "refrigerator", cascade = CascadeType.ALL) + @OneToMany(mappedBy = "refrigerator") @Builder.Default private List memberList = new ArrayList<>();