-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feature] - 좋아요 기능 구현 #336
Changes from all commits
d4d3b13
a4451a2
f08a78c
1d27c3e
0ed1859
ca10085
256841c
191fb0f
a8852f2
2461d83
58a06ee
60c4f64
799171b
63516e5
f5a3d18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,6 +14,7 @@ | |
import kr.touroot.global.exception.dto.ExceptionResponse; | ||
import kr.touroot.travelogue.dto.request.TravelogueRequest; | ||
import kr.touroot.travelogue.dto.request.TravelogueSearchRequest; | ||
import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; | ||
import kr.touroot.travelogue.dto.response.TravelogueResponse; | ||
import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; | ||
import kr.touroot.travelogue.service.TravelogueFacadeService; | ||
|
@@ -23,6 +24,7 @@ | |
import org.springframework.data.domain.Pageable; | ||
import org.springframework.data.domain.Sort.Direction; | ||
import org.springframework.data.web.PageableDefault; | ||
import org.springframework.http.HttpHeaders; | ||
import org.springframework.http.ResponseEntity; | ||
import org.springframework.web.bind.annotation.DeleteMapping; | ||
import org.springframework.web.bind.annotation.GetMapping; | ||
|
@@ -65,6 +67,29 @@ public ResponseEntity<TravelogueResponse> createTravelogue( | |
.body(response); | ||
} | ||
|
||
@Operation(summary = "여행기 좋아요") | ||
@ApiResponses(value = { | ||
@ApiResponse( | ||
responseCode = "201", | ||
description = "요청이 정상적으로 처리되었을 때" | ||
), | ||
@ApiResponse( | ||
responseCode = "400", | ||
description = "존재하지 않는 여행기 ID로 요청했을 때", | ||
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) | ||
), | ||
@ApiResponse( | ||
responseCode = "401", | ||
description = "로그인하지 않은 사용자가 좋아요를 할 때", | ||
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) | ||
), | ||
}) | ||
@PostMapping("/{id}/like") | ||
public ResponseEntity<TravelogueLikeResponse> likeTravelogue(@PathVariable Long id, @Valid MemberAuth member) { | ||
return ResponseEntity.ok() | ||
.body(travelogueFacadeService.likeTravelogue(id, member)); | ||
} | ||
|
||
@Operation(summary = "여행기 상세 조회") | ||
@ApiResponses(value = { | ||
@ApiResponse( | ||
|
@@ -82,6 +107,23 @@ public ResponseEntity<TravelogueResponse> findTravelogue(@PathVariable Long id) | |
return ResponseEntity.ok(travelogueFacadeService.findTravelogueById(id)); | ||
} | ||
|
||
@Operation(summary = "여행기 상세 조회") | ||
@ApiResponses(value = { | ||
@ApiResponse( | ||
responseCode = "200", | ||
description = "요청이 정상적으로 처리되었을 때" | ||
), | ||
@ApiResponse( | ||
responseCode = "400", | ||
description = "존재하지 않는 여행기 ID로 요청했을 때", | ||
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) | ||
), | ||
}) | ||
@GetMapping(value = "/{id}", headers = {HttpHeaders.AUTHORIZATION}) | ||
public ResponseEntity<TravelogueResponse> findTravelogue(@PathVariable Long id, MemberAuth member) { | ||
return ResponseEntity.ok(travelogueFacadeService.findTravelogueById(id, member)); | ||
} | ||
|
||
@Operation(summary = "여행기 메인 페이지 조회") | ||
@ApiResponses(value = { | ||
@ApiResponse( | ||
|
@@ -200,4 +242,27 @@ public ResponseEntity<Void> deleteTravelogue(@PathVariable Long id, MemberAuth m | |
return ResponseEntity.noContent() | ||
.build(); | ||
} | ||
|
||
@Operation(summary = "여행기 좋아요 취소") | ||
@ApiResponses(value = { | ||
@ApiResponse( | ||
responseCode = "200", | ||
description = "요청이 정상적으로 처리되었을 때" | ||
), | ||
@ApiResponse( | ||
responseCode = "400", | ||
description = "존재하지 않는 여행기 ID로 요청했을 때", | ||
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) | ||
), | ||
@ApiResponse( | ||
responseCode = "401", | ||
description = "로그인하지 않은 사용자가 좋아요를 취소 할 때", | ||
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) | ||
), | ||
}) | ||
@DeleteMapping("/{id}/like") | ||
public ResponseEntity<TravelogueLikeResponse> unlikeTravelogue(@PathVariable Long id, @Valid MemberAuth member) { | ||
return ResponseEntity.ok() | ||
.body(travelogueFacadeService.unlikeTravelogue(id, member)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 그냥 의견) 별거 아니긴한데 dislike, unlike중에 통일되면 좋을 것 같네요! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 와우 dislike로 하다가 unlike가 더 나은거 같아 중간에 바꿨는데 미처 바꾸지 못했던 부분이 있었군요!! |
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
package kr.touroot.travelogue.domain; | ||
|
||
import jakarta.persistence.Entity; | ||
import jakarta.persistence.FetchType; | ||
import jakarta.persistence.GeneratedValue; | ||
import jakarta.persistence.GenerationType; | ||
import jakarta.persistence.Id; | ||
import jakarta.persistence.JoinColumn; | ||
import jakarta.persistence.ManyToOne; | ||
import jakarta.persistence.Table; | ||
import jakarta.persistence.UniqueConstraint; | ||
import kr.touroot.member.domain.Member; | ||
import lombok.AccessLevel; | ||
import lombok.AllArgsConstructor; | ||
import lombok.Getter; | ||
import lombok.NoArgsConstructor; | ||
|
||
@Getter | ||
@NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
@AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"TRAVELOGUE_ID", "LIKER_ID"})}) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 질문) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
하지만 여행기 좋아요는 (travelogue_id, liker_id) 이 조합이 unique 해야 해서 위와 같이 구현했습니다! |
||
@Entity | ||
public class TravelogueLike { | ||
|
||
@Id | ||
@GeneratedValue(strategy = GenerationType.IDENTITY) | ||
private Long id; | ||
|
||
@JoinColumn(name = "TRAVELOGUE_ID", nullable = false) | ||
@ManyToOne(fetch = FetchType.LAZY) | ||
private Travelogue travelogue; | ||
|
||
@JoinColumn(name = "LIKER_ID", nullable = false) | ||
@ManyToOne(fetch = FetchType.LAZY) | ||
private Member liker; | ||
|
||
public TravelogueLike(Travelogue travelogue, Member liker) { | ||
this(null, travelogue, liker); | ||
} | ||
} | ||
Comment on lines
+23
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 다대다 확인! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package kr.touroot.travelogue.dto.response; | ||
|
||
import io.swagger.v3.oas.annotations.media.Schema; | ||
|
||
public record TravelogueLikeResponse( | ||
@Schema(description = "사용자의 좋아요 여부", example = "true") | ||
Boolean isLiked, | ||
@Schema(description = "여행기의 좋아요 수", example = "10") | ||
Long likeCount | ||
) { | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package kr.touroot.travelogue.repository; | ||
|
||
import kr.touroot.member.domain.Member; | ||
import kr.touroot.travelogue.domain.Travelogue; | ||
import kr.touroot.travelogue.domain.TravelogueLike; | ||
import org.springframework.data.jpa.repository.JpaRepository; | ||
|
||
public interface TravelogueLikeRepository extends JpaRepository<TravelogueLike, Long> { | ||
|
||
Long countByTravelogue(Travelogue travelogue); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아직 성능 논의할 단계가 아니긴한데 Like가 많아질수록 count가 자주 일어날 것 같기도합니다. 저희는 작성보다 조회가 훨씬 많이 일어나는 서비스라고 생각해서 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 보니 좋아요와 관련된 모든 로직에서 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 확실히 고민해 볼만한 부분은 맞는 것 같습니다! 하지만 클로버가 말씀하신대로 아직 성능을 논의할 단계가 아니라는 점이 계속 고민을 하게 만드는 것 같습니다 ㅠㅠ 월요일에 다같이 한번 논의해 보시죠! |
||
|
||
boolean existsByTravelogueAndLiker(Travelogue travelogue, Member liker); | ||
|
||
void deleteByTravelogueAndLiker(Travelogue travelogue, Member liker); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,7 @@ | |
import kr.touroot.travelogue.dto.request.TravelogueRequest; | ||
import kr.touroot.travelogue.dto.request.TravelogueSearchRequest; | ||
import kr.touroot.travelogue.dto.response.TravelogueDayResponse; | ||
import kr.touroot.travelogue.dto.response.TravelogueLikeResponse; | ||
import kr.touroot.travelogue.dto.response.TraveloguePlaceResponse; | ||
import kr.touroot.travelogue.dto.response.TravelogueResponse; | ||
import kr.touroot.travelogue.dto.response.TravelogueSimpleResponse; | ||
|
@@ -35,14 +36,16 @@ public class TravelogueFacadeService { | |
private final TraveloguePlaceService traveloguePlaceService; | ||
private final TraveloguePhotoService traveloguePhotoService; | ||
private final TravelogueTagService travelogueTagService; | ||
private final TravelogueLikeService travelogueLikeService; | ||
private final MemberService memberService; | ||
|
||
@Transactional | ||
public TravelogueResponse createTravelogue(MemberAuth member, TravelogueRequest request) { | ||
Member author = memberService.getById(member.memberId()); | ||
Travelogue travelogue = travelogueService.createTravelogue(author, request); | ||
List<TagResponse> tags = travelogueTagService.createTravelogueTags(travelogue, request.tags()); | ||
return TravelogueResponse.of(travelogue, createDays(request.days(), travelogue), tags); | ||
TravelogueLikeResponse like = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, author); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여행기를 생성할 때에는 좋아요 수가 0이고, 좋아요 여부가 false 입니다. private static final TravelogueLikeResponse INITIAL_LIKE = new TravelogueLikeResponse(false, 0L); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 개인적으로는 DB 찌르는 현재 구현이 신뢰성있다고 생각합니다! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 생성 시에는 어차피 좋아요 수 0, 여부 false가 고정값이라 상수로 빼두는 것도 괜찮은 구현 같습니다 |
||
return TravelogueResponse.of(travelogue, createDays(request.days(), travelogue), tags, like); | ||
} | ||
|
||
private List<TravelogueDayResponse> createDays(List<TravelogueDayRequest> requests, Travelogue travelogue) { | ||
|
@@ -71,15 +74,38 @@ private List<String> createPhotos(List<TraveloguePhotoRequest> requests, Travelo | |
.toList(); | ||
} | ||
|
||
@Transactional | ||
public TravelogueLikeResponse likeTravelogue(Long travelogueId, MemberAuth member) { | ||
Travelogue travelogue = travelogueService.getTravelogueById(travelogueId); | ||
Member liker = memberService.getById(member.memberId()); | ||
|
||
return travelogueLikeService.likeTravelogue(travelogue, liker); | ||
} | ||
|
||
@Transactional(readOnly = true) | ||
public TravelogueResponse findTravelogueById(Long id) { | ||
Travelogue travelogue = travelogueService.getTravelogueById(id); | ||
return getTravelogueResponse(travelogue); | ||
} | ||
|
||
@Transactional(readOnly = true) | ||
public TravelogueResponse findTravelogueById(Long id, MemberAuth member) { | ||
Travelogue travelogue = travelogueService.getTravelogueById(id); | ||
return getTravelogueResponse(travelogue, member); | ||
} | ||
|
||
private TravelogueResponse getTravelogueResponse(Travelogue travelogue) { | ||
List<TagResponse> tagResponses = travelogueTagService.readTagByTravelogue(travelogue); | ||
return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses); | ||
TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogue(travelogue); | ||
return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses, likeResponse); | ||
} | ||
|
||
private TravelogueResponse getTravelogueResponse(Travelogue travelogue, MemberAuth member) { | ||
Member liker = memberService.getById(member.memberId()); | ||
|
||
List<TagResponse> tagResponses = travelogueTagService.readTagByTravelogue(travelogue); | ||
TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, liker); | ||
return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses, likeResponse); | ||
} | ||
|
||
private List<TravelogueDayResponse> findDaysOfTravelogue(Travelogue travelogue) { | ||
|
@@ -125,7 +151,8 @@ public Page<TravelogueSimpleResponse> findSimpleTravelogues(Pageable pageable, T | |
|
||
private TravelogueSimpleResponse getTravelogueSimpleResponse(Travelogue travelogue) { | ||
List<TagResponse> tagResponses = travelogueTagService.readTagByTravelogue(travelogue); | ||
return TravelogueSimpleResponse.of(travelogue, tagResponses); | ||
TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogue(travelogue); | ||
return TravelogueSimpleResponse.of(travelogue, tagResponses, likeResponse); | ||
} | ||
|
||
@Transactional | ||
|
@@ -135,10 +162,11 @@ public TravelogueResponse updateTravelogue(Long id, MemberAuth member, Travelogu | |
|
||
Travelogue updatedTravelogue = travelogueService.update(id, author, request); | ||
List<TagResponse> tags = travelogueTagService.updateTravelogueTags(travelogue, request.tags()); | ||
TravelogueLikeResponse like = travelogueLikeService.findLikeByTravelogueAndLiker(travelogue, author); | ||
|
||
clearTravelogueContents(travelogue); | ||
|
||
return TravelogueResponse.of(updatedTravelogue, createDays(request.days(), updatedTravelogue), tags); | ||
return TravelogueResponse.of(updatedTravelogue, createDays(request.days(), updatedTravelogue), tags, like); | ||
} | ||
|
||
private void clearTravelogueContents(Travelogue travelogue) { | ||
|
@@ -156,4 +184,12 @@ public void deleteTravelogueById(Long id, MemberAuth member) { | |
clearTravelogueContents(travelogue); | ||
travelogueService.delete(travelogue, author); | ||
} | ||
|
||
@Transactional | ||
public TravelogueLikeResponse unlikeTravelogue(Long travelogueId, MemberAuth member) { | ||
Travelogue travelogue = travelogueService.getTravelogueById(travelogueId); | ||
Member liker = memberService.getById(member.memberId()); | ||
|
||
return travelogueLikeService.unlikeTravelogue(travelogue, liker); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
화이트 리스트에 포함된 요청이더라도, 액세스 토큰이 존재한다면
JwtAuthFilter
를 거치도록 구현하였습니다.여행기 상세 조회 시에만 따로 헤더를 체크하는 것 보다, 전체적인 통일성을 주는 것이 낫겠다고 생각했습니다.
하지만 이 방식의 문제점은 유효하지 않은 토큰을 보낼 때 문제가 됩니다.
이전에 클로버가 구현한 방식대로 58번 라인의 try-catch에서 화이트 리스트에 포함되는지 확인하는 것이 좋을까요?
아니면 유효하지 않은 토큰을 보낸 것 자체를 잘못된 요청으로 봐야 할까요?
전체적으로 의견 주시면 감사하겠습니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여행기 상세 조회 시에는 헤더를 체크하지 않는 것으로 알고 있는데 여행 계획을 말씀하신 거겠죠?
그리고 이전의 구현에서 58번 라인의 try-catch에서 화이트 리스트에 포함되어 있는지 확인하는 코드가 없는 것으로 확인했는데 어떤 것을 말씀하시는 건지 잘 모르겠습니다.
화이트리스트 체크는
shouldNotFilter
에서만 진행하고 있었고 이러한 구현에 위화감이 없다는 생각이었는데shouldNotFilter
의 조건을 화이트리스트 체크와isTokenBlank(token)
으로 바꾼 이유 좀 더 자세히 들을 수 있을까요?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Authorization
헤더가 있다면 WhiteList에 있더라도 검증을 시도하는 것으로 이해했는데 맞나용?좋아요 여부 조회 때문에 요렇게 구현하신 것 같은데 일단 최선의 방법 같습니다.
근데 이것도 저번에 말했던
비로그인해도 문제 없어야 되는 동작에서 토큰 만료 오류가 발생한다
는 문제점이 해결될 것 같진 않은데 요 부분은 어떻게 해결하실 생각이신가용? 의견이 궁금합니다There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
여행기 상세 조회 시, 사용자가 게시글에 좋아요 여부를 같이 줘야 합니다! 그래야 빨간색 하트로 채울지 말지 결정할 수 있습니닷!!
따라서 로그인 한 경우에는 헤더를 체크해서 사용자 정보를 가져와야 하는데, 그러면서도 로그인 하지 않은 사용자도 정상 접속 할 수 있어야 합니다.
따라서 위와 같은 방식으로 구현하게 되었습니다..!
요건 제가 설명을 잘 못한것 같네요 ㅠㅠ 현재 그렇게 구현되어 있다는 것이 아니고, 그렇게 구현하면 어떨지 여쭈어 보는 것이었습니닷!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 부분만 해결 되면 approve 주세욥 @Libienz !!
@eunjungL
조금 고민해 보았는데, 인증과 인가에 대한 역할을 분리하는 것은 어떻게 생각하시나요?
즉 2개의 필터를 운용하는 것이지요!
제가 생각하는 각 필터의 역할은 다음과 같습니다.
인증에 대한 필터는 모든 요청에 대해 액세스 토큰으로부터 멤버를 추출하는 역할만 합니다.
만약 토큰이 잘못되면 에러를 응답시키는 것이 아니라, request에 attribute를 넣지 않고 다음으로 넘어갑니다.
즉, 사용자의 신원을 확인하는 인증에 대한 책임만 가집니다.
그러면 그 다음에 인가에 대한 필터가 적용 되어서 request에 attribute가 있는지 확인합니다.
그리고 이에 따라 접근을 허용할지 말지 결정합니다. 즉 인가 필터에서 화이트 리스트를 다룹니다.
이렇게 하면 나중에 admin과 같은 추가 role이 들어와도 인가에 대한 필터에서 처리하면 됩니다.
저희가 저번에 대화를 나누면서도 모호했던 것이
화이트 리스트 자체가 인증이 아닌가?
였습니다.따라서 이를 분리하면 어떨까 생각되었습니다.
객체지향 관점에서도 인증/인가에 대한 역할을 분리하는 것이 좋다고 생각되는데 의견 주시면 감사하겠습니다.