From c59557c7c7cb9bbe81a7f8aafc962355693b760e Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 29 Aug 2025 06:19:18 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=AA=85=EC=86=8C=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../place/controller/PlaceController.kt | 18 ++++- .../busan/domain/place/domain/PlaceLike.kt | 21 +++-- .../busan/domain/place/domain/PlaceLikeId.kt | 14 ++++ .../domain/place/dto/PlaceResponseDTO.kt | 6 ++ .../place/repository/PlaceLikeRepository.kt | 6 ++ .../place/service/PlaceCommandService.kt | 78 +++++++++++++++++++ .../apiPayload/code/status/ErrorStatus.kt | 1 + 7 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLikeId.kt create mode 100644 src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCommandService.kt diff --git a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt index b2f2f30..eaa090b 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/controller/PlaceController.kt @@ -3,11 +3,13 @@ package busanVibe.busan.domain.place.controller import busanVibe.busan.domain.place.dto.PlaceResponseDTO import busanVibe.busan.domain.place.enums.PlaceType import busanVibe.busan.domain.place.enums.PlaceSortType +import busanVibe.busan.domain.place.service.PlaceCommandService import busanVibe.busan.domain.place.service.PlaceQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -17,7 +19,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/places") class PlaceController( - val placeQueryService: PlaceQueryService + val placeQueryService: PlaceQueryService, + val placeCommandService: PlaceCommandService ) { @GetMapping @@ -27,13 +30,14 @@ class PlaceController( @RequestParam("sort", required = false) sort: PlaceSortType? ): ApiResponse?{ + val placeList = placeQueryService.getPlaceList(type, sort) return ApiResponse.onSuccess(placeList) } @GetMapping("/{placeId}") - @Operation(summary = "명소 상세 조회", + @Operation(summary = "명소 상세 조회 API", description = """ 명소 상세 조회 API 입니다. 타입 구분 없이 API 응답 형태가 동일합니다. @@ -49,4 +53,14 @@ class PlaceController( return ApiResponse.onSuccess(placeDetail) } + @PatchMapping("/like/{placeId}") + @Operation(summary = "명소 좋아요 API", + description = "명소 좋아요 API 입니다. 만약 이미 좋아요를 누른 상태라면 좋아요가 취소됩니다.") + fun like(@PathVariable("placeId") placeId: Long?): ApiResponse{ + val likeDTO = placeCommandService.like(placeId) + return ApiResponse.onSuccess(likeDTO) + } + + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLike.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLike.kt index 1b2715b..5d0b58d 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLike.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLike.kt @@ -1,28 +1,33 @@ package busanVibe.busan.domain.place.domain +import busanVibe.busan.domain.common.BaseEntity import busanVibe.busan.domain.user.data.User +import jakarta.persistence.EmbeddedId 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.MapsId @Entity -class PlaceLike( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long?, +open class PlaceLike( +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// val id: Long?, + + @EmbeddedId + val id: PlaceLikeId, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @MapsId("userId") val user: User, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "place_id") + @MapsId("placeId") val place: Place, -) { +): BaseEntity() { } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLikeId.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLikeId.kt new file mode 100644 index 0000000..96ad4cc --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/PlaceLikeId.kt @@ -0,0 +1,14 @@ +package busanVibe.busan.domain.place.domain + +import jakarta.persistence.Embeddable +import lombok.Getter +import java.io.Serializable + +@Embeddable +@Getter +open class PlaceLikeId( + private val userId: Long, + private val placeId: Long +): Serializable { + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceResponseDTO.kt index ca07e34..3b5c8aa 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/dto/PlaceResponseDTO.kt @@ -120,4 +120,10 @@ class PlaceResponseDTO { // val content: String // ) + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) + data class LikeDto( + val success: Boolean, + val message: String, + ) + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt index 78091ff..ac27e48 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.place.repository import busanVibe.busan.domain.place.domain.Place import busanVibe.busan.domain.place.domain.PlaceLike +import busanVibe.busan.domain.user.data.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -13,4 +14,9 @@ interface PlaceLikeRepository: JpaRepository { @Query("SELECT pl FROM PlaceLike pl WHERE pl.place IN :places") fun findLikeByPlace(@Param("places") placeList: List): List + fun findByPlaceAndUser(@Param("place") place: Place, @Param("user") user: User): PlaceLike? + + fun deleteByPlaceAndUser(@Param("place") place: Place, @Param("user") user: User) + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCommandService.kt new file mode 100644 index 0000000..4d227b4 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/place/service/PlaceCommandService.kt @@ -0,0 +1,78 @@ +package busanVibe.busan.domain.place.service + +import busanVibe.busan.domain.place.domain.Place +import busanVibe.busan.domain.place.domain.PlaceLike +import busanVibe.busan.domain.place.domain.PlaceLikeId +import busanVibe.busan.domain.place.dto.PlaceResponseDTO +import busanVibe.busan.domain.place.repository.PlaceLikeRepository +import busanVibe.busan.domain.place.repository.PlaceRepository +import busanVibe.busan.domain.user.data.User +import busanVibe.busan.domain.user.service.login.AuthService +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class PlaceCommandService( + private val placeRepository: PlaceRepository, + private val placeLikeRepository: PlaceLikeRepository, +) { + + @Transactional + fun like(placeId: Long?): PlaceResponseDTO.LikeDto{ + + // null 처리 + if (placeId == null) { + throw ExceptionHandler(ErrorStatus.PLACE_ID_REQUIRED) + } + + // 필요한 객체 생성 + val currentUser = AuthService().getCurrentUser() + val place: Place= placeRepository.findById(placeId).orElseThrow{ ExceptionHandler(ErrorStatus.PLACE_NOT_FOUND) } + var message: String + + // 좋아요 정보 조회 + val placeLike = placeLikeRepository.findByPlaceAndUser(place, currentUser) + + // 기존 좋아요 여부에 따른 처리 + if (placeLike != null) { // 이미 좋아요 누른 경우 ( 데이터 조회에 따른 중복 감지 ) + cancelLike(place, currentUser) // 좋아요 취소 + message = "좋아요 취소 성공" + }else{ // 이전에 좋아요를 누르지 않은 경우 + + val placeLikeId = PlaceLikeId(userId = currentUser.id!!, placeId = placeId) // ID 객체 생성 ( 복합키 ) + + var placeLike: PlaceLike // 객체 미리 선언 + + try{ + placeLike = PlaceLike(id= placeLikeId, user = currentUser, place = place) + placeLikeRepository.save(placeLike) // 좋아요 저장 + message = "좋아요 성공" + }catch (e: DataIntegrityViolationException){ + // 동시성 문제 생각하여 2차로 중복 처리 - 복합키 조회하여 중복 조회 + cancelLike(place, currentUser) // 좋아요 취소 + message = "좋아요 취소 성공" + } + } + + // DTO 생성 및 반환 + return PlaceResponseDTO.LikeDto( + success = true, + message = message + ) + + } + + // 좋아요 취소 처리 + private fun cancelLike(place: Place, user: User){ + try { + placeLikeRepository.deleteByPlaceAndUser(place, user) + } catch (e: Exception){ + throw RuntimeException(e.message, e) + } + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt index d369747..73dc101 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt @@ -28,6 +28,7 @@ enum class ErrorStatus( // 명소 관련 에러 PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "PLACE4004", "명소를 찾을 수 없습니다."), + PLACE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "PLACE4005", "placeId가 필요합니다."), // 축제 관련 에러 FESTIVAL_NOT_FOUND(HttpStatus.NOT_FOUND, "FESTIVAL4004", "축제를 찾을 수 없습니다."), From 671ad7534084f69dfc30ac804c736b9e07241fb4 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 30 Aug 2025 04:49:45 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A7=80=EC=97=AD=EC=B6=95?= =?UTF-8?q?=EC=A0=9C=20=EC=A2=8B=EC=95=84=EC=9A=94=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festival/controller/FestivalController.kt | 14 +++- .../domain/festival/domain/FestivalLike.kt | 11 ++- .../domain/festival/domain/FestivalLikeId.kt | 15 ++++ .../festival/dto/FestivalLikeResponseDTO.kt | 12 +++ .../festival/dto/FestivalListResponseDTO.kt | 2 + .../repository/FestivalLikesRepository.kt | 8 +- .../service/FestivalCommandService.kt | 78 +++++++++++++++++++ .../place/repository/PlaceLikeRepository.kt | 1 - .../apiPayload/code/status/ErrorStatus.kt | 1 + 9 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLikeId.kt create mode 100644 src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalLikeResponseDTO.kt create mode 100644 src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalCommandService.kt diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt index 0fe8974..5421e71 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/controller/FestivalController.kt @@ -1,14 +1,17 @@ package busanVibe.busan.domain.festival.controller import busanVibe.busan.domain.festival.dto.FestivalDetailsDTO +import busanVibe.busan.domain.festival.dto.FestivalLikeResponseDTO import busanVibe.busan.domain.festival.dto.FestivalListResponseDTO import busanVibe.busan.domain.festival.enums.FestivalSortType import busanVibe.busan.domain.festival.enums.FestivalStatus +import busanVibe.busan.domain.festival.service.FestivalCommandService import busanVibe.busan.domain.festival.service.FestivalQueryService import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam @@ -18,7 +21,8 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/festivals") class FestivalController( - private val festivalQueryService: FestivalQueryService + private val festivalQueryService: FestivalQueryService, + private val festivalCommandService: FestivalCommandService ) { @GetMapping @@ -42,4 +46,12 @@ class FestivalController( return ApiResponse.onSuccess(festivalDetails); } + @PatchMapping("/like/{festivalId}") + @Operation(summary = "지역축제 좋아요 API", + description = "지역축제 좋아요 API 입니다. 이미지 좋아요를 누른 경우 좋아요가 취소됩니다.") + fun like(@PathVariable("festivalId") festivalId: Long?): ApiResponse{ + val likeDTO = festivalCommandService.like(festivalId) + return ApiResponse.onSuccess(likeDTO); + } + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLike.kt b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLike.kt index fac3a56..405edf4 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLike.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLike.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.festival.domain import busanVibe.busan.domain.common.BaseEntity import busanVibe.busan.domain.user.data.User +import jakarta.persistence.EmbeddedId import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue @@ -9,20 +10,22 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.MapsId @Entity -class FestivalLike( +open class FestivalLike( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - val id: Long? = null, + @EmbeddedId + val id: FestivalLikeId, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") + @MapsId("userId") val user: User, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "festival_id") + @MapsId("festivalId") val festival: Festival ): BaseEntity() { diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLikeId.kt b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLikeId.kt new file mode 100644 index 0000000..03cb2ca --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/festival/domain/FestivalLikeId.kt @@ -0,0 +1,15 @@ +package busanVibe.busan.domain.festival.domain + +import jakarta.persistence.Embeddable +import lombok.Getter +import java.io.Serializable + +@Embeddable +@Getter +open class FestivalLikeId( + + private val userId: Long, + private val festivalId: Long +): Serializable { + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalLikeResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalLikeResponseDTO.kt new file mode 100644 index 0000000..aa3aace --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalLikeResponseDTO.kt @@ -0,0 +1,12 @@ +package busanVibe.busan.domain.festival.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class FestivalLikeResponseDTO( + val success: Boolean, + val message: String, +) { + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt index c6ea951..185e99c 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/dto/FestivalListResponseDTO.kt @@ -24,4 +24,6 @@ class FestivalListResponseDTO { val likeAmount: Int, ) + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalLikesRepository.kt b/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalLikesRepository.kt index ce5e2c1..2af3631 100644 --- a/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalLikesRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/festival/repository/FestivalLikesRepository.kt @@ -2,15 +2,13 @@ package busanVibe.busan.domain.festival.repository import busanVibe.busan.domain.festival.domain.Festival import busanVibe.busan.domain.festival.domain.FestivalLike +import busanVibe.busan.domain.user.data.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param interface FestivalLikesRepository: JpaRepository { - fun findAllByFestivalIn(festivalList: List): List - fun findAllByFestivalIn(festivalList: Set): List - @Query( """ SELECT fl FROM FestivalLike fl @@ -20,4 +18,8 @@ interface FestivalLikesRepository: JpaRepository { ) fun findLikeByFestival(@Param("festivals") festivalList: List): List + fun findByFestivalAndUser(@Param("festival") festival: Festival, @Param("user") user: User): FestivalLike? + + fun deleteByFestivalAndUser(@Param("festival") festival: Festival, @Param("user") user: User) + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalCommandService.kt new file mode 100644 index 0000000..dfc74eb --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/festival/service/FestivalCommandService.kt @@ -0,0 +1,78 @@ +package busanVibe.busan.domain.festival.service + +import busanVibe.busan.domain.festival.domain.Festival +import busanVibe.busan.domain.festival.domain.FestivalLike +import busanVibe.busan.domain.festival.domain.FestivalLikeId +import busanVibe.busan.domain.festival.dto.FestivalLikeResponseDTO +import busanVibe.busan.domain.festival.repository.FestivalLikesRepository +import busanVibe.busan.domain.festival.repository.FestivalRepository +import busanVibe.busan.domain.user.data.User +import busanVibe.busan.domain.user.service.login.AuthService +import busanVibe.busan.global.apiPayload.code.status.ErrorStatus +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler +import jakarta.transaction.Transactional +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.stereotype.Service + +@Service +class FestivalCommandService( + val festivalRepository: FestivalRepository, + val festivalLikeRepository: FestivalLikesRepository, + private val festivalLikesRepository: FestivalLikesRepository +) { + + @Transactional + fun like(festivalId: Long?): FestivalLikeResponseDTO{ + + // null 처리 + if( festivalId == null ){ + throw ExceptionHandler(ErrorStatus.FESTIVAL_ID_REQUIRED) + } + + // 필요한 객체 생성 + val currentUser = AuthService().getCurrentUser() + val festival: Festival = festivalRepository.findById(festivalId).orElseThrow{ ExceptionHandler(ErrorStatus.FESTIVAL_NOT_FOUND) } + var message: String + + // 좋아요 정보 조회 + val festivalLike = festivalLikeRepository.findByFestivalAndUser(festival, currentUser) + + // 기존 좋아요 여부에 따른 처리 + if(festivalLike != null){ // 이미 좋아요 누른 경우 + cancelLike(festival, currentUser) // 좋아요 취소 + message = "좋아요 취소 성공" + }else{ // 이전에 좋아요 누르지 않은 경우 + val festivalLikeId = FestivalLikeId(userId = currentUser.id!!, festivalId = festivalId) // ID 객체 생성 + + var festivalLike: FestivalLike // 객체 미리 생성 + + try { + festivalLike = + FestivalLike(id = festivalLikeId, user = currentUser, festival = festival) + festivalLikesRepository.save(festivalLike) // 좋아요 정보 저장 + message = "좋아요 성공" + } catch (e: DataIntegrityViolationException) { + // 동시성 문제 대비 2차로 중복 처리 - 복합키 조회하여 중복 조회 + cancelLike(festival, currentUser) // 좋아요 취소 + message = "좋아요 취소 성공" + } + } + + // DTO 생성 및 반환 + return FestivalLikeResponseDTO( + success = true, + message = message + ) + + } + + // 좋아요 취소 처리 메서드 + private fun cancelLike(festival: Festival, user: User) { + try{ + festivalLikeRepository.deleteByFestivalAndUser(festival, user) + }catch (e: Exception){ + throw RuntimeException(e.message, e) + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt index ac27e48..90cd3a9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/repository/PlaceLikeRepository.kt @@ -9,7 +9,6 @@ import org.springframework.data.repository.query.Param interface PlaceLikeRepository: JpaRepository { fun findAllByPlaceIn(placeList: List): List - fun findByPlace(place: Place): List @Query("SELECT pl FROM PlaceLike pl WHERE pl.place IN :places") fun findLikeByPlace(@Param("places") placeList: List): List diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt index 73dc101..4070b57 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt @@ -32,6 +32,7 @@ enum class ErrorStatus( // 축제 관련 에러 FESTIVAL_NOT_FOUND(HttpStatus.NOT_FOUND, "FESTIVAL4004", "축제를 찾을 수 없습니다."), + FESTIVAL_ID_REQUIRED(HttpStatus.BAD_REQUEST, "FESTIVAL4005", "festivalId가 필요합니다."), // 검색 관련 에러 SEARCH_INVALID_CONDITION(HttpStatus.BAD_REQUEST, "SEARCH4002", "잘못된 검색 조건입니다."), From 0f121872bc6fc795a3c354951c1b133e6090a690 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 30 Aug 2025 06:04:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20tour=20api=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=20=20-=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EC=97=90=20=ED=8F=AC=ED=95=A8=EB=90=9C=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0=20=ED=9B=84=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=20=20-=20=EB=AA=85=EC=86=8C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=8B=9C=20=EB=B6=84=ED=8F=AC=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../busan/domain/place/domain/Place.kt | 4 +- .../tourApi/service/TourCommandService.kt | 54 +++++++++++++++---- .../tourApi/util/TourFestivalConverter.kt | 7 ++- .../domain/tourApi/util/TourPlaceUtil.kt | 3 +- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt index 84ae42e..20896ce 100644 --- a/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt +++ b/src/main/kotlin/busanVibe/busan/domain/place/domain/Place.kt @@ -73,9 +73,9 @@ class Place( @OneToMany(mappedBy = "place", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) val placeImages: MutableSet = mutableSetOf(), - @OneToOne(fetch = FetchType.LAZY) + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.PERSIST, CascadeType.MERGE]) @JoinColumn(name = "visitor_distribution_id") - val visitorDistribution: VisitorDistribution? = null, + val visitorDistribution: VisitorDistribution, ) : BaseEntity(){ diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt index 88c4ba5..81cfca3 100644 --- a/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/service/TourCommandService.kt @@ -2,6 +2,7 @@ package busanVibe.busan.domain.tourApi.service import busanVibe.busan.domain.festival.repository.FestivalRepository import busanVibe.busan.domain.place.domain.Place +import busanVibe.busan.domain.place.domain.VisitorDistribution import busanVibe.busan.domain.place.enums.PlaceType import busanVibe.busan.domain.place.repository.PlaceJdbcRepository import busanVibe.busan.domain.place.repository.PlaceRepository @@ -12,8 +13,10 @@ import busanVibe.busan.domain.tourApi.util.TourPlaceUtil import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal +import kotlin.random.Random @Service class TourCommandService( @@ -34,7 +37,7 @@ class TourCommandService( } - @Transactional +// @Transactional fun getPlace(placeType: PlaceType) { val apiResponse = tourPlaceUtil.getPlace(placeType).response val items = apiResponse.body?.items?.item @@ -54,22 +57,22 @@ class TourCommandService( val place = Place( contentId = apiItem.contentId, - name = apiItem.title.orNoInfo(), + name = apiItem.title.orNoInfo().removeTag(), type = placeType, latitude = apiItem.mapY?.toBigDecimal(), longitude = apiItem.mapX?.toBigDecimal(), - address = apiItem.addr1.orNoInfo(), - introduction = detailItem?.overview.orNoInfo(), + address = apiItem.addr1.orNoInfo().removeTag(), + introduction = detailItem?.overview.orNoInfo().removeTag(), phone = listOf(apiItem.tel, detailItem?.tel, getCenter(placeType, introItem)) .firstOrNull { !it.isNullOrBlank() } - .orNoInfo(), - useTime = getUseTime(placeType, introItem).orNoInfo(), - restDate = getRest(placeType, introItem).orNoInfo(), + .orNoInfo().removeTag(), + useTime = getUseTime(placeType, introItem).orNoInfo().removeTag(), + restDate = getRest(placeType, introItem).orNoInfo().removeTag(), reviews = emptyList(), placeLikes = emptySet(), openTime = null, placeImages = mutableSetOf(), - visitorDistribution = null, + visitorDistribution = getRandomVisitorDistribution(), ) @@ -84,7 +87,8 @@ class TourCommandService( placeList.add(place) } // placeJdbcRepository.saveAll(placeList) - placeRepository.saveAll(placeList) +// placeRepository.saveAll(placeList) + saveAllPlaces(placeList) } private fun getUseTime(placeType: PlaceType, introItem: PlaceIntroductionItem?): String = @@ -114,8 +118,40 @@ class TourCommandService( PlaceType.CULTURE -> introItem?.infoCenterCulture }.orNoInfo() + // 문자열 속 태그 제거 메서드 + private fun String.removeTag(): String{ + return this.replace(Regex("<[^>]*>"), "") + } + + private fun getRandomVisitorDistribution(): VisitorDistribution { + return VisitorDistribution( + m1020 = Random.nextInt(0, 101), + f1020 = Random.nextInt(0, 101), + m3040 = Random.nextInt(0, 101), + f3040 = Random.nextInt(0, 101), + m5060 = Random.nextInt(0, 101), + f5060 = Random.nextInt(0, 101), + m70 = Random.nextInt(0, 101), + f70 = Random.nextInt(0, 101), + ) + } private fun String?.orNoInfo(): String = if (this.isNullOrBlank()) noInfo else this private fun BigDecimal?.orZero(): BigDecimal = this ?: BigDecimal.ZERO + fun saveAllPlaces(placeList: List) { + placeList.forEach { place -> + try { + saveOne(place) // 별도 트랜잭션 + } catch (e: Exception) { + log.error("저장 실패: ${place.contentId}, 예외 메시지: ${e.message}") + } + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun saveOne(place: Place) { + placeRepository.save(place) + } + } diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt index f1b3438..4beefd8 100644 --- a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourFestivalConverter.kt @@ -19,7 +19,7 @@ class TourFestivalConverter { startDate = startDate?: Date(), endDate = endDate?: Date(), place = MAIN_PLACE ?: "장소없음", - introduction = ITEMCNTNTS ?: "", + introduction = ITEMCNTNTS?.removeTag() ?: "", fee = USAGE_AMOUNT?: "정보없음", phone = CNTCT_TEL ?: "정보없음", siteUrl = HOMEPAGE_URL ?: "정보없음", @@ -52,6 +52,11 @@ class TourFestivalConverter { return end?.let { Date.from(it.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant()) } } + // 문자열 속 태그 제거 메서드 + private fun String.removeTag(): String{ + return this.replace(Regex("<[^>]*>"), "") + } + private fun tryParseStartEnd(dateStr: String, isStart: Boolean): LocalDate? { val cleaned = dateStr.replace("\\(.*?\\)".toRegex(), "") // 요일 제거 val patterns = listOf( diff --git a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt index e33dab2..4ecb4b3 100644 --- a/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt +++ b/src/main/kotlin/busanVibe/busan/domain/tourApi/util/TourPlaceUtil.kt @@ -29,6 +29,7 @@ class TourPlaceUtil( private val mobileOs:String = "AND" private val mobileApp: String = "busanvibe" private val numOfRows: String = "10" + private val pageNum: String = "0" // webclient 응답 버퍼 증가 private val strategies = org.springframework.web.reactive.function.client.ExchangeStrategies.builder() @@ -46,7 +47,7 @@ class TourPlaceUtil( val url = StringBuilder("https://apis.data.go.kr/B551011/KorService2/areaBasedList2") .append("?numOfRows=").append(numOfRows) - .append("&pageNo=0") + .append("&pageNo=").append(pageNum) .append("&MobileOS=").append(mobileOs) .append("&MobileApp=").append(mobileApp) .append("&contentTypeId=").append(placeTypeCode)