diff --git a/backend/src/main/java/kr/touroot/member/controller/MyPageController.java b/backend/src/main/java/kr/touroot/member/controller/MyPageController.java index d1ff6b98..6ea08a83 100644 --- a/backend/src/main/java/kr/touroot/member/controller/MyPageController.java +++ b/backend/src/main/java/kr/touroot/member/controller/MyPageController.java @@ -12,6 +12,7 @@ import kr.touroot.global.auth.dto.MemberAuth; import kr.touroot.global.exception.dto.ExceptionResponse; import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.member.dto.response.MyLikeTravelogueResponse; import kr.touroot.member.dto.response.MyTravelogueResponse; import kr.touroot.member.dto.response.ProfileResponse; import kr.touroot.member.service.MyPageFacadeService; @@ -103,6 +104,30 @@ public ResponseEntity> readTravelPlans( return ResponseEntity.ok(data); } + @Operation(summary = "내가 좋아요 한 여행기 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "내가 좋아요 한 여행기 조회에 성공했을 때" + ), + @ApiResponse( + responseCode = "401", + description = "로그인하지 않은 사용자가 조회를 시도할 때", + content = @Content(schema = @Schema(implementation = ExceptionResponse.class)) + ) + }) + @PageableAsQueryParam + @GetMapping("/likes") + public ResponseEntity> readLikes( + @NotNull MemberAuth memberAuth, + @Parameter(hidden = true) + @PageableDefault(size = 5, sort = "id", direction = Sort.Direction.DESC) + Pageable pageable + ) { + Page data = myPageFacadeService.readLikes(memberAuth, pageable); + return ResponseEntity.ok(data); + } + @Operation(summary = "나의 프로필 정보 수정") @ApiResponses(value = { @ApiResponse( diff --git a/backend/src/main/java/kr/touroot/member/dto/response/MyLikeTravelogueResponse.java b/backend/src/main/java/kr/touroot/member/dto/response/MyLikeTravelogueResponse.java new file mode 100644 index 00000000..a3840e0e --- /dev/null +++ b/backend/src/main/java/kr/touroot/member/dto/response/MyLikeTravelogueResponse.java @@ -0,0 +1,31 @@ +package kr.touroot.member.dto.response; + +import java.time.format.DateTimeFormatter; +import kr.touroot.travelogue.domain.Travelogue; +import lombok.Builder; + +@Builder +public record MyLikeTravelogueResponse( + long id, + String title, + String thumbnailUrl, + String createdAt, + String authorName, + String authorProfileImageUrl +) { + + public static MyLikeTravelogueResponse from(Travelogue travelogue) { + String createdAt = travelogue.getCreatedAt() + .toLocalDate() + .format(DateTimeFormatter.ofPattern("yyyy.MM.dd")); + + return MyLikeTravelogueResponse.builder() + .id(travelogue.getId()) + .title(travelogue.getTitle()) + .thumbnailUrl(travelogue.getThumbnail()) + .createdAt(createdAt) + .authorName(travelogue.getAuthorNickname()) + .authorProfileImageUrl(travelogue.getAuthorProfileImageUrl()) + .build(); + } +} diff --git a/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java b/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java index f495395f..5644377c 100644 --- a/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java +++ b/backend/src/main/java/kr/touroot/member/service/MyPageFacadeService.java @@ -3,9 +3,12 @@ import kr.touroot.global.auth.dto.MemberAuth; import kr.touroot.member.domain.Member; import kr.touroot.member.dto.request.ProfileUpdateRequest; +import kr.touroot.member.dto.response.MyLikeTravelogueResponse; import kr.touroot.member.dto.response.MyTravelogueResponse; import kr.touroot.member.dto.response.ProfileResponse; import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueLike; +import kr.touroot.travelogue.service.TravelogueLikeService; import kr.touroot.travelogue.service.TravelogueService; import kr.touroot.travelplan.domain.TravelPlan; import kr.touroot.travelplan.dto.response.PlanResponse; @@ -23,6 +26,7 @@ public class MyPageFacadeService { private final MemberService memberService; private final TravelogueService travelogueService; private final TravelPlanService travelPlanService; + private final TravelogueLikeService travelogueLikeService; @Transactional(readOnly = true) public ProfileResponse readProfile(MemberAuth memberAuth) { @@ -46,6 +50,15 @@ public Page readTravelPlans(MemberAuth memberAuth, Pageable pageab return travelPlans.map(PlanResponse::from); } + @Transactional(readOnly = true) + public Page readLikes(MemberAuth memberAuth, Pageable pageable) { + Member member = memberService.getMemberById(memberAuth.memberId()); + + return travelogueLikeService.findByLiker(member, pageable) + .map(TravelogueLike::getTravelogue) + .map(MyLikeTravelogueResponse::from); + } + @Transactional public ProfileResponse updateProfile(ProfileUpdateRequest request, MemberAuth memberAuth) { return memberService.updateProfile(request, memberAuth); diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueCountry.java b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueCountry.java new file mode 100644 index 00000000..503e0b60 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/TravelogueCountry.java @@ -0,0 +1,47 @@ +package kr.touroot.travelogue.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +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 kr.touroot.travelogue.domain.search.CountryCode; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@EqualsAndHashCode(of = "id", callSuper = false) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Entity +public class TravelogueCountry { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Travelogue travelogue; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private CountryCode countryCode; + + @Column(nullable = false) + private Integer count; + + public TravelogueCountry(Travelogue travelogue, CountryCode countryCode, Integer count) { + this.travelogue = travelogue; + this.countryCode = countryCode; + this.count = count; + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/search/CountryCode.java b/backend/src/main/java/kr/touroot/travelogue/domain/search/CountryCode.java new file mode 100644 index 00000000..6ec439a9 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/domain/search/CountryCode.java @@ -0,0 +1,261 @@ +package kr.touroot.travelogue.domain.search; + +import java.util.Arrays; +import java.util.Set; +import kr.touroot.global.exception.BadRequestException; + +public enum CountryCode { + + AF(Set.of("아프가니스탄")), + AL(Set.of("알바니아")), + DZ(Set.of("알제리")), + AS(Set.of("아메리칸 사모아")), + AD(Set.of("안도라")), + AO(Set.of("앙골라")), + AI(Set.of("앵귈라")), + AQ(Set.of("남극")), + AG(Set.of("앤티가 바부다")), + AR(Set.of("아르헨티나")), + AM(Set.of("아르메니아")), + AW(Set.of("아루바")), + AU(Set.of("호주")), + AT(Set.of("오스트리아")), + AZ(Set.of("아제르바이잔")), + BS(Set.of("바하마")), + BH(Set.of("바레인")), + BD(Set.of("방글라데시")), + BB(Set.of("바베이도스")), + BY(Set.of("벨라루스")), + BE(Set.of("벨기에")), + BZ(Set.of("벨리즈")), + BJ(Set.of("베냉")), + BM(Set.of("버뮤다")), + BT(Set.of("부탄")), + BO(Set.of("볼리비아")), + BA(Set.of("보스니아 헤르체고비나")), + BW(Set.of("보츠와나")), + BV(Set.of("부베섬")), + BR(Set.of("브라질")), + IO(Set.of("영국령 인도양 지역")), + VG(Set.of("영국령 버진 아일랜드")), + BN(Set.of("브루나이")), + BG(Set.of("불가리아")), + BF(Set.of("부르키나파소")), + BI(Set.of("부룬디")), + KH(Set.of("캄보디아")), + CM(Set.of("카메룬")), + CA(Set.of("캐나다")), + CV(Set.of("카보베르데")), + KY(Set.of("케이맨 제도")), + CF(Set.of("중앙아프리카 공화국")), + TD(Set.of("차드")), + CL(Set.of("칠레")), + CN(Set.of("중국")), + CX(Set.of("크리스마스 섬")), + CC(Set.of("코코스 제도")), + CO(Set.of("콜롬비아")), + KM(Set.of("코모로")), + CD(Set.of("콩고 민주공화국")), + CG(Set.of("콩고 공화국")), + CK(Set.of("쿡 제도")), + CR(Set.of("코스타리카")), + CI(Set.of("코트디부아르")), + CU(Set.of("쿠바")), + CY(Set.of("키프로스")), + CZ(Set.of("체코")), + DK(Set.of("덴마크")), + DJ(Set.of("지부티")), + DM(Set.of("도미니카")), + DO(Set.of("도미니카 공화국")), + EC(Set.of("에콰도르")), + EG(Set.of("이집트")), + SV(Set.of("엘살바도르")), + GQ(Set.of("적도기니")), + ER(Set.of("에리트레아")), + EE(Set.of("에스토니아")), + ET(Set.of("에티오피아")), + FO(Set.of("페로 제도")), + FK(Set.of("포클랜드 제도")), + FJ(Set.of("피지")), + FI(Set.of("핀란드")), + FR(Set.of("프랑스")), + GF(Set.of("프랑스령 기아나")), + PF(Set.of("프랑스령 폴리네시아")), + TF(Set.of("프랑스령 남부 지역")), + GA(Set.of("가봉")), + GM(Set.of("감비아")), + GE(Set.of("조지아")), + DE(Set.of("독일")), + GH(Set.of("가나")), + GI(Set.of("지브롤터")), + GR(Set.of("그리스")), + GL(Set.of("그린란드")), + GD(Set.of("그레나다")), + GP(Set.of("과들루프")), + GU(Set.of("괌")), + GT(Set.of("과테말라")), + GN(Set.of("기니")), + GW(Set.of("기니비사우")), + GY(Set.of("가이아나")), + HT(Set.of("아이티")), + HM(Set.of("허드 맥도널드 제도")), + VA(Set.of("바티칸")), + HN(Set.of("온두라스")), + HK(Set.of("홍콩")), + HR(Set.of("크로아티아")), + HU(Set.of("헝가리")), + IS(Set.of("아이슬란드")), + IN(Set.of("인도")), + ID(Set.of("인도네시아")), + IR(Set.of("이란")), + IQ(Set.of("이라크")), + IE(Set.of("아일랜드")), + IL(Set.of("이스라엘")), + IT(Set.of("이탈리아")), + JM(Set.of("자메이카")), + JP(Set.of("일본")), + JO(Set.of("요르단")), + KZ(Set.of("카자흐스탄")), + KE(Set.of("케냐")), + KI(Set.of("키리바시")), + KP(Set.of("북한", "조선민주주의인민공화국")), + KR(Set.of("대한민국", "한국")), + KW(Set.of("쿠웨이트")), + KG(Set.of("키르기스스탄")), + LA(Set.of("라오스")), + LV(Set.of("라트비아")), + LB(Set.of("레바논")), + LS(Set.of("레소토")), + LR(Set.of("라이베리아")), + LY(Set.of("리비아")), + LI(Set.of("리히텐슈타인")), + LT(Set.of("리투아니아")), + LU(Set.of("룩셈부르크")), + MO(Set.of("마카오")), + MK(Set.of("북마케도니아")), + MG(Set.of("마다가스카르")), + MW(Set.of("말라위")), + MY(Set.of("말레이시아")), + MV(Set.of("몰디브")), + ML(Set.of("말리")), + MT(Set.of("몰타")), + MH(Set.of("마셜제도")), + MQ(Set.of("마르티니크")), + MR(Set.of("모리타니")), + MU(Set.of("모리셔스")), + YT(Set.of("마요트")), + MX(Set.of("멕시코")), + FM(Set.of("미크로네시아")), + MD(Set.of("몰도바")), + MC(Set.of("모나코")), + MN(Set.of("몽골")), + MS(Set.of("몬트세랫")), + MA(Set.of("모로코")), + MZ(Set.of("모잠비크")), + MM(Set.of("미얀마")), + NA(Set.of("나미비아")), + NR(Set.of("나우루")), + NP(Set.of("네팔")), + AN(Set.of("네덜란드 안틸레스")), + NL(Set.of("네덜란드")), + NC(Set.of("뉴칼레도니아")), + NZ(Set.of("뉴질랜드")), + NI(Set.of("니카라과")), + NE(Set.of("니제르")), + NG(Set.of("나이지리아")), + NU(Set.of("니우에")), + NF(Set.of("노퍽 섬")), + MP(Set.of("북마리아나 제도")), + NO(Set.of("노르웨이")), + OM(Set.of("오만")), + PK(Set.of("파키스탄")), + PW(Set.of("팔라우")), + PS(Set.of("팔레스타인")), + PA(Set.of("파나마")), + PG(Set.of("파푸아 뉴기니")), + PY(Set.of("파라과이")), + PE(Set.of("페루")), + PH(Set.of("필리핀")), + PN(Set.of("핏케언 제도")), + PL(Set.of("폴란드")), + PT(Set.of("포르투갈")), + PR(Set.of("푸에르토리코")), + QA(Set.of("카타르")), + RE(Set.of("레위니옹")), + RO(Set.of("루마니아")), + RU(Set.of("러시아")), + RW(Set.of("르완다")), + SH(Set.of("세인트헬레나")), + KN(Set.of("세인트키츠 네비스")), + LC(Set.of("세인트루시아")), + PM(Set.of("세인트피에르 미클롱")), + VC(Set.of("세인트빈센트 그레나딘")), + WS(Set.of("사모아")), + SM(Set.of("산마리노")), + ST(Set.of("상투메 프린시페")), + SA(Set.of("사우디아라비아")), + SN(Set.of("세네갈")), + CS(Set.of("세르비아 몬테네그로")), + SC(Set.of("세이셸")), + SL(Set.of("시에라리온")), + SG(Set.of("싱가포르")), + SK(Set.of("슬로바키아")), + SI(Set.of("슬로베니아")), + SB(Set.of("솔로몬 제도")), + SO(Set.of("소말리아")), + ZA(Set.of("남아프리카 공화국", "남아공")), + GS(Set.of("사우스조지아 사우스샌드위치 제도")), + ES(Set.of("스페인", "에스파냐")), + LK(Set.of("스리랑카")), + SD(Set.of("수단")), + SR(Set.of("수리남")), + SJ(Set.of("스발바르 얀마웬")), + SZ(Set.of("스와질란드", "에스와티니")), + SE(Set.of("스웨덴")), + CH(Set.of("스위스")), + SY(Set.of("시리아")), + TW(Set.of("대만", "타이완")), + TJ(Set.of("타지키스탄")), + TZ(Set.of("탄자니아")), + TH(Set.of("태국")), + TL(Set.of("동티모르")), + TG(Set.of("토고")), + TK(Set.of("토켈라우")), + TO(Set.of("통가")), + TT(Set.of("트리니다드 토바고")), + TN(Set.of("튀니지")), + TR(Set.of("터키", "튀르키예")), + TM(Set.of("투르크메니스탄")), + TC(Set.of("터크스 케이커스 제도")), + TV(Set.of("투발루")), + VI(Set.of("미국령 버진 아일랜드")), + UG(Set.of("우간다")), + UA(Set.of("우크라이나")), + AE(Set.of("아랍에미리트")), + GB(Set.of("영국")), + UM(Set.of("미국령 외곽 소섬")), + US(Set.of("미국")), + UY(Set.of("우루과이")), + UZ(Set.of("우즈베키스탄")), + VU(Set.of("바누아투")), + VE(Set.of("베네수엘라")), + VN(Set.of("베트남")), + WF(Set.of("왈리스 푸투나")), + EH(Set.of("서사하라")), + YE(Set.of("예멘")), + ZM(Set.of("잠비아")), + ZW(Set.of("짐바브웨")); + + private final Set names; + + CountryCode(Set names) { + this.names = names; + } + + public static CountryCode findByName(String name) { + return Arrays.stream(values()) + .filter(code -> code.names.contains(name)) + .findFirst() + .orElseThrow(() -> new BadRequestException("국가 이름을 찾을 수 없습니다.")); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/domain/search/SearchType.java b/backend/src/main/java/kr/touroot/travelogue/domain/search/SearchType.java index 299cbd76..804bc632 100644 --- a/backend/src/main/java/kr/touroot/travelogue/domain/search/SearchType.java +++ b/backend/src/main/java/kr/touroot/travelogue/domain/search/SearchType.java @@ -3,7 +3,7 @@ import java.util.Arrays; public enum SearchType { - TITLE, AUTHOR; + TITLE, AUTHOR, COUNTRY; public static SearchType from(String searchType) { return Arrays.stream(SearchType.values()) diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java index ad48e8fe..8bd4c898 100644 --- a/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TraveloguePlaceRequest.java @@ -26,7 +26,10 @@ public record TraveloguePlaceRequest( @NotNull(message = "여행기 장소 사진은 null일 수 없습니다.") @Size(message = "여행기 장소 사진은 최대 10개입니다.", max = 10) @Valid - List photoUrls + List photoUrls, + @Schema(description = "여행기 장소 국가 코드") + @NotBlank(message = "여행기 장소 국가 코드는 비어있을 수 없습니다.") + String countryCode ) { public TraveloguePlace toTraveloguePlace(int order, TravelogueDay travelogueDay) { diff --git a/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java index 9017c06c..c03c6003 100644 --- a/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java +++ b/backend/src/main/java/kr/touroot/travelogue/dto/request/TravelogueSearchRequest.java @@ -9,7 +9,7 @@ public record TravelogueSearchRequest( @NotBlank(message = "검색어는 2글자 이상이어야 합니다.") @Size(min = 2, message = "검색어는 2글자 이상이어야 합니다.") String keyword, - @Schema(description = "검색 키워드 종류 (TITLE, AUTHOR)", example = "TITLE") + @Schema(description = "검색 키워드 종류 (TITLE, AUTHOR, COUNTRY)", example = "TITLE") @NotBlank(message = "검색 키워드 종류는 필수입니다.") String searchType ) { diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueCountryRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueCountryRepository.java new file mode 100644 index 00000000..4a420ecb --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueCountryRepository.java @@ -0,0 +1,15 @@ +package kr.touroot.travelogue.repository; + +import java.util.List; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueCountry; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TravelogueCountryRepository extends JpaRepository { + + List findAllByTravelogue(Travelogue travelogue); + + void deleteAllByTravelogue(Travelogue travelogue); +} diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java index 52df6652..31aa2f1a 100644 --- a/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java +++ b/backend/src/main/java/kr/touroot/travelogue/repository/TravelogueLikeRepository.java @@ -3,10 +3,14 @@ import kr.touroot.member.domain.Member; import kr.touroot.travelogue.domain.Travelogue; import kr.touroot.travelogue.domain.TravelogueLike; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface TravelogueLikeRepository extends JpaRepository { + Page findAllByLiker(Member liker, Pageable pageable); + boolean existsByTravelogueAndLiker(Travelogue travelogue, Member liker); void deleteAllByTravelogue(Travelogue travelogue); diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java index 7d4eae28..853c9ca6 100644 --- a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepository.java @@ -2,6 +2,7 @@ import kr.touroot.travelogue.domain.Travelogue; import kr.touroot.travelogue.domain.TravelogueFilterCondition; +import kr.touroot.travelogue.domain.search.CountryCode; import kr.touroot.travelogue.domain.search.SearchCondition; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -10,5 +11,7 @@ public interface TravelogueQueryRepository { Page findByKeywordAndSearchType(SearchCondition condition, Pageable pageable); + Page findByKeywordAndCountryCode(CountryCode countryCode, Pageable pageable); + Page findAllByFilter(TravelogueFilterCondition filter, Pageable pageable); } diff --git a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java index 377fd2e5..d47be6d5 100644 --- a/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java +++ b/backend/src/main/java/kr/touroot/travelogue/repository/query/TravelogueQueryRepositoryImpl.java @@ -1,17 +1,19 @@ package kr.touroot.travelogue.repository.query; import static kr.touroot.travelogue.domain.QTravelogue.travelogue; +import static kr.touroot.travelogue.domain.QTravelogueCountry.travelogueCountry; import static kr.touroot.travelogue.domain.QTravelogueTag.travelogueTag; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import kr.touroot.travelogue.domain.Travelogue; import kr.touroot.travelogue.domain.TravelogueFilterCondition; -import com.querydsl.core.types.dsl.StringPath; +import kr.touroot.travelogue.domain.search.CountryCode; import kr.touroot.travelogue.domain.search.SearchCondition; import kr.touroot.travelogue.domain.search.SearchType; import lombok.RequiredArgsConstructor; @@ -45,6 +47,21 @@ public Page findByKeywordAndSearchType(SearchCondition condition, Pa return new PageImpl<>(results, pageable, results.size()); } + @Override + public Page findByKeywordAndCountryCode(CountryCode countryCode, Pageable pageable) { + List results = jpaQueryFactory.select(travelogue) + .from(travelogue) + .join(travelogueCountry) + .on(travelogue.id.eq(travelogueCountry.travelogue.id)) + .where(travelogueCountry.countryCode.eq(countryCode)) + .orderBy(travelogueCountry.count.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(results, pageable, results.size()); + } + private StringPath getTargetField(SearchType searchType) { if (SearchType.AUTHOR.equals(searchType)) { return travelogue.author.nickname; diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueCountryService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueCountryService.java new file mode 100644 index 00000000..62b6de82 --- /dev/null +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueCountryService.java @@ -0,0 +1,52 @@ +package kr.touroot.travelogue.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueCountry; +import kr.touroot.travelogue.domain.search.CountryCode; +import kr.touroot.travelogue.dto.request.TravelogueRequest; +import kr.touroot.travelogue.repository.TravelogueCountryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class TravelogueCountryService { + + private final TravelogueCountryRepository travelogueCountryRepository; + + @Transactional(readOnly = true) + public List readCountryByTravelogue(Travelogue travelogue) { + return travelogueCountryRepository.findAllByTravelogue(travelogue); + } + + @Transactional + public void createTravelogueCountries(Travelogue travelogue, TravelogueRequest request) { + Map countryCounts = countCountries(request); + + countryCounts.forEach((countryCode, count) -> travelogueCountryRepository.save( + new TravelogueCountry(travelogue, countryCode, count.intValue()))); + } + + private Map countCountries(TravelogueRequest request) { + return request.days().stream() + .flatMap(day -> day.places().stream()) + .map(place -> CountryCode.valueOf(place.countryCode())) + .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); + } + + @Transactional + public void updateTravelogueCountries(Travelogue travelogue, TravelogueRequest request) { + deleteAllByTravelogue(travelogue); + createTravelogueCountries(travelogue, request); + } + + @Transactional + public void deleteAllByTravelogue(Travelogue travelogue) { + travelogueCountryRepository.deleteAllByTravelogue(travelogue); + } +} diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java index 8c919b59..47ab457c 100644 --- a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueFacadeService.java @@ -28,6 +28,7 @@ public class TravelogueFacadeService { private final TravelogueImagePerpetuationService travelogueImagePerpetuationService; private final TravelogueTagService travelogueTagService; private final TravelogueLikeService travelogueLikeService; + private final TravelogueCountryService travelogueCountryService; private final MemberService memberService; @Transactional @@ -36,6 +37,7 @@ public TravelogueCreateResponse createTravelogue(MemberAuth member, TravelogueRe Travelogue travelogue = travelogueService.save(request.toTravelogue(author)); travelogueImagePerpetuationService.copyTravelogueImagesToPermanentStorage(travelogue); travelogueTagService.createTravelogueTags(travelogue, request.tags()); + travelogueCountryService.createTravelogueCountries(travelogue, request); return TravelogueCreateResponse.from(travelogue); } @@ -89,6 +91,7 @@ public TravelogueResponse updateTravelogue(Long id, MemberAuth member, Travelogu Travelogue updated = travelogueService.update(id, author, updateRequest); travelogueImagePerpetuationService.copyTravelogueImagesToPermanentStorage(updated); List travelogueTags = travelogueTagService.updateTravelogueTag(updated, updateRequest.tags()); + travelogueCountryService.updateTravelogueCountries(updated, updateRequest); boolean isLikedFromAccessor = travelogueLikeService.existByTravelogueAndMember(updated, author); return TravelogueResponse.of(updated, travelogueTags, isLikedFromAccessor); @@ -101,6 +104,7 @@ public void deleteTravelogueById(Long id, MemberAuth member) { travelogueTagService.deleteAllByTravelogue(travelogue); travelogueLikeService.deleteAllByTravelogue(travelogue); + travelogueCountryService.deleteAllByTravelogue(travelogue); travelogueService.delete(travelogue, author); } diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java index a5f6eea8..82b193c8 100644 --- a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueLikeService.java @@ -5,6 +5,8 @@ import kr.touroot.travelogue.domain.TravelogueLike; import kr.touroot.travelogue.repository.TravelogueLikeRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,11 @@ public class TravelogueLikeService { private final TravelogueLikeRepository travelogueLikeRepository; + @Transactional(readOnly = true) + public Page findByLiker(Member liker, Pageable pageable) { + return travelogueLikeRepository.findAllByLiker(liker, pageable); + } + @Transactional(readOnly = true) public boolean existByTravelogueAndMember(Travelogue travelogue, Member liker) { return travelogueLikeRepository.existsByTravelogueAndLiker(travelogue, liker); diff --git a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java index 4a42a7ef..eb67a40b 100644 --- a/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java +++ b/backend/src/main/java/kr/touroot/travelogue/service/TravelogueService.java @@ -5,6 +5,7 @@ import kr.touroot.member.domain.Member; import kr.touroot.travelogue.domain.Travelogue; import kr.touroot.travelogue.domain.TravelogueFilterCondition; +import kr.touroot.travelogue.domain.search.CountryCode; import kr.touroot.travelogue.domain.search.SearchCondition; import kr.touroot.travelogue.domain.search.SearchType; import kr.touroot.travelogue.dto.request.TravelogueRequest; @@ -43,11 +44,19 @@ public Page findAllByMember(Member member, Pageable pageable) { @Transactional(readOnly = true) public Page findByKeyword(TravelogueSearchRequest request, Pageable pageable) { SearchType searchType = SearchType.from(request.searchType()); + if (searchType == SearchType.COUNTRY) { + return findByKeywordAndCountryCode(request.keyword(), pageable); + } SearchCondition searchCondition = new SearchCondition(request.keyword(), searchType); return travelogueQueryRepository.findByKeywordAndSearchType(searchCondition, pageable); } + private Page findByKeywordAndCountryCode(String keyword, Pageable pageable) { + CountryCode countryCode = CountryCode.findByName(keyword); + return travelogueQueryRepository.findByKeywordAndCountryCode(countryCode, pageable); + } + @Transactional(readOnly = true) public Page findAllByFilter(TravelogueFilterCondition filter, Pageable pageable) { if (filter.isEmptyCondition()) { diff --git a/backend/src/main/resources/db/migration/mysql/V6__add_travelogue_country.sql b/backend/src/main/resources/db/migration/mysql/V6__add_travelogue_country.sql new file mode 100644 index 00000000..ea760784 --- /dev/null +++ b/backend/src/main/resources/db/migration/mysql/V6__add_travelogue_country.sql @@ -0,0 +1,13 @@ +CREATE TABLE travelogue_country +( + id BIGINT NOT NULL AUTO_INCREMENT, + travelogue_id BIGINT NOT NULL, + country_code VARCHAR(50) NOT NULL, + count INT NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE travelogue_country + ADD CONSTRAINT fk_travelogue_country_travelogue_id FOREIGN KEY (travelogue_id) REFERENCES travelogue (id); + +CREATE INDEX idx_country_code_count ON travelogue_country (country_code, count); diff --git a/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java b/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java index 93051ab4..3676ead7 100644 --- a/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java +++ b/backend/src/test/java/kr/touroot/member/controller/MyPageControllerTest.java @@ -101,6 +101,25 @@ void readTravelPlans() { .body("content.size()", is(2)); } + @DisplayName("마이 페이지 컨트롤러는 내가 좋아요 한 여행기 조회 시 요청이 들어오면 로그인한 사용자의 여행 계획을 조회한다.") + @Test + void readLikeTravelogues() { + // given + travelogueTestHelper.initTravelogueTestDataWithLike(member); + travelogueTestHelper.initTravelogueTestDataWithLike(member); + travelogueTestHelper.initTravelogueTestData(); + + // when & then + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .when().log().all() + .get("/api/v1/member/me/likes") + .then().log().all() + .statusCode(200) + .body("content.size()", is(2)); + } + @DisplayName("마이 페이지 컨트롤러는 내 프로필 수정 요청이 들어오면 로그인한 사용자의 프로필을 수정한다.") @Test void updateProfile() { diff --git a/backend/src/test/java/kr/touroot/travelogue/domain/search/CountryCodeTest.java b/backend/src/test/java/kr/touroot/travelogue/domain/search/CountryCodeTest.java new file mode 100644 index 00000000..6a268d0a --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/domain/search/CountryCodeTest.java @@ -0,0 +1,31 @@ +package kr.touroot.travelogue.domain.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import kr.touroot.global.exception.BadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CountryCodeTest { + + @DisplayName("국가 이름으로 찾으면 국가 코드를 반환한다.") + @ValueSource(strings = {"한국", "대한민국"}) + @ParameterizedTest + void findByName(String name) { + CountryCode code = CountryCode.findByName(name); + + assertThat(code) + .isEqualTo(CountryCode.KR); + } + + @DisplayName("없는 나라 이름으로 찾으면 예외로 처리한다.") + @Test + void findByNonCountryName() { + assertThatThrownBy(() -> CountryCode.findByName("미역국")) + .isInstanceOf(BadRequestException.class) + .hasMessage("국가 이름을 찾을 수 없습니다."); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueCountryFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueCountryFixture.java new file mode 100644 index 00000000..adc7b8d3 --- /dev/null +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueCountryFixture.java @@ -0,0 +1,18 @@ +package kr.touroot.travelogue.fixture; + +import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueCountry; +import kr.touroot.travelogue.domain.search.CountryCode; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum TravelogueCountryFixture { + TRAVELOGUE_COUNTRY(CountryCode.KR, 1L); + + private final CountryCode countryCode; + private final Long count; + + public TravelogueCountry create(Travelogue travelogue) { + return new TravelogueCountry(travelogue, countryCode, count.intValue()); + } +} diff --git a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java index f0b8f887..d38b3bc8 100644 --- a/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java +++ b/backend/src/test/java/kr/touroot/travelogue/fixture/TravelogueRequestFixture.java @@ -54,7 +54,8 @@ public static List getTraveloguePlaceRequests(List getUpdateTraveloguePlaceRequests(List "함덕 해수욕장", getTraveloguePositionRequest(), "에메랄드 빛 해변은 해외 휴양지와 견줘도 밀리지 않습니다.", - photos + photos, + "KR" )); } diff --git a/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java b/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java index ccfd0411..60a157a9 100644 --- a/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java +++ b/backend/src/test/java/kr/touroot/travelogue/helper/TravelogueTestHelper.java @@ -1,5 +1,6 @@ package kr.touroot.travelogue.helper; +import static kr.touroot.travelogue.fixture.TravelogueCountryFixture.TRAVELOGUE_COUNTRY; import static kr.touroot.travelogue.fixture.TravelogueDayFixture.TRAVELOGUE_DAY; import static kr.touroot.travelogue.fixture.TravelogueFixture.TRAVELOGUE; import static kr.touroot.travelogue.fixture.TraveloguePhotoFixture.TRAVELOGUE_PHOTO; @@ -14,11 +15,13 @@ import kr.touroot.tag.fixture.TagFixture; import kr.touroot.tag.repository.TagRepository; import kr.touroot.travelogue.domain.Travelogue; +import kr.touroot.travelogue.domain.TravelogueCountry; import kr.touroot.travelogue.domain.TravelogueDay; import kr.touroot.travelogue.domain.TravelogueLike; import kr.touroot.travelogue.domain.TraveloguePhoto; import kr.touroot.travelogue.domain.TraveloguePlace; import kr.touroot.travelogue.domain.TravelogueTag; +import kr.touroot.travelogue.repository.TravelogueCountryRepository; import kr.touroot.travelogue.repository.TravelogueDayRepository; import kr.touroot.travelogue.repository.TravelogueLikeRepository; import kr.touroot.travelogue.repository.TraveloguePhotoRepository; @@ -39,6 +42,7 @@ public class TravelogueTestHelper { private final TagRepository tagRepository; private final TravelogueTagRepository travelogueTagRepository; private final TravelogueLikeRepository travelogueLikeRepository; + private final TravelogueCountryRepository travelogueCountryRepository; @Autowired public TravelogueTestHelper( @@ -49,7 +53,8 @@ public TravelogueTestHelper( MemberRepository memberRepository, TagRepository tagRepository, TravelogueTagRepository travelogueTagRepository, - TravelogueLikeRepository travelogueLikeRepository + TravelogueLikeRepository travelogueLikeRepository, + TravelogueCountryRepository travelogueCountryRepository ) { this.travelogueRepository = travelogueRepository; this.travelogueDayRepository = travelogueDayRepository; @@ -59,6 +64,7 @@ public TravelogueTestHelper( this.tagRepository = tagRepository; this.travelogueTagRepository = travelogueTagRepository; this.travelogueLikeRepository = travelogueLikeRepository; + this.travelogueCountryRepository = travelogueCountryRepository; } public void initAllTravelogueTestData() { @@ -82,6 +88,7 @@ public Travelogue initTravelogueTestData(Member author) { Travelogue travelogue = persistTravelogue(author); TravelogueDay day = persistTravelogueDay(travelogue); TraveloguePlace place = persistTraveloguePlace(day); + persistTravelogueCountry(travelogue); persistTraveloguePhoto(place); return travelogue; @@ -103,6 +110,7 @@ public Travelogue initTravelogueTestDataWithTag(Member author) { Travelogue travelogue = persistTravelogue(author); TravelogueDay day = persistTravelogueDay(travelogue); TraveloguePlace place = persistTraveloguePlace(day); + persistTravelogueCountry(travelogue); persistTraveloguePhoto(place); persisTravelogueTag(travelogue, TagFixture.TAG_1.get()); @@ -156,6 +164,12 @@ public TraveloguePlace persistTraveloguePlace(TravelogueDay day) { return traveloguePlaceRepository.save(place); } + public TravelogueCountry persistTravelogueCountry(Travelogue travelogue) { + TravelogueCountry travelogueCountry = TRAVELOGUE_COUNTRY.create(travelogue); + + return travelogueCountryRepository.save(travelogueCountry); + } + public TraveloguePhoto persistTraveloguePhoto(TraveloguePlace place) { TraveloguePhoto photo = TRAVELOGUE_PHOTO.create(place); diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java index ba0c8820..99ae5bb9 100644 --- a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueFacadeServiceTest.java @@ -48,6 +48,7 @@ TravelogueImagePerpetuationService.class, TravelogueTagService.class, TravelogueLikeService.class, + TravelogueCountryService.class, MemberService.class, TravelogueTestHelper.class, PasswordEncryptor.class, @@ -197,6 +198,19 @@ void findTraveloguesByAuthorNicknameKeyword() { assertThat(searchResults).containsAll(responses); } + @DisplayName("국가 코드를 기반으로 여행기 목록을 조회한다.") + @Test + void findTraveloguesByCountryCodeKeyword() { + testHelper.initAllTravelogueTestData(); + Page responses = TravelogueResponseFixture.getTravelogueSimpleResponses(); + + TravelogueSearchRequest searchRequest = new TravelogueSearchRequest("한국", "country"); + PageRequest pageRequest = PageRequest.of(0, 5, Sort.by("id")); + Page searchResults = service.findSimpleTravelogues(searchRequest, pageRequest); + + assertThat(searchResults).containsAll(responses); + } + @DisplayName("여행기를 수정할 수 있다.") @Test void updateTravelogue() { diff --git a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java index f1dd162b..50908264 100644 --- a/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java +++ b/backend/src/test/java/kr/touroot/travelogue/service/TravelogueLikeServiceTest.java @@ -12,12 +12,15 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; @DisplayName("여행기 좋아요 서비스") @Import(value = {TravelogueLikeService.class, TravelogueTestHelper.class}) @ServiceTest class TravelogueLikeServiceTest { + public static final int BASIC_PAGE_SIZE = 5; + private final TravelogueLikeService travelogueLikeService; private final DatabaseCleaner databaseCleaner; private final TravelogueTestHelper testHelper; @@ -38,6 +41,20 @@ void setUp() { databaseCleaner.executeTruncate(); } + @DisplayName("특정 멤버가 좋아요 한 여행기를 조회할 수 있다.") + @Test + void findByLiker() { + // given + Member liker = testHelper.initKakaoMemberTestData(); + testHelper.initTravelogueTestDataWithLike(liker); + testHelper.initTravelogueTestDataWithLike(liker); + testHelper.initTravelogueTestData(); + + // when & then + assertThat(travelogueLikeService.findByLiker(liker, Pageable.ofSize(BASIC_PAGE_SIZE))) + .hasSize(2); + } + @DisplayName("특정 여행기에 특정 멤버가 좋아요 했는지 알 수 있다") @Test void existByTravelogueAndMember() {