Skip to content

Commit

Permalink
[Feature] - 좋아요 기능 구현 (woowacourse-teams#336)
Browse files Browse the repository at this point in the history
* feat: 여행기 좋아요 기능 구현

* style: swagger 메시지 수정

* feat: `@Transactional` 추가

* feat: 여행기와 좋아요를 누른 사용자에 대해 unique 제약 조건 추가

* fix: 컬럼명에 id 누락된 부분 추가

* feat: 여행기 좋아요 취소 기능 구현

* feat: 액세스 토큰이 존재하는 경우, 화이트 리스트의 요청도 `JwtAuthFilter`를 거치도록 변경

* feat: 여행기 상세 조회 시 좋아요 수, 좋아요 여부도 같이 응답하도록 변경

* feat: 메인 페이지에서 여행기 조회 시 좋아요 개수도 같이 응답하도록 변경

* refactor: JwtAuthFilter 메소드 분리

* docs: Swagger 응답 description 수정

* style: 클래스 첫 빈 줄 추가

* refactor: 좋아요 취소에 대한 단어를 전체적으로 `unlike`로 통일

* test: 401 예외 확인 테스트에서 메시지도 검증도 추가

* style: `.`이 하나만 존재할 때 줄바꿈 하지 않도록 컨벤션에 맞게 수정
  • Loading branch information
nak-honest authored and hangillee committed Aug 20, 2024
1 parent af47c69 commit f418f4e
Show file tree
Hide file tree
Showing 14 changed files with 542 additions and 15 deletions.
17 changes: 13 additions & 4 deletions backend/src/main/java/kr/touroot/global/auth/JwtAuthFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public class JwtAuthFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if (token == null || token.isBlank()) {
if (isTokenBlank(token)) {
sendUnauthorizedResponse(response, "로그인을 해주세요.");
return;
}
Expand Down Expand Up @@ -79,12 +79,21 @@ private void sendUnauthorizedResponse(HttpServletResponse response, String messa

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
AntPathMatcher antPathMatcher = new AntPathMatcher();

String url = request.getRequestURI();
String method = request.getMethod();
String requestURI = request.getRequestURI();
String token = request.getHeader(HttpHeaders.AUTHORIZATION);

return isInWhiteList(method, requestURI) && isTokenBlank(token);
}

private boolean isInWhiteList(String method, String url) {
AntPathMatcher antPathMatcher = new AntPathMatcher();

return WHITE_LIST.stream()
.anyMatch(white -> white.method().matches(method) && antPathMatcher.match(white.urlPattern(), url));
}

private boolean isTokenBlank(String token) {
return token == null || token.isBlank();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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));
}
}
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"})})
@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);
}
}
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
Expand Up @@ -26,10 +26,18 @@ public record TravelogueResponse(
@Schema(description = "여행기 태그")
List<TagResponse> tags,
@Schema(description = "여행기 일자 목록")
List<TravelogueDayResponse> days
List<TravelogueDayResponse> days,
@Schema(description = "여행기 좋아요 숫자", example = "10")
Long likeCount,
@Schema(description = "사용자의 여행기 좋아요 여부", example = "true")
Boolean isLiked
) {

public static TravelogueResponse of(Travelogue travelogue, List<TravelogueDayResponse> days, List<TagResponse> tags) {
public static TravelogueResponse of(
Travelogue travelogue,
List<TravelogueDayResponse> days,
List<TagResponse> tags,
TravelogueLikeResponse like) {
return TravelogueResponse.builder()
.id(travelogue.getId())
.createdAt(travelogue.getCreatedAt().toLocalDate())
Expand All @@ -40,6 +48,8 @@ public static TravelogueResponse of(Travelogue travelogue, List<TravelogueDayRes
.thumbnail(travelogue.getThumbnail())
.days(days)
.tags(tags)
.likeCount(like.likeCount())
.isLiked(like.isLiked())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,23 @@ public record TravelogueSimpleResponse(
@Schema(description = "작성자 프로필 사진 URL", example = "https://dev.touroot.kr/images/profile.png")
String authorProfileUrl,
@Schema(description = "여행기 태그 목록")
List<TagResponse> tags
List<TagResponse> tags,
@Schema(description = "작성자 프로필 사진 URL", example = "10")
Long likeCount
) {

public static TravelogueSimpleResponse of(Travelogue travelogue, List<TagResponse> tags) {
public static TravelogueSimpleResponse of(
Travelogue travelogue,
List<TagResponse> tags,
TravelogueLikeResponse like) {
return TravelogueSimpleResponse.builder()
.id(travelogue.getId())
.title(travelogue.getTitle())
.thumbnail(travelogue.getThumbnail())
.authorNickname(travelogue.getAuthorNickname())
.authorProfileUrl(travelogue.getAuthorProfileImageUrl())
.tags(tags)
.likeCount(like.likeCount())
.build();
}
}
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);

boolean existsByTravelogueAndLiker(Travelogue travelogue, Member liker);

void deleteByTravelogueAndLiker(Travelogue travelogue, Member liker);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
return TravelogueResponse.of(travelogue, createDays(request.days(), travelogue), tags, like);
}

private List<TravelogueDayResponse> createDays(List<TravelogueDayRequest> requests, Travelogue travelogue) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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);
}
}
Loading

0 comments on commit f418f4e

Please sign in to comment.