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') { 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", "레시피 수정/삭제 권한이 없습니다."), 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/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/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/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/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/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/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/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/converter/RefrigeratorConverter.java b/src/main/java/novaminds/gradproj/domain/refrigerator/converter/RefrigeratorConverter.java index d43e8ac..7bd63bb 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; @@ -76,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(); @@ -142,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()) @@ -199,4 +202,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/Refrigerator.java b/src/main/java/novaminds/gradproj/domain/refrigerator/entity/Refrigerator.java index 91ffd50..aeec9cb 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") + @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/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..804483d --- /dev/null +++ b/src/main/java/novaminds/gradproj/domain/refrigerator/repository/RefrigeratorInvitationRepository.java @@ -0,0 +1,34 @@ +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 + ); + + List findByInviterAndInviteeInAndStatus( + Member inviter, + List invitees, + InvitationStatus status + ); +} \ No newline at end of file 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..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; @@ -39,7 +42,6 @@ public void createRefrigerator(Member member) { // 냉장고 생성 Refrigerator refrigerator = Refrigerator.builder() - .member(member) .build(); // 냉장고 저장 @@ -69,6 +71,8 @@ public void addIngredientsToRefrigerator( for (RefrigeratorRequestDTO.IngredientItem request : requests) { addSingleIngredientToRefrigerator(refrigerator, request); } + + sendRefreshSignal(refrigerator.getId()); } /** @@ -137,6 +141,7 @@ public Long modifyMyStoredItem(Member member, Long storedItemId, RefrigeratorReq // 변경된 필드만 업데이트 storedItem.updateFieldIfChanged(request); + sendRefreshSignal(refrigerator.getId()); return storedItem.getId(); } @@ -178,6 +183,8 @@ public void removeMyIngredients( // Refrigerator 엔티티의 storedItems 리스트에서 제거 storedItemsToDelete.forEach(refrigerator::removeStoredItem); + + sendRefreshSignal(refrigerator.getId()); } @@ -199,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) {} } 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..71ff030 --- /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 (inviteeRefrigerator != null && 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/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/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..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; @@ -177,4 +182,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; + } } 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..7b466f1 --- /dev/null +++ b/src/main/java/novaminds/gradproj/global/websocket/StompHandler.java @@ -0,0 +1,45 @@ +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); + + // 토큰 검증 with 예외 처리 + try { + if (jwtToken == null || !jwtTokenProvider.validateToken(jwtToken)) { + throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다."); + } + } catch (Exception e) { + throw new IllegalArgumentException("유효하지 않은 웹소켓 연결 토큰입니다.", e); + } + } + return message; + } +}