diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/answer/entity/Answer.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/answer/entity/Answer.java index 9f48d423..d09f1e53 100644 --- a/baebae-BE/src/main/java/com/web/baebaeBE/domain/answer/entity/Answer.java +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/answer/entity/Answer.java @@ -4,6 +4,7 @@ import com.web.baebaeBE.domain.category.entity.Category; import com.web.baebaeBE.domain.member.entity.Member; import com.web.baebaeBE.domain.question.entity.Question; +import com.web.baebaeBE.domain.reaction.entity.ReactionValue; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.OnDelete; @@ -87,6 +88,20 @@ public static Answer of(Long id, Question question, Category category, Member me musicPicture, musicAudio, linkAttachments, heartCount, curiousCount, sadCount, createdDate,null); } + + public void increaseReactionCount(ReactionValue reaction) { + switch (reaction) { + case HEART: // 좋아요 + this.heartCount++; + break; + case CURIOUS: // 궁금해요 + this.curiousCount++; + break; + case SAD: // 슬퍼요 + this.sadCount++; + break; + } + } } diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/MemberAnswerReactionController.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/MemberAnswerReactionController.java new file mode 100644 index 00000000..a584a587 --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/MemberAnswerReactionController.java @@ -0,0 +1,40 @@ +package com.web.baebaeBE.domain.reaction.controller; + +import com.web.baebaeBE.domain.reaction.controller.api.MemberAnswerReactionApi; +import com.web.baebaeBE.domain.reaction.dto.ReactionRequest; +import com.web.baebaeBE.domain.reaction.dto.ReactionResponse; +import com.web.baebaeBE.domain.reaction.entity.MemberAnswerReaction; +import com.web.baebaeBE.domain.reaction.service.MemberAnswerReactionService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/reactions") +@RequiredArgsConstructor +public class MemberAnswerReactionController implements MemberAnswerReactionApi { + private final MemberAnswerReactionService memberAnswerReactionService; + + @PostMapping("/{memberId}/{answerId}") + public ResponseEntity createReaction( + @PathVariable Long memberId, + @PathVariable Long answerId, + @RequestBody ReactionRequest.create reactionDto) { + + return ResponseEntity.ok(memberAnswerReactionService.createReaction(memberId, answerId, reactionDto.getReaction())); + } + + // 통했당~ + @PostMapping("/connection/{memberId}/{answerId}/{destinationMemberId}") + public ResponseEntity createClickReaction( + @PathVariable Long memberId, // 자기자신 (통했당 하는 주체) + @PathVariable Long answerId, // 피드정보 + @RequestParam(required = false) Long destinationMemberId // 피드작성자가 통했당 누군지 대상 지정 + ) { + // destinationMemberId 파라미터 여부로 체크 + if(destinationMemberId == null) + return ResponseEntity.ok(memberAnswerReactionService.createConnectionReaction(memberId, answerId)); + else + return ResponseEntity.ok(memberAnswerReactionService.connectConnectionReaction(memberId, answerId, destinationMemberId)); + } +} \ No newline at end of file diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/api/MemberAnswerReactionApi.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/api/MemberAnswerReactionApi.java new file mode 100644 index 00000000..7597e996 --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/controller/api/MemberAnswerReactionApi.java @@ -0,0 +1,102 @@ +package com.web.baebaeBE.domain.reaction.controller.api; + +import com.web.baebaeBE.domain.reaction.dto.ReactionRequest; +import com.web.baebaeBE.domain.reaction.dto.ReactionResponse; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestParam; + + +@Tag(name = "Reaction", description = "피드 반응 API") +@SecurityRequirement(name = "bearerAuth") +@RequestMapping("/api/reactions") +public interface MemberAnswerReactionApi { + + @Operation( + summary = "피드 반응 생성", + description = "피드에 대한 지정된 멤버 ID와 답변 ID에 대한 반응을 생성합니다. " + + "(HEART, CURIOUS, SAD 만 가능합니다.)", + security = @SecurityRequirement(name = "bearerAuth") + ) + @Parameter( + in = ParameterIn.HEADER, + name = "Authorization", required = true, + schema = @Schema(type = "string"), + description = "Bearer [Access 토큰]") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "생성 성공", + content = @Content(mediaType = "application/json", + schema = @Schema( + implementation = ReactionResponse.ReactionInformationDto.class + )) + ), + @ApiResponse(responseCode = "401", description = "토큰 인증 실패", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n" + + " \"errorCode\": \"M-003\",\n" + + " \"message\": \"해당 토큰은 유효한 토큰이 아닙니다.\"\n" + + "}")) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 회원 또는 답변", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n" + + " \"errorCode\": \"M-002\",\n" + + " \"message\": \"존재하지 않는 회원 또는 답변입니다.\"\n" + + "}")) + ) + }) + ResponseEntity createReaction(@Parameter(description = "멤버의 ID", required = true) @PathVariable Long memberId, + @Parameter(description = "답변의 ID", required = true) @PathVariable Long answerId, + @RequestBody ReactionRequest.create reactionDto); + + @Operation( + summary = "통했당 생성", + description = "지정된 멤버 ID, 답변 ID, 대상 멤버 ID에 대한 '통했당'을 생성합니다. " + + "다른 피드에 '통했당'을 남길경우 memberId, answerId만 필요합니다. " + + "또한 피드 주인이 통했당을 완료할 경우 destinationMemberId가 추가적으로 필요합니다." + + "(", + security = @SecurityRequirement(name = "bearerAuth") + ) + @Parameter( + in = ParameterIn.HEADER, + name = "Authorization", required = true, + schema = @Schema(type = "string"), + description = "Bearer [Access 토큰]") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "생성 성공", + content = @Content(mediaType = "application/json", + schema = @Schema( + implementation = ReactionResponse.ConnectionReactionInformationDto.class + )) + ), + @ApiResponse(responseCode = "401", description = "토큰 인증 실패", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n" + + " \"errorCode\": \"M-003\",\n" + + " \"message\": \"해당 토큰은 유효한 토큰이 아닙니다.\"\n" + + "}")) + ), + @ApiResponse(responseCode = "404", description = "존재하지 않는 회원 또는 답변", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n" + + " \"errorCode\": \"M-002\",\n" + + " \"message\": \"존재하지 않는 회원 또는 답변입니다.\"\n" + + "}")) + ) + }) + ResponseEntity createClickReaction(@Parameter(description = "멤버의 ID", required = true) @PathVariable Long memberId, + @Parameter(description = "답변의 ID", required = true) @PathVariable Long answerId, + @Parameter(description = "대상 멤버의 ID (피드주인이 통했당 완료할때만 가능)", required = false) @RequestParam(required = false) Long destinationMemberId); +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionRequest.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionRequest.java new file mode 100644 index 00000000..9600fb99 --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionRequest.java @@ -0,0 +1,20 @@ +package com.web.baebaeBE.domain.reaction.dto; + +import com.web.baebaeBE.domain.reaction.entity.ReactionValue; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +public class ReactionRequest { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class create{ + private ReactionValue reaction; + } + +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionResponse.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionResponse.java new file mode 100644 index 00000000..c30861fe --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/dto/ReactionResponse.java @@ -0,0 +1,46 @@ +package com.web.baebaeBE.domain.reaction.dto; + +import com.web.baebaeBE.domain.answer.entity.Answer; +import com.web.baebaeBE.domain.reaction.entity.MemberAnswerReaction; +import lombok.*; + +public class ReactionResponse { + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ReactionInformationDto { + private boolean isClicked; + private int heartCount; + private int curiousCount; + private int sadCount; + + public static ReactionInformationDto of(Answer answer, boolean isClicked) { + return ReactionInformationDto.builder() + .isClicked(isClicked) + .heartCount(answer.getHeartCount()) + .curiousCount(answer.getCuriousCount()) + .sadCount(answer.getSadCount()) + .build(); + } + } + + @Getter + @Setter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ConnectionReactionInformationDto { + private boolean isClicked; + private boolean isMatched; + + public static ConnectionReactionInformationDto of(boolean isClicked, boolean isMatched) { + return ConnectionReactionInformationDto.builder() + .isClicked(isClicked) + .isMatched(isMatched) + .build(); + } + } +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/MemberAnswerReaction.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/MemberAnswerReaction.java new file mode 100644 index 00000000..1434b93e --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/MemberAnswerReaction.java @@ -0,0 +1,34 @@ +package com.web.baebaeBE.domain.reaction.entity; + + +import com.web.baebaeBE.domain.answer.entity.Answer; +import com.web.baebaeBE.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "member_answer_reaction") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class MemberAnswerReaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "answer_id", nullable = false) + private Answer answer; + + @Enumerated(EnumType.STRING) + @Column(name = "reaction", nullable = false) + private ReactionValue reaction; + +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/ReactionValue.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/ReactionValue.java new file mode 100644 index 00000000..9479b88a --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/entity/ReactionValue.java @@ -0,0 +1,5 @@ +package com.web.baebaeBE.domain.reaction.entity; + +public enum ReactionValue { + HEART, CURIOUS, SAD, CONNECTION +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/exception/ReactionException.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/exception/ReactionException.java new file mode 100644 index 00000000..b09f9332 --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/exception/ReactionException.java @@ -0,0 +1,20 @@ +package com.web.baebaeBE.domain.reaction.exception; + +import com.web.baebaeBE.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ReactionException implements ErrorCode { + + NOT_EXIST_CONNECTION_REACTION(HttpStatus.NOT_FOUND, "R-001", "상대방이 통했당을 하지 않았습니다."), + NOT_EXIST_MEMBER(HttpStatus.NOT_FOUND, "R-002", "존재하지 않는 회원입니다."), + NOT_EXIST_ANSWER(HttpStatus.NOT_FOUND, "R-003", "존재하지 않는 답변입니다."); + + private final HttpStatus httpStatus; + private final String errorCode; + private final String message; + +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/repository/MemberAnswerReactionRepository.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/repository/MemberAnswerReactionRepository.java new file mode 100644 index 00000000..b036115b --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/repository/MemberAnswerReactionRepository.java @@ -0,0 +1,13 @@ +package com.web.baebaeBE.domain.reaction.repository; + +import com.web.baebaeBE.domain.answer.entity.Answer; +import com.web.baebaeBE.domain.member.entity.Member; +import com.web.baebaeBE.domain.reaction.entity.MemberAnswerReaction; +import com.web.baebaeBE.domain.reaction.entity.ReactionValue; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberAnswerReactionRepository extends JpaRepository { + Optional findByMemberAndAnswerAndReaction(Member member, Answer answer, ReactionValue reaction); +} diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/MemberAnswerReactionService.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/MemberAnswerReactionService.java new file mode 100644 index 00000000..60fd6973 --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/MemberAnswerReactionService.java @@ -0,0 +1,111 @@ +package com.web.baebaeBE.domain.reaction.service; + + +import com.web.baebaeBE.domain.answer.entity.Answer; +import com.web.baebaeBE.domain.answer.exception.AnswerError; +import com.web.baebaeBE.domain.answer.repository.AnswerRepository; +import com.web.baebaeBE.domain.login.exception.LoginException; +import com.web.baebaeBE.domain.member.entity.Member; +import com.web.baebaeBE.domain.member.repository.MemberRepository; +import com.web.baebaeBE.domain.reaction.dto.ReactionResponse; +import com.web.baebaeBE.domain.reaction.entity.MemberAnswerReaction; +import com.web.baebaeBE.domain.reaction.entity.ReactionValue; +import com.web.baebaeBE.domain.reaction.exception.ReactionException; +import com.web.baebaeBE.domain.reaction.repository.MemberAnswerReactionRepository; +import com.web.baebaeBE.global.error.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class MemberAnswerReactionService { + + private final ReactionUpdateService reactionUpdateService; + private final MemberAnswerReactionRepository memberAnswerReactionRepository; + private final MemberRepository memberRepository; + private final AnswerRepository answerRepository; + + public ReactionResponse.ReactionInformationDto createReaction(Long memberId, Long answerId, ReactionValue reaction) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(LoginException.NOT_EXIST_MEMBER)); + Answer answer = answerRepository.findByAnswerId(answerId) + .orElseThrow(() -> new BusinessException(AnswerError.NO_EXIST_ANSWER)); + boolean isClicked = false; + + // 이미 해당 반응이 있는지 확인 + Optional existingReactionOpt = memberAnswerReactionRepository.findByMemberAndAnswerAndReaction(member, answer, reaction); + + if (existingReactionOpt.isPresent()) { + // 이미 해당 반응이 있다면 반응을 삭제 + MemberAnswerReaction existingReaction = existingReactionOpt.get(); + memberAnswerReactionRepository.delete(existingReaction); + reactionUpdateService.decreaseReactionCount(answer, reaction); + isClicked = false; + } else { + // 해당 반응이 없다면 새로운 반응을 저장 + MemberAnswerReaction memberAnswerReaction = MemberAnswerReaction.builder() + .member(member) + .answer(answer) + .reaction(reaction) + .build(); + + memberAnswerReactionRepository.save(memberAnswerReaction); + reactionUpdateService.increaseReactionCount(answer, reaction); + isClicked = true; + } + + return ReactionResponse.ReactionInformationDto.of(answer,isClicked); + } + + // 피드 주인이 아닌 다른 사람이 통했당 신청 + public ReactionResponse.ConnectionReactionInformationDto createConnectionReaction(Long memberId, Long answerId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ReactionException.NOT_EXIST_MEMBER)); + Answer answer = answerRepository.findByAnswerId(answerId) + .orElseThrow(() -> new BusinessException(ReactionException.NOT_EXIST_ANSWER)); + boolean isClicked = false; + + Optional existingReactionOpt = memberAnswerReactionRepository.findByMemberAndAnswerAndReaction(member, answer, ReactionValue.CONNECTION); + if(existingReactionOpt.isPresent()){ + // 이미 해당 반응이 있다면 반응을 삭제 + MemberAnswerReaction existingReaction = existingReactionOpt.get(); + memberAnswerReactionRepository.delete(existingReaction); + }else{// 해당 반응이 없다면 상대방 반응유무 체크후 통했당 저장 + MemberAnswerReaction memberAnswerReaction = MemberAnswerReaction.builder() + .member(member) + .answer(answer) + .reaction(ReactionValue.CONNECTION) // 통했당 + .build(); + + memberAnswerReactionRepository.save(memberAnswerReaction); + isClicked = true; + } + + return ReactionResponse.ConnectionReactionInformationDto.of(isClicked, false); + } + + //피드 주인이 통했당을 연결할 때, + public ReactionResponse.ConnectionReactionInformationDto connectConnectionReaction(Long memberId, Long answerId, Long destinationMemberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new BusinessException(ReactionException.NOT_EXIST_MEMBER)); + Answer answer = answerRepository.findByAnswerId(answerId) + .orElseThrow(() -> new BusinessException(ReactionException.NOT_EXIST_ANSWER)); + Member destinationMember = memberRepository.findById(destinationMemberId) + .orElseThrow(() -> new BusinessException(ReactionException.NOT_EXIST_MEMBER)); + + // (상대방이 해당 피드에 통했당을 남겼는지 체크 + Optional existingCheckOpt = memberAnswerReactionRepository.findByMemberAndAnswerAndReaction(destinationMember, answer, ReactionValue.CONNECTION); + + // 상대방의 통했당 신청이 있는지 확인 + if (existingCheckOpt.isPresent()) { + memberAnswerReactionRepository.delete(existingCheckOpt.get()); // 상대방 통했당 신청 삭제 후 연결 + return ReactionResponse.ConnectionReactionInformationDto.of(true,true); + } else { + throw new BusinessException(ReactionException.NOT_EXIST_CONNECTION_REACTION); + } + } +} \ No newline at end of file diff --git a/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/ReactionUpdateService.java b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/ReactionUpdateService.java new file mode 100644 index 00000000..89cf666e --- /dev/null +++ b/baebae-BE/src/main/java/com/web/baebaeBE/domain/reaction/service/ReactionUpdateService.java @@ -0,0 +1,41 @@ +package com.web.baebaeBE.domain.reaction.service; + +import com.web.baebaeBE.domain.answer.entity.Answer; +import com.web.baebaeBE.domain.reaction.entity.ReactionValue; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ReactionUpdateService { + + public void increaseReactionCount(Answer answer, ReactionValue reaction) { + switch (reaction) { + case HEART: // 좋아요 + answer.setHeartCount(answer.getHeartCount() + 1); + break; + case CURIOUS: // 궁금해요 + answer.setCuriousCount(answer.getCuriousCount() + 1); + break; + case SAD: // 슬퍼요 + answer.setSadCount(answer.getSadCount() + 1); + break; + } + } + + public void decreaseReactionCount(Answer answer, ReactionValue reaction) { + switch (reaction) { + case HEART: // 좋아요 + answer.setHeartCount(answer.getHeartCount() - 1); + break; + case CURIOUS: // 궁금해요 + answer.setCuriousCount(answer.getCuriousCount() - 1); + break; + case SAD: // 슬퍼요 + answer.setSadCount(answer.getSadCount() - 1); + break; + } + } +}