diff --git a/bootstrap/src/main/resources/application.yml b/bootstrap/src/main/resources/application.yml index e095c58..214c13c 100644 --- a/bootstrap/src/main/resources/application.yml +++ b/bootstrap/src/main/resources/application.yml @@ -5,3 +5,4 @@ spring: config: import: - application-persistence.yml + - application-internal.yml diff --git a/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt b/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt deleted file mode 100644 index a9da087..0000000 --- a/community-application/src/main/kotlin/gloddy/article/dto/read/ArticleIdReadData.kt +++ /dev/null @@ -1,5 +0,0 @@ -package gloddy.article.dto.read - -data class ArticleIdReadData( - val articleId: Long -) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt index ded8688..a8998c9 100644 --- a/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt +++ b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleCommandUseCase.kt @@ -1,10 +1,10 @@ package gloddy.article.port.`in` -import gloddy.article.dto.command.ArticleCreateCommand -import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.dto.command.ArticleCreateRequest +import gloddy.article.port.`in`.dto.read.ArticleCreateResponse interface ArticleCommandUseCase { - fun create(userId: Long, command: ArticleCreateCommand): ArticleIdReadData + fun create(userId: Long, command: ArticleCreateRequest): ArticleCreateResponse fun delete(userId: Long, articleId: Long) - fun like(userId: Long, articleId: Long) + fun upsertLike(userId: Long, articleId: Long) } \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/ArticleOrder.kt b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleOrder.kt new file mode 100644 index 0000000..4e1a42e --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleOrder.kt @@ -0,0 +1,7 @@ +package gloddy.article.port.`in` + +enum class ArticleOrder( + val value: String +) { + LATEST("최신순") +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/ArticleQueryUseCase.kt b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleQueryUseCase.kt new file mode 100644 index 0000000..064064c --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/ArticleQueryUseCase.kt @@ -0,0 +1,11 @@ +package gloddy.article.port.`in` + +import gloddy.article.port.`in`.dto.command.ArticleDetailGetRequest +import gloddy.article.port.`in`.dto.command.ArticleDetailPageGetRequest +import gloddy.article.port.`in`.dto.read.ArticleDetailResponse +import gloddy.core.dto.PageResponse + +interface ArticleQueryUseCase { + fun getArticleDetailPage(request: ArticleDetailPageGetRequest): PageResponse + fun getArticleDetail(request: ArticleDetailGetRequest): ArticleDetailResponse +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleCreateRequest.kt similarity index 58% rename from community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt rename to community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleCreateRequest.kt index c488ef8..b379175 100644 --- a/community-application/src/main/kotlin/gloddy/article/dto/command/ArticleCreateCommand.kt +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleCreateRequest.kt @@ -1,6 +1,6 @@ -package gloddy.article.dto.command +package gloddy.article.port.`in`.dto.command -data class ArticleCreateCommand( +data class ArticleCreateRequest( val categoryId: Long, val title: String, val content: String, diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailGetRequest.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailGetRequest.kt new file mode 100644 index 0000000..eb9956e --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailGetRequest.kt @@ -0,0 +1,6 @@ +package gloddy.article.port.`in`.dto.command + +data class ArticleDetailGetRequest( + val id: Long, + val userId: Long +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailPageGetRequest.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailPageGetRequest.kt new file mode 100644 index 0000000..ecb931b --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/command/ArticleDetailPageGetRequest.kt @@ -0,0 +1,11 @@ +package gloddy.article.port.`in`.dto.command + +import gloddy.article.port.`in`.ArticleOrder + +data class ArticleDetailPageGetRequest( + val categoryId: Long?, + val userId: Long, + val size: Int, + val page: Int, + val order: ArticleOrder +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleCreateResponse.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleCreateResponse.kt new file mode 100644 index 0000000..93c05a2 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleCreateResponse.kt @@ -0,0 +1,5 @@ +package gloddy.article.port.`in`.dto.read + +data class ArticleCreateResponse( + val articleId: Long +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailResponse.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailResponse.kt new file mode 100644 index 0000000..49a6bb3 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailResponse.kt @@ -0,0 +1,8 @@ +package gloddy.article.port.`in`.dto.read + +import gloddy.user.port.`in`.dto.UserPreviewUnit + +data class ArticleDetailResponse( + val article: ArticleDetailUnit, + val writer: UserPreviewUnit +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailUnit.kt b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailUnit.kt new file mode 100644 index 0000000..8b6ddf6 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/port/in/dto/read/ArticleDetailUnit.kt @@ -0,0 +1,18 @@ +package gloddy.article.port.`in`.dto.read + +import gloddy.category.port.`in`.dto.CategoryGetResponse + +data class ArticleDetailUnit( + val id: Long, + val userId: Long, + val isWriter: Boolean, + val isLiked: Boolean, + val category: CategoryGetResponse, + val title: String, + val content: String, + val thumbnail: String?, + val images: List?, + val likeCount: Int, + val commentCount: Int, + val createdAt: String +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt index 155a266..4e2fcee 100644 --- a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleCommandPersistencePort.kt @@ -1,8 +1,10 @@ package gloddy.article.port.out import gloddy.article.Article +import gloddy.article.ArticleLike interface ArticleCommandPersistencePort { fun save(article: Article) : Article fun delete(id: Long) + fun upsertLike(articleLike: ArticleLike, article: Article) } \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt index 01f2379..78aa961 100644 --- a/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt +++ b/community-application/src/main/kotlin/gloddy/article/port/out/ArticleQueryPersistencePort.kt @@ -1,7 +1,23 @@ package gloddy.article.port.out import gloddy.article.Article +import gloddy.article.port.`in`.dto.read.ArticleDetailUnit +import gloddy.article.port.`in`.ArticleOrder +import gloddy.core.dto.PageResponse interface ArticleQueryPersistencePort { fun findById(id: Long): Article + + fun findArticleDetailUnitPageByCategoryId( + categoryId: Long? = null, + userId: Long, + size: Int, + page: Int, + order: ArticleOrder, + ): PageResponse + + fun findArticleDetailUnitById( + id: Long, + userId: Long + ): ArticleDetailUnit } \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt b/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt index 1e730e9..c14c2fe 100644 --- a/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt +++ b/community-application/src/main/kotlin/gloddy/article/service/ArticleCommandService.kt @@ -2,8 +2,8 @@ package gloddy.article.service import gloddy.article.Article import gloddy.article.ArticleLike -import gloddy.article.dto.command.ArticleCreateCommand -import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.dto.command.ArticleCreateRequest +import gloddy.article.port.`in`.dto.read.ArticleCreateResponse import gloddy.article.port.`in`.ArticleCommandUseCase import gloddy.article.port.out.ArticleCommandPersistencePort import gloddy.article.port.out.ArticleLikeCommandPersistencePort @@ -20,10 +20,10 @@ class ArticleCommandService( private val articleQueryPersistencePort: ArticleQueryPersistencePort, private val articleCommandPersistencePort: ArticleCommandPersistencePort, private val articleLikeCommandPersistencePort: ArticleLikeCommandPersistencePort, - private val articleLikeQueryPersistencePort: ArticleLikeQueryPersistencePort + private val articleLikeQueryPersistencePort: ArticleLikeQueryPersistencePort, ) : ArticleCommandUseCase { - override fun create(userId: Long, command: ArticleCreateCommand) : ArticleIdReadData { + override fun create(userId: Long, command: ArticleCreateRequest): ArticleCreateResponse { val category = categoryQueryPersistencePort.findById(CategoryId(command.categoryId)) @@ -34,7 +34,7 @@ class ArticleCommandService( content = command.content, images = command.images, ).let { articleCommandPersistencePort.save(it) } - return ArticleIdReadData(articleId = article.id!!.value) + return ArticleCreateResponse(articleId = article.id!!.value) } override fun delete(userId: Long, articleId: Long) { @@ -43,17 +43,21 @@ class ArticleCommandService( articleCommandPersistencePort.delete(article.id!!.value) } - override fun like(userId: Long, articleId: Long) { - + override fun upsertLike(userId: Long, articleId: Long) { val article = articleQueryPersistencePort.findById(articleId) - articleLikeQueryPersistencePort.findByUserIdAndArticleOrNull(userId, article) - ?.run { articleLikeCommandPersistencePort.delete(this) } - ?: articleLikeCommandPersistencePort.save( - ArticleLike( + ?.run { + articleCommandPersistencePort.upsertLike( + articleLike = this, + article = article.unlike() + ) + } + ?: articleCommandPersistencePort.upsertLike( + articleLike = ArticleLike( userId = UserId(userId), article = article - ) + ), + article = article.like() ) } } \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/article/service/ArticleQueryService.kt b/community-application/src/main/kotlin/gloddy/article/service/ArticleQueryService.kt new file mode 100644 index 0000000..8d84e57 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/article/service/ArticleQueryService.kt @@ -0,0 +1,57 @@ +package gloddy.article.service + +import gloddy.article.port.`in`.ArticleQueryUseCase +import gloddy.article.port.`in`.dto.command.ArticleDetailGetRequest +import gloddy.article.port.`in`.dto.command.ArticleDetailPageGetRequest +import gloddy.article.port.`in`.dto.read.ArticleDetailResponse +import gloddy.article.port.out.ArticleQueryPersistencePort +import gloddy.core.dto.PageResponse +import gloddy.user.port.out.UserQueryPort +import org.springframework.stereotype.Service + +@Service +class ArticleQueryService( + private val userQueryPort: UserQueryPort, + private val articleQueryPersistencePort: ArticleQueryPersistencePort, +) : ArticleQueryUseCase { + + override fun getArticleDetailPage(request: ArticleDetailPageGetRequest): PageResponse { + val articleDetailUnitPage = articleQueryPersistencePort.findArticleDetailUnitPageByCategoryId( + categoryId = request.categoryId, + userId = request.userId, + size = request.size, + page = request.page, + order = request.order + ) + + val userPreviewUnits = userQueryPort.getUserPreviewUnits( + userIds = articleDetailUnitPage.contents.map { it.userId }.toSet() + ) + + return PageResponse( + totalCount = articleDetailUnitPage.totalCount, + currentCount = articleDetailUnitPage.currentCount, + totalPage = articleDetailUnitPage.totalPage, + currentPage = articleDetailUnitPage.currentPage, + contents = articleDetailUnitPage.contents + .map { + ArticleDetailResponse( + article = it, + writer = userPreviewUnits[it.userId]!! + ) + } + ) + } + + override fun getArticleDetail(request: ArticleDetailGetRequest): ArticleDetailResponse { + val articleDetailUnit = articleQueryPersistencePort.findArticleDetailUnitById( + id = request.id, + userId = request.userId + ) + val userPreviewUnit = userQueryPort.getUserPreviewUnit(request.userId) + return ArticleDetailResponse( + article = articleDetailUnit, + writer = userPreviewUnit + ) + } +} \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt b/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt deleted file mode 100644 index 0ac7168..0000000 --- a/community-application/src/main/kotlin/gloddy/category/port/dto/CategoryReadData.kt +++ /dev/null @@ -1,6 +0,0 @@ -package gloddy.category.port.dto - -data class CategoryReadData( - val id: Long, - val name: String -) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt b/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt index febc50a..db4c87c 100644 --- a/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt +++ b/community-application/src/main/kotlin/gloddy/category/port/in/CategoryQueryUseCase.kt @@ -1,7 +1,7 @@ package gloddy.category.port.`in` -import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.dto.CategoryGetResponse interface CategoryQueryUseCase { - fun getAll(): List + fun getAll(): List } \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/in/dto/CategoryGetResponse.kt b/community-application/src/main/kotlin/gloddy/category/port/in/dto/CategoryGetResponse.kt new file mode 100644 index 0000000..959aed6 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/category/port/in/dto/CategoryGetResponse.kt @@ -0,0 +1,6 @@ +package gloddy.category.port.`in`.dto + +data class CategoryGetResponse( + val id: Long, + val name: String +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt b/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt index e8e37b8..092706e 100644 --- a/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt +++ b/community-application/src/main/kotlin/gloddy/category/port/service/CategoryQueryService.kt @@ -1,6 +1,6 @@ package gloddy.category.port.service -import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.dto.CategoryGetResponse import gloddy.category.port.`in`.CategoryQueryUseCase import gloddy.category.port.out.CategoryQueryPersistencePort import org.springframework.stereotype.Service @@ -10,10 +10,10 @@ class CategoryQueryService( private val categoryQueryPersistencePort: CategoryQueryPersistencePort, ) : CategoryQueryUseCase { - override fun getAll(): List { + override fun getAll(): List { val categories = categoryQueryPersistencePort.findAll() return categories.map { - CategoryReadData( + CategoryGetResponse( id = it.id!!.value, name = it.name ) diff --git a/community-application/src/main/kotlin/gloddy/core/dto/PageResponse.kt b/community-application/src/main/kotlin/gloddy/core/dto/PageResponse.kt new file mode 100644 index 0000000..fe55e4a --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/core/dto/PageResponse.kt @@ -0,0 +1,9 @@ +package gloddy.core.dto + +data class PageResponse( + val totalCount: Long, + val currentCount: Int, + val totalPage: Int, + val currentPage: Int, + val contents: List +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/core/util/DateTimeUtil.kt b/community-application/src/main/kotlin/gloddy/core/util/DateTimeUtil.kt new file mode 100644 index 0000000..6763eda --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/core/util/DateTimeUtil.kt @@ -0,0 +1,8 @@ +package gloddy.core.util + +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter.* + +fun LocalDateTime.toResponse(): String = + this.format(ofPattern("yyyy-MM-dd hh:mm")) + .replace(" ", "T") \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/user/port/in/dto/UserPreviewUnit.kt b/community-application/src/main/kotlin/gloddy/user/port/in/dto/UserPreviewUnit.kt new file mode 100644 index 0000000..7a43f35 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/user/port/in/dto/UserPreviewUnit.kt @@ -0,0 +1,11 @@ +package gloddy.user.port.`in`.dto + +data class UserPreviewUnit( + val id: Long, + val isCertifiedStudent: Boolean, + val profileImage: String, + val nickName: String, + val countryName: String?, + val countryImage: String?, + val reliabilityLevel: String +) \ No newline at end of file diff --git a/community-application/src/main/kotlin/gloddy/user/port/out/UserQueryPort.kt b/community-application/src/main/kotlin/gloddy/user/port/out/UserQueryPort.kt new file mode 100644 index 0000000..dc56fb3 --- /dev/null +++ b/community-application/src/main/kotlin/gloddy/user/port/out/UserQueryPort.kt @@ -0,0 +1,8 @@ +package gloddy.user.port.out + +import gloddy.user.port.`in`.dto.UserPreviewUnit + +interface UserQueryPort { + fun getUserPreviewUnit(userId: Long): UserPreviewUnit + fun getUserPreviewUnits(userIds: Set): Map +} \ No newline at end of file diff --git a/community-domain/src/main/kotlin/gloddy/article/Article.kt b/community-domain/src/main/kotlin/gloddy/article/Article.kt index 137524e..3894911 100644 --- a/community-domain/src/main/kotlin/gloddy/article/Article.kt +++ b/community-domain/src/main/kotlin/gloddy/article/Article.kt @@ -5,15 +5,18 @@ import gloddy.article.vo.ArticleImage import gloddy.category.Category import gloddy.core.ArticleId import gloddy.core.UserId +import java.time.LocalDateTime +import java.time.LocalDateTime.* data class Article( val userId: UserId, - var category: Category, - var title: String, - var content: String, - var image: ArticleImage, - var commentCount: Int = 0, - var likeCount: Int = 0, + val category: Category, + val title: String, + val content: String, + val image: ArticleImage, + val commentCount: Int = 0, + val likeCount: Int = 0, + val createdAt: LocalDateTime = now(), val id: ArticleId? = null, ) { constructor( @@ -37,4 +40,14 @@ data class Article( throw ArticleNoAuthorizationException() } } + + fun like(): Article = + this.copy( + likeCount = this.likeCount + 1 + ) + + fun unlike(): Article = + this.copy( + likeCount = this.likeCount - 1 + ) } \ No newline at end of file diff --git a/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt b/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt index 56f3780..b670ace 100644 --- a/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt +++ b/community-domain/src/testFixtures/kotlin/gloddy/ArticleFixture.kt @@ -14,7 +14,8 @@ enum class ArticleFixture( private val content: String, private val image: ArticleImage, ) { - JIHWAN(null, QNA.toPersistDomain(2L), "한국 핫플", "요즘 한국 핫플이 어디에용?", ArticleImage(null)); + JIHWAN(null, QNA.toPersistDomain(2L), "한국 핫플", "요즘 한국 핫플이 어디에용?", ArticleImage(null)), + HAVE_IMAGE(null, LANGUAGE.toPersistDomain(1L), "언어 모임", "언어 모임 하실 분~!", ArticleImage(listOf("image1", "image2"))); fun toDomain(userId: Long, category: Category? = null): Article = Article( diff --git a/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt b/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt index 28cbf18..92f1b28 100644 --- a/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt +++ b/community-domain/src/testFixtures/kotlin/gloddy/CategoryFixture.kt @@ -4,7 +4,7 @@ import gloddy.category.Category import gloddy.core.CategoryId enum class CategoryFixture( - private val names: String, + val names: String, ) { K_POP("K-POP"), QNA("Q&A"), diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt index 8859345..4898a6f 100644 --- a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandController.kt @@ -1,7 +1,7 @@ package gloddy.controller.article -import gloddy.article.dto.command.ArticleCreateCommand -import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.dto.command.ArticleCreateRequest +import gloddy.article.port.`in`.dto.read.ArticleCreateResponse import gloddy.article.port.`in`.ArticleCommandUseCase import gloddy.response.CommunityApiResponse import gloddy.response.ApiResponseEntityWrapper @@ -20,8 +20,8 @@ class ArticleCommandController( @PostMapping("/articles/create") override fun create( @RequestHeader("USER_ID") userId: Long, - @RequestBody command: ArticleCreateCommand, - ): ResponseEntity> { + @RequestBody command: ArticleCreateRequest, + ): ResponseEntity> { val data = articleCommandUseCase.create(userId, command) return ApiResponseEntityWrapper(data).created() } @@ -36,7 +36,7 @@ class ArticleCommandController( @PostMapping("/articles/{articleId}/like") override fun like(@RequestHeader("USER_ID") userId: Long, @PathVariable("articleId") articleId: Long) : ResponseEntity> { - articleCommandUseCase.like(userId, articleId) + articleCommandUseCase.upsertLike(userId, articleId) return ApiResponseEntityWrapper().noContent() } } \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt index 380a55e..ebd3e8c 100644 --- a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleCommandControllerDocs.kt @@ -1,7 +1,7 @@ package gloddy.controller.article -import gloddy.article.dto.command.ArticleCreateCommand -import gloddy.article.dto.read.ArticleIdReadData +import gloddy.article.port.`in`.dto.command.ArticleCreateRequest +import gloddy.article.port.`in`.dto.read.ArticleCreateResponse import gloddy.response.CommunityApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter @@ -16,8 +16,8 @@ interface ArticleCommandControllerDocs { @Operation(summary = "게시글 생성") @ApiResponse(responseCode = "201", description = "게시글 생성 성공") - fun create(@Parameter(hidden = true) userId: Long, @RequestBody command: ArticleCreateCommand) - : ResponseEntity> + fun create(@Parameter(hidden = true) userId: Long, @RequestBody command: ArticleCreateRequest) + : ResponseEntity> @Operation(summary = "게시글 삭제") @ApiResponse(responseCode = "204", description = "게시글 삭제 성공") diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryController.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryController.kt new file mode 100644 index 0000000..93bd99b --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryController.kt @@ -0,0 +1,53 @@ +package gloddy.controller.article + +import gloddy.article.port.`in`.ArticleOrder +import gloddy.article.port.`in`.ArticleQueryUseCase +import gloddy.article.port.`in`.dto.command.ArticleDetailGetRequest +import gloddy.article.port.`in`.dto.command.ArticleDetailPageGetRequest +import gloddy.article.port.`in`.dto.read.ArticleDetailResponse +import gloddy.core.dto.PageResponse +import gloddy.response.ApiResponseEntityWrapper +import gloddy.response.CommunityApiResponse +import gloddy.response.ok +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/communities") +class ArticleQueryController( + private val articleQueryUseCase: ArticleQueryUseCase, +) : ArticleQueryControllerDocs { + + @GetMapping("/articles") + override fun getArticlePage( + @RequestHeader("USER_ID") userId: Long, + @RequestParam(name = "categoryId", required = false) categoryId: Long?, + @RequestParam(name = "page", required = false, defaultValue = "0") page: Int, + @RequestParam(name = "size", required = false, defaultValue = "5") size: Int, + @RequestParam(name = "order", required = false, defaultValue = "LATEST") order: ArticleOrder, + ): ResponseEntity>> { + val data = articleQueryUseCase.getArticleDetailPage( + ArticleDetailPageGetRequest( + userId = userId, + categoryId = categoryId, + page = page, + size = size, + order = order + ) + ) + return ApiResponseEntityWrapper(data).ok() + } + + @GetMapping("/articles/{articleId}") + override fun getArticleDetail( + @RequestHeader("USER_ID") userId: Long, + @PathVariable("articleId") articleId: Long, + ): ResponseEntity> { + return articleQueryUseCase.getArticleDetail( + ArticleDetailGetRequest( + id = articleId, + userId = userId + ) + ).let { ApiResponseEntityWrapper(it).ok() } + } +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryControllerDocs.kt b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryControllerDocs.kt new file mode 100644 index 0000000..69039c2 --- /dev/null +++ b/community-in-api/src/main/kotlin/gloddy/controller/article/ArticleQueryControllerDocs.kt @@ -0,0 +1,34 @@ +package gloddy.controller.article + +import gloddy.article.port.`in`.ArticleOrder +import gloddy.article.port.`in`.dto.read.ArticleDetailResponse +import gloddy.core.dto.PageResponse +import gloddy.response.CommunityApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam + +@Tag(name = "게시글") +interface ArticleQueryControllerDocs { + + @Operation(summary = "게시글 페이징 조회") + @ApiResponse(responseCode = "200", description = "게시글 페이징 조회 성공") + fun getArticlePage( + @Parameter(hidden = true) userId: Long, + @RequestParam(name = "categoryId", required = false) categoryId: Long?, + @RequestParam(name = "page", required = false, defaultValue = "0") page: Int, + @RequestParam(name = "size", required = false, defaultValue = "5") size: Int, + @RequestParam(name = "order", required = false, defaultValue = "LATEST") order: ArticleOrder + ) : ResponseEntity>> + + @Operation(summary = "게시글 단건 조회") + @ApiResponse(responseCode = "200", description = "게시글 단건 조회 성공") + fun getArticleDetail( + @Parameter(hidden = true) userId: Long, + @PathVariable("articleId") articleId: Long + ): ResponseEntity> +} \ No newline at end of file diff --git a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt index e199d18..dd12ae3 100644 --- a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt +++ b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryController.kt @@ -1,6 +1,6 @@ package gloddy.controller.category -import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.dto.CategoryGetResponse import gloddy.category.port.`in`.CategoryQueryUseCase import gloddy.response.CommunityApiResponse import gloddy.response.ApiResponseEntityWrapper @@ -19,7 +19,7 @@ class CategoryQueryController( @GetMapping("/categories") - override fun getAll(@RequestHeader("USER_ID") userId: Long): ResponseEntity>> { + override fun getAll(@RequestHeader("USER_ID") userId: Long): ResponseEntity>> { val data = categoryQueryUseCase.getAll() return ApiResponseEntityWrapper(data).ok() } diff --git a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt index 4650fc0..a4fe877 100644 --- a/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt +++ b/community-in-api/src/main/kotlin/gloddy/controller/category/CategoryQueryControllerDocs.kt @@ -1,6 +1,6 @@ package gloddy.controller.category -import gloddy.category.port.dto.CategoryReadData +import gloddy.category.port.`in`.dto.CategoryGetResponse import gloddy.response.CommunityApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter @@ -13,5 +13,5 @@ interface CategoryQueryControllerDocs { @Operation(summary = "카테고리 전체 조회") @ApiResponse(responseCode = "200", description = "카테고리 전체 조회 성공") - fun getAll(@Parameter(hidden = true) userId: Long) : ResponseEntity>> + fun getAll(@Parameter(hidden = true) userId: Long) : ResponseEntity>> } \ No newline at end of file diff --git a/community-infrastructure/build.gradle.kts b/community-infrastructure/build.gradle.kts index 1f8cff6..2440746 100644 --- a/community-infrastructure/build.gradle.kts +++ b/community-infrastructure/build.gradle.kts @@ -2,6 +2,8 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar plugins { kotlin("plugin.jpa") version "1.9.22" + kotlin("kapt") + idea } val jar: Jar by tasks @@ -17,6 +19,11 @@ dependencies { implementation(project(":community-domain")) implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + kapt("com.querydsl:querydsl-apt:5.0.0:jakarta") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + kapt("org.springframework.boot:spring-boot-configuration-processor") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") implementation("org.springframework.cloud:spring-cloud-starter-openfeign") @@ -36,3 +43,11 @@ allOpen { annotation("jakarta.persistence.MappedSuperclass") } +idea { + module { + val kaptMain = file("build/generated/source/kapt/main") + sourceDirs.add(kaptMain) + generatedSourceDirs.add(kaptMain) + } +} + diff --git a/community-infrastructure/src/main/kotlin/gloddy/internal/client/UserQueryClient.kt b/community-infrastructure/src/main/kotlin/gloddy/internal/client/UserQueryClient.kt new file mode 100644 index 0000000..d205cbd --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/internal/client/UserQueryClient.kt @@ -0,0 +1,22 @@ +package gloddy.internal.client + +import gloddy.internal.client.payload.UserPreviewPayload +import gloddy.internal.client.payload.UserPreviewsPayload +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestParam + +@FeignClient(name = "UserGetClient", url = "\${internal.api.base-url}") +interface UserQueryClient { + + @GetMapping("/users/{userId}") + fun getUserPreview( + @PathVariable("userId") userId: Long, + ): UserPreviewPayload + + @GetMapping("/users") + fun getUserPreviews( + @RequestParam("ids") ids:Set + ): UserPreviewsPayload +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/internal/client/adapter/UserQueryAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/internal/client/adapter/UserQueryAdapter.kt new file mode 100644 index 0000000..2c9c448 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/internal/client/adapter/UserQueryAdapter.kt @@ -0,0 +1,23 @@ +package gloddy.internal.client.adapter + +import gloddy.internal.client.UserQueryClient +import gloddy.internal.client.payload.toResponse +import gloddy.user.port.`in`.dto.UserPreviewUnit +import gloddy.user.port.out.UserQueryPort +import org.springframework.stereotype.Component + +@Component +class UserQueryAdapter( + private val userQueryClient: UserQueryClient, +) : UserQueryPort { + + override fun getUserPreviewUnit(userId: Long): UserPreviewUnit { + return userQueryClient.getUserPreview(userId).toResponse() + } + + override fun getUserPreviewUnits(userIds: Set): Map { + return userQueryClient.getUserPreviews(userIds) + .users.associateBy { it.id } + .mapValues { it.value.toResponse() } + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewPayload.kt b/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewPayload.kt new file mode 100644 index 0000000..fd28dd5 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewPayload.kt @@ -0,0 +1,24 @@ +package gloddy.internal.client.payload + +import gloddy.user.port.`in`.dto.UserPreviewUnit + +data class UserPreviewPayload( + val id: Long, + val isCertifiedStudent: Boolean, + val profileImage: String, + val nickName: String, + val countryName: String?, + val countryImage: String?, + val reliabilityLevel: String +) + +fun UserPreviewPayload.toResponse(): UserPreviewUnit = + UserPreviewUnit( + id = this.id, + isCertifiedStudent = this.isCertifiedStudent, + profileImage = this.profileImage, + nickName = this.nickName, + countryName = this.countryName, + countryImage = this.countryImage, + reliabilityLevel = this.reliabilityLevel + ) \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewsPayload.kt b/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewsPayload.kt new file mode 100644 index 0000000..14ddf47 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/internal/client/payload/UserPreviewsPayload.kt @@ -0,0 +1,5 @@ +package gloddy.internal.client.payload + +data class UserPreviewsPayload( + val users: List +) \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/internal/config/OpenFeignConfig.kt b/community-infrastructure/src/main/kotlin/gloddy/internal/config/OpenFeignConfig.kt new file mode 100644 index 0000000..38eabee --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/internal/config/OpenFeignConfig.kt @@ -0,0 +1,16 @@ +package gloddy.internal.config + +import feign.Logger +import org.springframework.cloud.openfeign.EnableFeignClients +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@EnableFeignClients(basePackages = ["gloddy.internal"]) +class OpenFeignConfig { + + @Bean + fun feignLoggerLevel(): Logger.Level { + return Logger.Level.FULL + } +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt index 1c20eec..b2a1c6e 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/ArticleJpaEntity.kt @@ -5,6 +5,7 @@ import gloddy.persistence.common.BaseTimeEntity import gloddy.persistence.util.converter.StringArrayConverter import jakarta.persistence.* import org.hibernate.annotations.SQLRestriction +import java.time.LocalDateTime @Entity @Table(name = "article") @@ -35,7 +36,11 @@ class ArticleJpaEntity( @field:Id @field:GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long? = null, -) : BaseTimeEntity() { + + createdAt: LocalDateTime? = null +) : BaseTimeEntity( + createdAt = createdAt +) { fun changeDeletedToTrue() { this.deleted = true } diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt index bdaca04..0251b83 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleCommandPersistenceAdapter.kt @@ -1,10 +1,12 @@ package gloddy.persistence.article.adapter import gloddy.article.Article +import gloddy.article.ArticleLike import gloddy.article.exception.ArticleNotFoundException import gloddy.article.port.out.ArticleCommandPersistencePort import gloddy.persistence.article.ArticleJpaEntity import gloddy.persistence.article.repository.ArticleJpaRepository +import gloddy.persistence.article.repository.ArticleLikeJpaRepository import gloddy.persistence.util.mapper.toDomain import gloddy.persistence.util.mapper.toEntity import org.springframework.data.repository.findByIdOrNull @@ -14,7 +16,8 @@ import org.springframework.transaction.annotation.Transactional @Component @Transactional class ArticleCommandPersistenceAdapter( - private val articleJpaRepository: ArticleJpaRepository + private val articleJpaRepository: ArticleJpaRepository, + private val articleLikeJpaRepository: ArticleLikeJpaRepository ) : ArticleCommandPersistencePort { override fun save(article: Article): Article { @@ -26,6 +29,18 @@ class ArticleCommandPersistenceAdapter( articleJpaEntity.changeDeletedToTrue() } + override fun upsertLike(articleLike: ArticleLike, article: Article) { + save(article) + articleLike + .run { + if (articleLike.id == null) { + articleLikeJpaRepository.save(this.toEntity()) + } else { + articleLikeJpaRepository.delete(this.toEntity()) + } + } + } + fun find(id: Long): ArticleJpaEntity { return articleJpaRepository.findByIdOrNull(id) ?: throw ArticleNotFoundException() } diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt index 8bbe04b..3dc8d71 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/adapter/ArticleQueryPersistenceAdapter.kt @@ -1,10 +1,17 @@ package gloddy.persistence.article.adapter import gloddy.article.Article +import gloddy.article.port.`in`.dto.read.ArticleDetailResponse +import gloddy.article.port.`in`.dto.read.ArticleDetailUnit import gloddy.article.exception.ArticleNotFoundException +import gloddy.article.port.`in`.ArticleOrder import gloddy.article.port.out.ArticleQueryPersistencePort +import gloddy.core.dto.PageResponse import gloddy.persistence.article.repository.ArticleJpaRepository +import gloddy.persistence.article.repository.read.toUnit import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toResponse +import org.springframework.data.domain.PageRequest import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Component @@ -16,4 +23,26 @@ class ArticleQueryPersistenceAdapter( override fun findById(id: Long): Article { return (articleJpaRepository.findByIdOrNull(id) ?: throw ArticleNotFoundException()).toDomain() } + + override fun findArticleDetailUnitPageByCategoryId( + categoryId: Long?, + userId: Long, + size: Int, + page: Int, + order: ArticleOrder, + ): PageResponse { + return articleJpaRepository.findDetailArticlePageByCategoryId( + categoryId = categoryId, + userId = userId, + pageable = PageRequest.of(page, size), + order = order + ).toResponse(userId) + } + + override fun findArticleDetailUnitById(id: Long, userId: Long): ArticleDetailUnit { + return articleJpaRepository.findDetailArticleByIdOrNull( + id = id, + userId = userId + )?.toUnit(userId) ?: throw ArticleNotFoundException() + } } \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt index cb6af88..8049b8d 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/ArticleJpaRepository.kt @@ -1,7 +1,8 @@ package gloddy.persistence.article.repository import gloddy.persistence.article.ArticleJpaEntity +import gloddy.persistence.article.repository.custom.ArticleJpaRepositoryCustom import org.springframework.data.jpa.repository.JpaRepository -interface ArticleJpaRepository : JpaRepository { +interface ArticleJpaRepository : JpaRepository, ArticleJpaRepositoryCustom { } \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/custom/ArticleJpaRepositoryCustom.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/custom/ArticleJpaRepositoryCustom.kt new file mode 100644 index 0000000..4164767 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/custom/ArticleJpaRepositoryCustom.kt @@ -0,0 +1,13 @@ +package gloddy.persistence.article.repository.custom + +import gloddy.article.port.`in`.ArticleOrder +import gloddy.persistence.article.repository.read.ArticleDetailData +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface ArticleJpaRepositoryCustom { + fun findDetailArticlePageByCategoryId(categoryId: Long? = null, userId: Long, pageable: Pageable, order: ArticleOrder) + : Page + + fun findDetailArticleByIdOrNull(id: Long, userId: Long): ArticleDetailData? +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/impl/ArticleJpaRepositoryImpl.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/impl/ArticleJpaRepositoryImpl.kt new file mode 100644 index 0000000..e71c32c --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/impl/ArticleJpaRepositoryImpl.kt @@ -0,0 +1,106 @@ +package gloddy.persistence.article.repository.impl + +import com.querydsl.core.types.OrderSpecifier +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import gloddy.article.port.`in`.ArticleOrder +import gloddy.persistence.article.QArticleJpaEntity.* +import gloddy.persistence.article.QArticleLikeJpaEntity.* +import gloddy.persistence.article.repository.custom.ArticleJpaRepositoryCustom +import gloddy.persistence.article.repository.read.ArticleDetailData +import gloddy.persistence.article.repository.read.QArticleDetailData +import gloddy.persistence.category.QCategoryJpaEntity.* +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Repository + +@Repository +class ArticleJpaRepositoryImpl( + private val query: JPAQueryFactory +) : ArticleJpaRepositoryCustom { + + override fun findDetailArticlePageByCategoryId(categoryId: Long?, userId: Long, pageable: Pageable, order: ArticleOrder) + : Page { + + val articleDetailDataPage = query.select( + QArticleDetailData( + articleJpaEntity.id, + articleJpaEntity.userId, + articleJpaEntity.category.id, + articleJpaEntity.category.name, + articleJpaEntity.title, + articleJpaEntity.content, + articleJpaEntity.images, + articleJpaEntity.commentCount, + articleJpaEntity.likeCount, + articleJpaEntity.createdAt, + articleJpaEntity.updatedAt, + articleLikeJpaEntity.id + ) + ).from(articleJpaEntity) + .innerJoin(articleJpaEntity.category, categoryJpaEntity) + .leftJoin(articleLikeJpaEntity) + .on( + articleJpaEntity.id.eq(articleLikeJpaEntity.article.id) + .and(articleLikeJpaEntity.userId.eq(userId)) + ) + .where(eqCategoryIdOrNull(categoryId)) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + .orderBy(articleOrder(order)) + .fetch() + + val totalCount = query.select(articleJpaEntity.count()) + .from(articleJpaEntity) + .where(eqCategoryIdOrNull(categoryId)) + .fetchOne() + + return PageImpl( + articleDetailDataPage, + pageable, + totalCount!! + ) + } + override fun findDetailArticleByIdOrNull(id: Long, userId: Long): ArticleDetailData? { + return query.select( + QArticleDetailData( + articleJpaEntity.id, + articleJpaEntity.userId, + articleJpaEntity.category.id, + articleJpaEntity.category.name, + articleJpaEntity.title, + articleJpaEntity.content, + articleJpaEntity.images, + articleJpaEntity.commentCount, + articleJpaEntity.likeCount, + articleJpaEntity.createdAt, + articleJpaEntity.updatedAt, + articleLikeJpaEntity.id + ) + ).from(articleJpaEntity) + .innerJoin(articleJpaEntity.category, categoryJpaEntity) + .leftJoin(articleLikeJpaEntity) + .on( + articleJpaEntity.id.eq(articleLikeJpaEntity.article.id) + .and(articleLikeJpaEntity.userId.eq(userId)) + ) + .where(eqId(id)) + .fetchOne() + } + + private fun eqCategoryIdOrNull(categoryId: Long?): BooleanExpression? { + return categoryId?.let { + articleJpaEntity.category.id.eq(it) + } + } + + private fun articleOrder(order: ArticleOrder): OrderSpecifier<*> { + return when(order) { + ArticleOrder.LATEST -> articleJpaEntity.createdAt.desc() + } + } + + private fun eqId(id: Long): BooleanExpression = + articleJpaEntity.id.eq(id) +} \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/read/ArticleDetailData.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/read/ArticleDetailData.kt new file mode 100644 index 0000000..ebe22a1 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/article/repository/read/ArticleDetailData.kt @@ -0,0 +1,41 @@ +package gloddy.persistence.article.repository.read + +import com.querydsl.core.annotations.QueryProjection +import gloddy.article.port.`in`.dto.read.ArticleDetailUnit +import gloddy.category.port.`in`.dto.CategoryGetResponse +import gloddy.core.util.toResponse +import java.time.LocalDateTime + +data class ArticleDetailData @QueryProjection constructor( + val id: Long, + val userId: Long, + val categoryId: Long, + val categoryName: String, + val title: String, + val content: String, + val images: List?, + val commentCount: Int, + val likeCount: Int, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val articleLikeId: Long?, +) + +fun ArticleDetailData.toUnit(userId: Long): ArticleDetailUnit = + ArticleDetailUnit( + id = this.id, + userId = this.userId, + isWriter = userId == this.userId, + isLiked = this.articleLikeId != null, + category = CategoryGetResponse( + id = this.categoryId, + name = this.categoryName + ), + title = this.title, + content = this.content, + thumbnail = this.images?.firstOrNull(), + images = this.images, + likeCount = this.likeCount, + commentCount = this.commentCount, + createdAt = this.createdAt.toResponse() + ) diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt index b406d23..35579aa 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/config/JpaConfig.kt @@ -1,9 +1,20 @@ package gloddy.persistence.config + +import com.querydsl.jpa.impl.JPAQueryFactory +import jakarta.persistence.EntityManager +import jakarta.persistence.PersistenceContext +import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.data.jpa.repository.config.EnableJpaAuditing @Configuration @EnableJpaAuditing -class JpaConfig { +class JpaConfig( + @PersistenceContext + private val em: EntityManager +) { + @Bean + fun jpaQueryFactory(): JPAQueryFactory = + JPAQueryFactory(em) } \ No newline at end of file diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt index ee4ca61..f6ad230 100644 --- a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/ArticleMapper.kt @@ -7,6 +7,7 @@ import gloddy.core.ArticleId import gloddy.core.UserId import gloddy.persistence.article.ArticleJpaEntity import gloddy.persistence.article.ArticleLikeJpaEntity +import kotlin.io.path.createTempDirectory fun Article.toEntity() : ArticleJpaEntity = ArticleJpaEntity( @@ -17,6 +18,7 @@ fun Article.toEntity() : ArticleJpaEntity = images = this.image.images, commentCount = this.commentCount, likeCount = this.likeCount, + createdAt = this.createdAt, id = this.id?.value ) @@ -29,6 +31,7 @@ fun ArticleJpaEntity.toDomain() : Article = image = ArticleImage(this.images), commentCount = this.commentCount, likeCount = this.likeCount, + createdAt = this.createdAt!!, id = ArticleId(this.id!!) ) diff --git a/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/PageMapper.kt b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/PageMapper.kt new file mode 100644 index 0000000..5066279 --- /dev/null +++ b/community-infrastructure/src/main/kotlin/gloddy/persistence/util/mapper/PageMapper.kt @@ -0,0 +1,33 @@ +package gloddy.persistence.util.mapper + +import gloddy.article.port.`in`.dto.read.ArticleDetailUnit +import gloddy.category.port.`in`.dto.CategoryGetResponse +import gloddy.core.dto.PageResponse +import gloddy.core.util.toResponse +import gloddy.persistence.article.repository.read.ArticleDetailData +import org.springframework.data.domain.Page + +fun Page.toResponse(userId: Long): PageResponse = + this.map { ArticleDetailUnit( + id = it.id, + userId = it.userId, + isWriter = it.userId == userId, + isLiked = it.articleLikeId != null, + category = CategoryGetResponse( + id = it.categoryId, + name = it.categoryName + ), + title = it.title, + content = it.content, + thumbnail = it.images?.firstOrNull(), + images = it.images, + likeCount = it.likeCount, + commentCount = it.commentCount, + createdAt = it.createdAt.toResponse() + ) }.let { PageResponse( + totalCount = it.totalElements, + currentCount = it.numberOfElements, + totalPage = it.totalPages, + currentPage = it.number, + contents = it.content + ) } \ No newline at end of file diff --git a/community-infrastructure/src/main/resources/application-internal.yml b/community-infrastructure/src/main/resources/application-internal.yml new file mode 100644 index 0000000..0b95153 --- /dev/null +++ b/community-infrastructure/src/main/resources/application-internal.yml @@ -0,0 +1,3 @@ +internal: + api: + base-url: ${INTERNAL_API_BASE_URL} \ No newline at end of file diff --git a/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt b/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt index cf35018..c5d12e0 100644 --- a/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt +++ b/community-infrastructure/src/test/kotlin/gloddy/PersistenceTest.kt @@ -4,15 +4,18 @@ import gloddy.category.Category import gloddy.persistence.article.repository.ArticleJpaRepository import gloddy.persistence.article.repository.ArticleLikeJpaRepository import gloddy.persistence.category.repository.CategoryJpaRepository +import gloddy.persistence.config.JpaConfig import gloddy.persistence.util.mapper.toDomain import gloddy.persistence.util.mapper.toEntity import jakarta.persistence.EntityManager import org.junit.jupiter.api.BeforeEach import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import import org.springframework.test.context.ActiveProfiles @DataJpaTest +@Import(JpaConfig::class) @ActiveProfiles("test") abstract class PersistenceTest { diff --git a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt index be5d6b4..f9ba2e7 100644 --- a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt +++ b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleCommandPersistenceAdapterTest.kt @@ -24,7 +24,7 @@ class ArticleCommandPersistenceAdapterTest : PersistenceTest() { @BeforeEach fun setUp() { article = ArticleFixture.JIHWAN.toDomain(USER_ID, CATEGORY[0]) - articleCommandPersistenceAdapter = ArticleCommandPersistenceAdapter(articleJpaRepository) + articleCommandPersistenceAdapter = ArticleCommandPersistenceAdapter(articleJpaRepository, articleLikeJpaRepository) } @Nested diff --git a/community-infrastructure/src/test/kotlin/gloddy/article/ArticleJpaRepositoryTest.kt b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleJpaRepositoryTest.kt new file mode 100644 index 0000000..69f93fc --- /dev/null +++ b/community-infrastructure/src/test/kotlin/gloddy/article/ArticleJpaRepositoryTest.kt @@ -0,0 +1,122 @@ +package gloddy.article + +import gloddy.ArticleFixture +import gloddy.CategoryFixture +import gloddy.PersistenceTest +import gloddy.article.port.`in`.ArticleOrder +import gloddy.persistence.article.ArticleLikeJpaEntity +import gloddy.persistence.category.CategoryJpaEntity +import gloddy.persistence.util.mapper.toDomain +import gloddy.persistence.util.mapper.toEntity +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.data.domain.PageRequest + +@DisplayName("ArticleJpaRepository 클래스의") +class ArticleJpaRepositoryTest : PersistenceTest(){ + + @DisplayName("findDetailArticlePageByCategoryId 메소드는") + @Nested + inner class FindDetailArticlePageByCategoryId { + + private val USER_ID = 99L + private val PAGE_SIZE = 10 + private lateinit var kpopCategory: CategoryJpaEntity + private lateinit var languageCategory: CategoryJpaEntity + private lateinit var qnaCategory: CategoryJpaEntity + + @BeforeEach + fun setUp() { + val categoryJpaEntities = categoryJpaRepository.findAll() + kpopCategory = categoryJpaEntities.firstOrNull { it.name == CategoryFixture.K_POP.names}!! + languageCategory = categoryJpaEntities.firstOrNull { it.name == CategoryFixture.LANGUAGE.names }!! + qnaCategory = categoryJpaEntities.firstOrNull { it.name == CategoryFixture.QNA.names }!! + } + + @Test + @DisplayName("categoryId = null, order = LATEST 값을 입력받으면 전체 카테고리에 대한 페이지를 최신순으로 반환한다.") + fun success_when_categoryId_is_null_and_latest() { + + //given + for (i in 1..30) { + ArticleFixture.HAVE_IMAGE.toDomain(USER_ID, kpopCategory.toDomain()) + .run { articleJpaRepository.save(this.toEntity())} + } + + for (i in 1..30) { + ArticleFixture.HAVE_IMAGE.toDomain(USER_ID, languageCategory.toDomain()) + .run { articleJpaRepository.save(this.toEntity()) } + } + //when + val pageRequest = PageRequest.of(0, PAGE_SIZE) + val articlePage = articleJpaRepository.findDetailArticlePageByCategoryId( + categoryId = null, + userId = USER_ID, + pageable = pageRequest, + order = ArticleOrder.LATEST + ) + + //then + assertEquals(articlePage.content.size, 10) + assertEquals(articlePage.totalElements, 60) + } + + @Test + @DisplayName("categoryId = ?, order = LATEST 값을 입력 받으면 그 카테고리에 대한 페이지를 최신순으로 반환한다.") + fun success_when_categoryId_Is_specific() { + + //given + for (i in 1..30) { + ArticleFixture.HAVE_IMAGE.toDomain(USER_ID, kpopCategory.toDomain()) + .run { articleJpaRepository.save(this.toEntity())} + } + + //when + val pageRequest = PageRequest.of(0, PAGE_SIZE) + val articlePage = articleJpaRepository.findDetailArticlePageByCategoryId( + categoryId = kpopCategory.id!!, + userId = USER_ID, + pageable = pageRequest, + order = ArticleOrder.LATEST + ) + + //then + assertEquals(articlePage.content.size, 10) + assertEquals(articlePage.totalElements, 30) + } + + @Test + @DisplayName("특정 게시글이 자신이 좋아요한 게시글이 아니면 articleLikeId 값이면 null이다.") + fun success_returns_article_like_id() { + + val LIKE_USER_ID = 9999L + + //given + val likedArticle = ArticleFixture.HAVE_IMAGE.toDomain(USER_ID, kpopCategory.toDomain()) + .let { articleJpaRepository.save(it.toEntity()) } + ArticleFixture.HAVE_IMAGE.toDomain(USER_ID, kpopCategory.toDomain()) + .run { articleJpaRepository.save(this.toEntity())} + val articleLike = ArticleLikeJpaEntity( + userId = LIKE_USER_ID, + article = likedArticle + ).let { articleLikeJpaRepository.save(it) } + + //when + val pageRequest = PageRequest.of(0, PAGE_SIZE) + val articlePage = articleJpaRepository.findDetailArticlePageByCategoryId( + categoryId = kpopCategory.id!!, + userId = LIKE_USER_ID, + pageable = pageRequest, + order = ArticleOrder.LATEST + ) + + //then + assertNull(articlePage.content[0].articleLikeId) + assertNotNull(articlePage.content[1].articleLikeId) + assertEquals(articlePage.content[1].articleLikeId!!, articleLike.id!!) + } + } +} \ No newline at end of file