From b416f3794a46118b936459c3d23afb515b62363f Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 19:29:54 +0900 Subject: [PATCH 01/44] =?UTF-8?q?test:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/CommentConcurrencyTest.kt | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt new file mode 100644 index 00000000..4f746bea --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -0,0 +1,280 @@ +package com.weeth.domain.comment.application.usecase.command + +import com.weeth.config.QueryCountUtil +import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.Category +import com.weeth.domain.board.domain.entity.enums.Part +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.request.CommentSaveRequest +import com.weeth.domain.comment.domain.entity.Comment +import com.weeth.domain.comment.domain.repository.CommentRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Status +import com.weeth.domain.user.domain.repository.UserRepository +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import jakarta.persistence.EntityManager +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.ActiveProfiles +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) +class CommentConcurrencyTest( + private val postCommentUsecase: PostCommentUsecase, + private val postRepository: PostRepository, + private val userRepository: UserRepository, + private val commentRepository: CommentRepository, + private val entityManager: EntityManager, + private val atomicCommentCountCommand: AtomicCommentCountCommand, +) : DescribeSpec({ + val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false + + data class ConcurrencyResult( + val successCount: Int, + val failCount: Int, + val postCommentCount: Int, + val actualCommentCount: Int, + val queryCount: Long, + val elapsedTimeMs: Double, + val firstError: String?, + ) + + fun createUsers(size: Int): List = + (1..size).map { i -> + userRepository.save( + User + .builder() + .name("user$i") + .email("user$i@test.com") + .status(Status.ACTIVE) + .build(), + ) + } + + fun createPost(title: String): Post = + postRepository.save( + Post + .builder() + .title(title) + .content("내용") + .comments(ArrayList()) + .commentCount(0) + .category(Category.StudyLog) + .cardinalNumber(1) + .week(1) + .part(Part.ALL) + .parts(listOf(Part.ALL)) + .build(), + ) + + fun runConcurrentSave( + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): ConcurrencyResult { + val users = createUsers(threadCount) + val post = createPost("동시성 테스트 게시글") + val executor = Executors.newFixedThreadPool(threadCount) + val latch = CountDownLatch(threadCount) + val successCount = AtomicInteger(0) + val failCount = AtomicInteger(0) + val firstError = AtomicReference(null) + + entityManager.clear() + + val measured = + QueryCountUtil.count(entityManager) { + repeat(threadCount) { i -> + executor.submit { + try { + saveAction(post.id, users[i].id, i) + successCount.incrementAndGet() + } catch (e: Exception) { + failCount.incrementAndGet() + firstError.compareAndSet(null, "${e::class.simpleName}: ${e.message}") + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + } + + entityManager.clear() + val updatedPost = postRepository.findById(post.id).orElseThrow() + val actualCommentCount = + entityManager + .createQuery("select count(c) from Comment c where c.post.id = :postId", java.lang.Long::class.java) + .setParameter("postId", post.id) + .singleResult + .toInt() + + return ConcurrencyResult( + successCount = successCount.get(), + failCount = failCount.get(), + postCommentCount = updatedPost.commentCount, + actualCommentCount = actualCommentCount, + queryCount = measured.queryCount, + elapsedTimeMs = measured.elapsedTimeMs, + firstError = firstError.get(), + ) + } + + afterEach { + commentRepository.deleteAllInBatch() + postRepository.deleteAllInBatch() + userRepository.deleteAllInBatch() + } + + describe("동시 댓글 생성") { + it("10개의 동시 요청 후 commentCount가 정확히 10이어야 한다") { + val threadCount = 10 + val result = + runConcurrentSave(threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = CommentSaveRequest(parentCommentId = null, content = "댓글 $index", files = null), + postId = postId, + userId = userId, + ) + } + result.successCount shouldBe threadCount + result.failCount shouldBe 0 + result.postCommentCount shouldBe result.actualCommentCount + result.postCommentCount shouldBe threadCount + result.firstError shouldBe null + } + } + + describe("동시성 해소 방식별 성능 비교") { + // TODO(board-refactor): Board 도메인 구조 개편(댓글 카운트 책임/저장 구조 변경) 이후 + // 이 비교 시나리오는 동일 조건으로 다시 측정해 기준선을 재작성한다. + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정한다").config(enabled = runPerformanceTests) { + val threadCount = 30 + + val pessimisticResult = + runConcurrentSave(threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "pessimistic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + val atomicResult = + runConcurrentSave(threadCount) { postId, userId, index -> + atomicCommentCountCommand.savePostCommentWithAtomicIncrement( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "atomic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + println("[pessimistic] $pessimisticResult") + println("[atomic] $atomicResult") + + pessimisticResult.failCount shouldBe 0 + atomicResult.failCount shouldBe 0 + pessimisticResult.postCommentCount shouldBe threadCount + pessimisticResult.actualCommentCount shouldBe threadCount + atomicResult.postCommentCount shouldBe threadCount + atomicResult.actualCommentCount shouldBe threadCount + } + } + }) + +class AtomicCommentCountCommand( + private val commentRepository: CommentRepository, + private val postRepository: PostRepository, + private val userRepository: UserRepository, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, +) { + // TODO(board-refactor): 현재는 동시성 비교 실험용 테스트 전용 커맨드. + // Board 리팩토링 후 실제 카운트 갱신 구조에 맞춰 제거 또는 대체한다. + fun savePostCommentWithAtomicIncrement( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val maxRetries = 10 + var lastError: Exception? = null + + repeat(maxRetries) { attempt -> + try { + transactionTemplate.executeWithoutResult { + val user = userRepository.findById(userId).orElseThrow() + val post = postRepository.findById(postId).orElseThrow() + val parent = + dto.parentCommentId?.let { parentId -> + commentRepository.findByIdAndPostId(parentId, postId) ?: throw IllegalArgumentException("parent not found") + } + + commentRepository.save( + Comment.createForPost( + content = dto.content, + post = post, + user = user, + parent = parent, + ), + ) + + entityManager + .createQuery("update Post p set p.commentCount = p.commentCount + 1 where p.id = :postId") + .setParameter("postId", postId) + .executeUpdate() + } + return + } catch (e: Exception) { + lastError = e + val deadlock = e.message?.contains("Deadlock found", ignoreCase = true) == true + if (!deadlock || attempt == maxRetries - 1) { + throw e + } + Thread.sleep(10) + } + } + + throw IllegalStateException("Atomic increment retries exhausted", lastError) + } +} + +@TestConfiguration +class CommentConcurrencyBenchmarkConfig { + @Bean + fun atomicCommentCountCommand( + commentRepository: CommentRepository, + postRepository: PostRepository, + userRepository: UserRepository, + entityManager: EntityManager, + transactionManager: PlatformTransactionManager, + ): AtomicCommentCountCommand = + AtomicCommentCountCommand( + commentRepository = commentRepository, + postRepository = postRepository, + userRepository = userRepository, + entityManager = entityManager, + transactionTemplate = TransactionTemplate(transactionManager), + ) +} From 938020a7c374ea79475cd57e6339e106922d54bd Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 19:30:07 +0900 Subject: [PATCH 02/44] =?UTF-8?q?refactor:=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=A0=84=20=EA=B2=80=EC=A6=9D=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageCommentUseCase.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index 03fde804..e5357bec 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -178,12 +178,15 @@ class ManageCommentUseCase( if (comment.children.isEmpty()) { deleteCommentFiles(comment) val parent = comment.parent + val shouldDeleteParent = parent?.let { it.isDeleted && it.children.size == 1 } == true commentRepository.delete(comment) - // 부모 댓글이 삭제된 상태이고 자식 댓글이 1개인 경우 -> 부모 댓글도 삭제 - if (parent != null && parent.isDeleted && parent.children.size == 1) { - deleteCommentFiles(parent) - commentRepository.delete(parent) + // 부모 댓글이 삭제된 상태이고 유일한 자식이었던 경우 -> 부모 댓글도 삭제 + if (shouldDeleteParent) { + parent.let { + deleteCommentFiles(it) + commentRepository.delete(it) + } } return } From 6ee95a0412b330b97ba7a14fcaa806cc0a187eea Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 19:30:15 +0900 Subject: [PATCH 03/44] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/application/dto/response/CommentResponse.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt index 0a30e1d6..810996c0 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/dto/response/CommentResponse.kt @@ -3,15 +3,24 @@ package com.weeth.domain.comment.application.dto.response import com.weeth.domain.file.application.dto.response.FileResponse import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime data class CommentResponse( + @field:Schema(description = "댓글 ID", example = "1") val id: Long, + @field:Schema(description = "작성자 이름", example = "홍길동") val name: String, + @field:Schema(description = "작성자 포지션", example = "BE") val position: Position, + @field:Schema(description = "작성자 역할", example = "USER") val role: Role, + @field:Schema(description = "댓글 내용", example = "댓글입니다.") val content: String, + @field:Schema(description = "작성 시간", example = "2026-02-18T12:00:00") val time: LocalDateTime, + @field:Schema(description = "첨부 파일 목록") val fileUrls: List, + @field:Schema(description = "대댓글 목록") val children: List, ) From 76ade6afe7bfbfaf1c06925e423634ec1faa3ab1 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 19:30:31 +0900 Subject: [PATCH 04/44] =?UTF-8?q?chore:=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yml | 45 +++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 6d3399f3..bd5030bb 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,6 +1,9 @@ spring: - profiles: - active: test + data: + redis: + host: localhost + port: 6379 + password: jpa: hibernate: ddl-auto: create-drop @@ -10,3 +13,41 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect generate_statistics: true + +weeth: + jwt: + key: test-jwt-secret-key-test-jwt-secret-key + access: + expiration: 30 + header: Auth + refresh: + expiration: 1440 + header: Refresh + +auth: + providers: + kakao: + authorize_uri: https://kauth.kakao.com/oauth/authorize + client_id: test-kakao-client-id + redirect_uri: http://localhost/test/kakao/callback + grant_type: authorization_code + token_uri: https://kauth.kakao.com/oauth/token + user_info_uri: https://kapi.kakao.com/v2/user/me + apple: + client_id: test.apple.client + team_id: TESTTEAMID + key_id: TESTKEYID + redirect_uri: http://localhost/test/apple/callback + token_uri: https://appleid.apple.com/auth/token + keys_uri: https://appleid.apple.com/auth/keys + private_key_path: test/AuthKey_TEST.p8 + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test-access-key + secret-key: test-secret-key + region: + static: ap-northeast-2 From bfafdcb2a269aa436a5a62bf32582888bd1c81d5 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:06:45 +0900 Subject: [PATCH 05/44] =?UTF-8?q?refactor:=20Notice=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/application/dto/NoticeDTO.java | 70 ----- .../exception/NoticeErrorCode.java | 22 -- .../exception/NoticeNotFoundException.java | 9 - .../NoticeTypeNotMatchException.java | 9 - .../application/mapper/NoticeMapper.java | 42 --- .../application/usecase/NoticeUsecase.java | 20 -- .../usecase/NoticeUsecaseImpl.java | 157 ----------- .../domain/board/domain/entity/Notice.java | 38 --- .../domain/repository/NoticeRepository.java | 31 --- .../domain/service/NoticeDeleteService.java | 19 -- .../domain/service/NoticeFindService.java | 38 --- .../domain/service/NoticeSaveService.java | 18 -- .../domain/service/NoticeUpdateService.java | 19 -- .../presentation/NoticeAdminController.java | 55 ---- .../board/presentation/NoticeController.java | 45 ---- .../usecase/command/NoticeCommentUsecase.kt | 25 -- .../presentation/NoticeCommentController.kt | 65 ----- .../usecase/NoticeUsecaseImplTest.kt | 252 ------------------ .../domain/repository/NoticeRepositoryTest.kt | 65 ----- .../domain/board/fixture/NoticeTestFixture.kt | 21 -- 20 files changed, 1020 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Notice.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/NoticeController.java delete mode 100644 src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt delete mode 100644 src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt delete mode 100644 src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt diff --git a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java b/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java deleted file mode 100644 index 4c6ed47e..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/NoticeDTO.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class NoticeDTO { - - @Builder - public record Save( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Update( - @NotNull String title, - @NotNull String content, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time, //createdAt - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Position position, - Role role, - String title, - String content, - LocalDateTime time,//modifiedAt - Integer commentCount, - boolean hasFile - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "공지사항 생성 응답", example = "1") - long id - ) { - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java deleted file mode 100644 index 06b62f98..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeErrorCode.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum NoticeErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 공지사항 ID에 해당하는 공지사항이 없을 때 발생합니다.") - NOTICE_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 공지사항입니다."), - - @ExplainError("일반 게시판에서 공지사항을 수정하려 하거나, 그 반대의 경우 발생합니다.") - NOTICE_TYPE_NOT_MATCH(2304, HttpStatus.BAD_REQUEST, "공지사항은 공지사항 게시판에서 수정하세요."); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java deleted file mode 100644 index b42fb2d7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeNotFoundException extends BaseException { - public NoticeNotFoundException() { - super(NoticeErrorCode.NOTICE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java b/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java deleted file mode 100644 index 51a51bb8..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoticeTypeNotMatchException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoticeTypeNotMatchException extends BaseException { - public NoticeTypeNotMatchException() { - super(NoticeErrorCode.NOTICE_TYPE_NOT_MATCH); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java deleted file mode 100644 index 4acc286a..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/NoticeMapper.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE) -public interface NoticeMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "user", source = "user") - }) - Notice fromNoticeDto(NoticeDTO.Save dto, User user); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)") - }) - NoticeDTO.ResponseAll toAll(Notice notice, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "notice.user.name"), - @Mapping(target = "position", source = "notice.user.position"), - @Mapping(target = "role", source = "notice.user.role"), - @Mapping(target = "time", source = "notice.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); - - NoticeDTO.SaveResponse toSaveResponse(Notice notice); - -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java deleted file mode 100644 index b2dc0d40..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecase.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface NoticeUsecase { - NoticeDTO.SaveResponse save(NoticeDTO.Save dto, Long userId); - - NoticeDTO.Response findNotice(Long noticeId); - - Slice findNotices(int pageNumber, int pageSize); - - NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) throws UserNotMatchException; - - void delete(Long noticeId, Long userId) throws UserNotMatchException; - - Slice searchNotice(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java deleted file mode 100644 index ea1e6ceb..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.NoticeMapper; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.service.NoticeDeleteService; -import com.weeth.domain.board.domain.service.NoticeFindService; -import com.weeth.domain.board.domain.service.NoticeSaveService; -import com.weeth.domain.board.domain.service.NoticeUpdateService; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeUsecaseImpl implements NoticeUsecase { - - private final NoticeSaveService noticeSaveService; - private final NoticeFindService noticeFindService; - private final NoticeUpdateService noticeUpdateService; - private final NoticeDeleteService noticeDeleteService; - - private final UserGetService userGetService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - - private final NoticeMapper mapper; - private final GetCommentQueryService getCommentQueryService; - private final FileMapper fileMapper; - - @Override - @Transactional - public NoticeDTO.SaveResponse save(NoticeDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - Notice notice = mapper.fromNoticeDto(request, user); - Notice savedNotice = noticeSaveService.save(notice); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.NOTICE, savedNotice.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(savedNotice); - } - - @Override - public NoticeDTO.Response findNotice(Long noticeId) { - Notice notice = noticeFindService.find(noticeId); - - List response = getFiles(noticeId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toNoticeDto(notice, response, filterParentComments(notice.getComments())); - } - - @Override - public Slice findNotices(int pageNumber, int pageSize) { - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); // id를 기준으로 내림차순 - Slice notices = noticeFindService.findRecentNotices(pageable); - return notices.map(notice->mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - public Slice searchNotice(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice notices = noticeFindService.search(keyword, pageable); - - if (notices.isEmpty()){ - throw new NoSearchResultException(); - } - - return notices.map(notice -> mapper.toAll(notice, checkFileExistsByNotice(notice.id))); - } - - @Override - @Transactional - public NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) { - Notice notice = validateOwner(noticeId, userId); - - if (dto.files() != null) { - List fileList = getFiles(noticeId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, notice.getId()); - fileRepository.saveAll(files); - } - - noticeUpdateService.update(notice, dto); - - return mapper.toSaveResponse(notice); - } - - @Override - @Transactional - public void delete(Long noticeId, Long userId) { - validateOwner(noticeId, userId); - - List fileList = getFiles(noticeId); - fileRepository.deleteAll(fileList); - - noticeDeleteService.delete(noticeId); - } - - private List getFiles(Long noticeId) { - return fileReader.findAll(FileOwnerType.NOTICE, noticeId, null); - } - - private Notice validateOwner(Long noticeId, Long userId) { - Notice notice = noticeFindService.find(noticeId); - if (!notice.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return notice; - } - - private boolean checkFileExistsByNotice(Long noticeId){ - return fileReader.exists(FileOwnerType.NOTICE, noticeId, null); - } - - private List filterParentComments(List comments) { - return getCommentQueryService.toCommentTreeResponses(comments); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java b/src/main/java/com/weeth/domain/board/domain/entity/Notice.java deleted file mode 100644 index 0477a32a..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Notice.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -import java.util.List; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Notice extends Board { - - // Todo: OneToMany 매핑 제거 - @OneToMany(mappedBy = "notice", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(NoticeDTO.Update dto){ - this.updateUpperClass(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java deleted file mode 100644 index 42c615a9..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/NoticeRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import com.weeth.domain.board.domain.entity.Notice; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.query.Param; - -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; - -public interface NoticeRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("select n from Notice n where n.id = :id") - Notice findByIdWithLock(@Param("id") Long id); - - Slice findPageBy(Pageable page); - - @Query(""" - SELECT n FROM Notice n - WHERE (LOWER(n.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(n.content) LIKE LOWER(CONCAT('%', :kw, '%'))) - ORDER BY n.id DESC - """) - Slice search(@Param("kw") String kw, Pageable pageable); -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java deleted file mode 100644 index af8f72ec..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeDeleteService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeDeleteService { - - private final NoticeRepository noticeRepository; - - @Transactional - public void delete(Long noticeId) { - noticeRepository.deleteById(noticeId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java deleted file mode 100644 index 0bf77b58..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeFindService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.List; -import com.weeth.domain.board.application.exception.NoticeNotFoundException; -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeFindService { - - private final NoticeRepository noticeRepository; - - public Notice find(Long noticeId) { - return noticeRepository.findById(noticeId) - .orElseThrow(NoticeNotFoundException::new); - } - - public List find() { - return noticeRepository.findAll(); - } - - - public Slice findRecentNotices(Pageable pageable) { - return noticeRepository.findPageBy(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentNotices(pageable); - } - return noticeRepository.search(keyword.strip(), pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java deleted file mode 100644 index a0730dc1..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeSaveService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Notice; -import com.weeth.domain.board.domain.repository.NoticeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class NoticeSaveService { - - private final NoticeRepository noticeRepository; - - public Notice save(Notice notice){ - return noticeRepository.save(notice); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java deleted file mode 100644 index 0d28974e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/NoticeUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.domain.entity.Notice; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class NoticeUpdateService { - - public void update(Notice notice, NoticeDTO.Update dto){ - notice.update(dto); - } - -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java deleted file mode 100644 index 94b61a01..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeAdminController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "NOTICE ADMIN", description = "[ADMIN] 공지사항 어드민 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeAdminController { - - private final NoticeUsecase noticeUsecase; - - @PostMapping - @Operation(summary="공지사항 생성") - public CommonResponse save(@RequestBody @Valid NoticeDTO.Save dto, - @Parameter(hidden = true) @CurrentUser Long userId) { - NoticeDTO.SaveResponse response = noticeUsecase.save(dto, userId); - - return CommonResponse.success(NOTICE_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{noticeId}") - @Operation(summary="특정 공지사항 수정") - public CommonResponse update(@PathVariable Long noticeId, - @RequestBody @Valid NoticeDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - NoticeDTO.SaveResponse response = noticeUsecase.update(noticeId, dto, userId); - - return CommonResponse.success(NOTICE_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{noticeId}") - @Operation(summary="특정 공지사항 삭제") - public CommonResponse delete(@PathVariable Long noticeId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeUsecase.delete(noticeId, userId); - return CommonResponse.success(NOTICE_DELETED_SUCCESS); - } - -} diff --git a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java b/src/main/java/com/weeth/domain/board/presentation/NoticeController.java deleted file mode 100644 index 5ddeb287..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/NoticeController.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.weeth.domain.board.presentation; - - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.usecase.NoticeUsecase; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - - -@Tag(name = "NOTICE", description = "공지사항 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/notices") -@ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class}) -public class NoticeController { - - private final NoticeUsecase noticeUsecase; - - @GetMapping - @Operation(summary="공지사항 목록 조회 [무한스크롤]") - public CommonResponse> findNotices(@RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_FIND_ALL_SUCCESS, noticeUsecase.findNotices(pageNumber, pageSize)); - } - - @GetMapping("/{noticeId}") - @Operation(summary="특정 공지사항 조회") - public CommonResponse findNoticeById(@PathVariable Long noticeId) { - return CommonResponse.success(NOTICE_FIND_BY_ID_SUCCESS, noticeUsecase.findNotice(noticeId)); - } - - @GetMapping("/search") - @Operation(summary="공지사항 검색 [무한스크롤]") - public CommonResponse> findNotice(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(NOTICE_SEARCH_SUCCESS, noticeUsecase.searchNotice(keyword, pageNumber, pageSize)); - } -} diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt deleted file mode 100644 index c0c3f1bb..00000000 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/NoticeCommentUsecase.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.comment.application.usecase.command - -import com.weeth.domain.comment.application.dto.request.CommentSaveRequest -import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest - -interface NoticeCommentUsecase { - fun saveNoticeComment( - dto: CommentSaveRequest, - noticeId: Long, - userId: Long, - ) - - fun updateNoticeComment( - dto: CommentUpdateRequest, - noticeId: Long, - commentId: Long, - userId: Long, - ) - - fun deleteNoticeComment( - noticeId: Long, - commentId: Long, - userId: Long, - ) -} diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt deleted file mode 100644 index e47a35ab..00000000 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/NoticeCommentController.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.comment.presentation - -import com.weeth.domain.comment.application.dto.request.CommentSaveRequest -import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest -import com.weeth.domain.comment.application.exception.CommentErrorCode -import com.weeth.domain.comment.application.usecase.command.NoticeCommentUsecase -import com.weeth.global.auth.annotation.CurrentUser -import com.weeth.global.common.exception.ApiErrorCodeExample -import com.weeth.global.common.response.CommonResponse -import io.swagger.v3.oas.annotations.Operation -import io.swagger.v3.oas.annotations.Parameter -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@Tag(name = "COMMENT-NOTICE", description = "공지사항 댓글 API") -@RestController -@RequestMapping("/api/v1/notices/{noticeId}/comments") -@ApiErrorCodeExample(CommentErrorCode::class) -class NoticeCommentController( - private val noticeCommentUsecase: NoticeCommentUsecase, -) { - @PostMapping - @Operation(summary = "공지사항 댓글 작성") - fun saveNoticeComment( - @RequestBody @Valid dto: CommentSaveRequest, - @PathVariable noticeId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.saveNoticeComment(dto, noticeId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_CREATED_SUCCESS) - } - - @PatchMapping("/{commentId}") - @Operation( - summary = "공지사항 댓글 수정", - description = "files 규약: null=기존 첨부 유지, []=기존 첨부 전체 삭제, 배열 전달=전달 목록으로 교체", - ) - fun updateNoticeComment( - @RequestBody @Valid dto: CommentUpdateRequest, - @PathVariable noticeId: Long, - @PathVariable commentId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.updateNoticeComment(dto, noticeId, commentId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_UPDATED_SUCCESS) - } - - @DeleteMapping("/{commentId}") - @Operation(summary = "공지사항 댓글 삭제") - fun deleteNoticeComment( - @PathVariable noticeId: Long, - @PathVariable commentId: Long, - @Parameter(hidden = true) @CurrentUser userId: Long, - ): CommonResponse { - noticeCommentUsecase.deleteNoticeComment(noticeId, commentId, userId) - return CommonResponse.success(CommentResponseCode.COMMENT_DELETED_SUCCESS) - } -} diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt deleted file mode 100644 index b3960018..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.NoticeDTO -import com.weeth.domain.board.application.mapper.NoticeMapper -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.board.domain.service.NoticeDeleteService -import com.weeth.domain.board.domain.service.NoticeFindService -import com.weeth.domain.board.domain.service.NoticeSaveService -import com.weeth.domain.board.domain.service.NoticeUpdateService -import com.weeth.domain.board.fixture.NoticeTestFixture -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService -import com.weeth.domain.file.application.dto.request.FileSaveRequest -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.File -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.domain.vo.FileContentType -import com.weeth.domain.file.domain.vo.StorageKey -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Department -import com.weeth.domain.user.domain.entity.enums.Position -import com.weeth.domain.user.domain.entity.enums.Role -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort -import org.springframework.test.util.ReflectionTestUtils - -class NoticeUsecaseImplTest : - DescribeSpec({ - - val noticeSaveService = mockk(relaxUnitFun = true) - val noticeFindService = mockk() - val noticeUpdateService = mockk(relaxUnitFun = true) - val noticeDeleteService = mockk(relaxUnitFun = true) - val userGetService = mockk() - val fileRepository = mockk(relaxed = true) - val fileReader = mockk() - val noticeMapper = mockk() - val getCommentQueryService = mockk() - val fileMapper = mockk() - - val noticeUsecase = - NoticeUsecaseImpl( - noticeSaveService, - noticeFindService, - noticeUpdateService, - noticeDeleteService, - userGetService, - fileRepository, - fileReader, - noticeMapper, - getCommentQueryService, - fileMapper, - ) - - describe("findNotices") { - it("공지사항이 최신순으로 정렬된다") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i", user = user).also { - ReflectionTestUtils.setField(it, "id", (i + 1).toLong()) - } - } - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[4], notices[3], notices[2]), pageable, true) - - every { noticeFindService.findRecentNotices(any()) } returns slice - every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } returns false - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - false, - ) - } - - val noticeResponses = noticeUsecase.findNotices(0, 3) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - noticeResponses.hasNext().shouldBeTrue() - - verify(exactly = 1) { noticeFindService.findRecentNotices(pageable) } - } - } - - describe("searchNotice") { - it("공지사항 검색시 결과와 파일 존재여부가 정상적으로 반환") { - val user = - User - .builder() - .email("abc@test.com") - .name("홍길동") - .position(Position.BE) - .department(Department.SW) - .role(Role.USER) - .build() - - val notices = mutableListOf() - for (i in 0 until 3) { - val notice = NoticeTestFixture.createNotice(title = "공지$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - for (i in 3 until 6) { - val notice = NoticeTestFixture.createNotice(title = "검색$i", user = user) - ReflectionTestUtils.setField(notice, "id", (i + 1).toLong()) - notices.add(notice) - } - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - val slice = SliceImpl(listOf(notices[5], notices[4], notices[3]), pageable, false) - - every { noticeFindService.search(any(), any()) } returns slice - every { fileReader.exists(FileOwnerType.NOTICE, any(), null) } answers { - val noticeId = secondArg() - noticeId % 2 == 0L - } - every { noticeMapper.toAll(any(), any()) } answers { - val notice = firstArg() - val fileExists = secondArg() - NoticeDTO.ResponseAll( - notice.id, - notice.user?.name ?: "", - notice.user?.position ?: Position.BE, - notice.user?.role ?: Role.USER, - notice.title, - notice.content, - notice.createdAt, - notice.commentCount, - fileExists, - ) - } - - val noticeResponses = noticeUsecase.searchNotice("검색", 0, 5) - - noticeResponses.shouldNotBeNull() - noticeResponses.content shouldHaveSize 3 - noticeResponses.content.map { it.title() } shouldContainExactly - listOf(notices[5].title, notices[4].title, notices[3].title) - noticeResponses.hasNext().shouldBeFalse() - - noticeResponses.content[0].hasFile().shouldBeTrue() - noticeResponses.content[1].hasFile().shouldBeFalse() - - verify(exactly = 1) { noticeFindService.search("검색", pageable) } - } - } - - describe("update") { - it("공지사항 수정 시 기존 파일 삭제 후 새 파일로 업데이트된다") { - val noticeId = 1L - val userId = 1L - - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = noticeId, title = "기존 제목", user = user) - - val oldFile = - FileTestFixture.createFile( - 1L, - "old.pdf", - storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), - ownerType = FileOwnerType.NOTICE, - ownerId = noticeId, - contentType = FileContentType("application/pdf"), - ) - val oldFiles = listOf(oldFile) - - val dto = - NoticeDTO.Update( - "수정된 제목", - "수정된 내용", - listOf(FileSaveRequest("new.pdf", "NOTICE/2026-02/new.pdf", 100L, "application/pdf")), - ) - - val newFile = - FileTestFixture.createFile( - 2L, - "new.pdf", - storageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_old.pdf"), - ownerType = FileOwnerType.NOTICE, - ownerId = noticeId, - contentType = FileContentType("application/pdf"), - ) - val newFiles = listOf(newFile) - - val expectedResponse = NoticeDTO.SaveResponse(noticeId) - - every { noticeFindService.find(noticeId) } returns notice - every { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } returns oldFiles - every { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } returns newFiles - every { noticeMapper.toSaveResponse(notice) } returns expectedResponse - - val response = noticeUsecase.update(noticeId, dto, userId) - - response shouldBe expectedResponse - - verify { noticeFindService.find(noticeId) } - verify { fileReader.findAll(FileOwnerType.NOTICE, noticeId, null) } - verify { fileRepository.deleteAll(oldFiles) } - verify { fileMapper.toFileList(dto.files(), FileOwnerType.NOTICE, noticeId) } - verify { fileRepository.saveAll(newFiles) } - verify { noticeUpdateService.update(notice, dto) } - } - - it("공지사항 엔티티 update() 호출 시 제목과 내용이 변경된다") { - val userId = 1L - val user = UserTestFixture.createActiveUser1(userId) - val notice = NoticeTestFixture.createNotice(id = 1L, title = "기존 제목", user = user) - val dto = NoticeDTO.Update("수정된 제목", "수정된 내용", listOf()) - - notice.update(dto) - - notice.title shouldBe dto.title() - notice.content shouldBe dto.content() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt deleted file mode 100644 index fbcad847..00000000 --- a/src/test/kotlin/com/weeth/domain/board/domain/repository/NoticeRepositoryTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.weeth.domain.board.domain.repository - -import com.weeth.config.TestContainersConfig -import com.weeth.domain.board.fixture.NoticeTestFixture -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.shouldBe -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.context.annotation.Import -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Sort - -@DataJpaTest -@Import(TestContainersConfig::class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -class NoticeRepositoryTest( - private val noticeRepository: NoticeRepository, -) : DescribeSpec({ - - describe("findPageBy") { - it("공지 id 내림차순으로 조회") { - val notices = - (0 until 5).map { i -> - NoticeTestFixture.createNotice(title = "공지$i") - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")) - - val pagedNotices = noticeRepository.findPageBy(pageable) - - pagedNotices.size shouldBe 3 - pagedNotices.map { it.title } shouldContainExactly - listOf(notices[4].title, notices[3].title, notices[2].title) - pagedNotices.hasNext().shouldBeTrue() - } - } - - describe("search") { - it("검색어가 포함된 공지를 id 내림차순으로 조회") { - val notices = - (0 until 6).map { i -> - if (i % 2 == 0) { - NoticeTestFixture.createNotice(title = "공지$i") - } else { - NoticeTestFixture.createNotice(title = "검색$i") - } - } - noticeRepository.saveAll(notices) - - val pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")) - - val searchedNotices = noticeRepository.search("검색", pageable) - - searchedNotices.content shouldHaveSize 3 - searchedNotices.content.map { it.title } shouldContainExactly - listOf(notices[5].title, notices[3].title, notices[1].title) - searchedNotices.hasNext().shouldBeFalse() - } - } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt deleted file mode 100644 index da71720e..00000000 --- a/src/test/kotlin/com/weeth/domain/board/fixture/NoticeTestFixture.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.weeth.domain.board.fixture - -import com.weeth.domain.board.domain.entity.Notice -import com.weeth.domain.user.domain.entity.User - -object NoticeTestFixture { - fun createNotice( - id: Long? = null, - title: String, - user: User? = null, - ): Notice = - Notice - .builder() - .id(id) - .title(title) - .content("내용") - .user(user) - .comments(ArrayList()) - .commentCount(0) - .build() -} From 5d9361b958ea6a01b608d91b50a79e690661d10b Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:09:15 +0900 Subject: [PATCH 06/44] =?UTF-8?q?refactor:=20Board,=20Post=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/converter/PartListConverter.java | 30 ------ .../domain/board/domain/entity/Board.java | 83 ---------------- .../domain/board/domain/entity/Post.java | 76 --------------- .../board/domain/entity/enums/Category.java | 7 -- .../board/domain/entity/enums/Part.java | 9 -- .../domain/converter/BoardConfigConverter.kt | 9 ++ .../weeth/domain/board/domain/entity/Board.kt | 46 +++++++++ .../weeth/domain/board/domain/entity/Post.kt | 95 +++++++++++++++++++ .../board/domain/entity/enums/BoardType.kt | 8 ++ .../domain/board/domain/entity/enums/Part.kt | 9 ++ .../domain/board/domain/vo/BoardConfig.kt | 13 +++ .../converter/BoardConfigConverterTest.kt | 29 ++++++ .../board/domain/entity/BoardEntityTest.kt | 51 ++++++++++ 13 files changed, 260 insertions(+), 205 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Board.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/Post.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt diff --git a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java b/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java deleted file mode 100644 index 58872ffb..00000000 --- a/src/main/java/com/weeth/domain/board/domain/converter/PartListConverter.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.weeth.domain.board.domain.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; -import com.weeth.domain.board.domain.entity.enums.Part; - -@Converter -public class PartListConverter implements AttributeConverter, String> { - - private static final String DELIMITER = ","; - - @Override - public String convertToDatabaseColumn(List parts) { - - return parts.stream() - .map(Part::name) - .collect(Collectors.joining(DELIMITER)); - } - - @Override - public List convertToEntityAttribute(String dbData) { - - return Arrays.stream(dbData.split(DELIMITER)) - .map(Part::valueOf) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Board.java b/src/main/java/com/weeth/domain/board/domain/entity/Board.java deleted file mode 100644 index 37ea7de0..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Board.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import java.util.List; -import com.weeth.domain.board.application.dto.NoticeDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.global.common.entity.BaseEntity; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; - -@Getter -@MappedSuperclass -@EntityListeners(AuditingEntityListener.class) -@SuperBuilder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Slf4j -public class Board extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - public Long id; - - private String title; - - @Column(columnDefinition = "TEXT") - private String content; - - @ManyToOne - @JoinColumn(name = "user_id") - private User user; - - private Integer commentCount; - - @PrePersist - public void prePersist() { - commentCount = 0; - } - - public void decreaseCommentCount() { - if (commentCount > 0) { - commentCount--; - } - } - - public void increaseCommentCount() { - commentCount++; - } - - public void updateCommentCount(List comments) { - this.commentCount = (int) comments.stream() - .filter(comment -> !comment.getIsDeleted()) - .count(); - } - - public void updateUpperClass(NoticeDTO.Update dto) { - this.title = dto.title(); - this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.Update dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } - - public void updateUpperClass(PostDTO.UpdateEducation dto) { - if (dto.title() != null) this.title = dto.title(); - if (dto.content() != null) this.content = dto.content(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/Post.java b/src/main/java/com/weeth/domain/board/domain/entity/Post.java deleted file mode 100644 index 4bcb9ffc..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/Post.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.weeth.domain.board.domain.entity; - -import com.fasterxml.jackson.annotation.JsonManagedReference; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.OneToMany; -import java.util.ArrayList; -import java.util.List; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.converter.PartListConverter; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.domain.entity.Comment; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@SuperBuilder -public class Post extends Board { - - @Column - private String studyName; - - @Column(nullable = false) - private int cardinalNumber; - - @Column(nullable=false) - private int week; - - @Enumerated(EnumType.STRING) - private Part part; - - @Column(nullable = false, columnDefinition = "varchar(255)") - @Convert(converter = PartListConverter.class) - private List parts = new ArrayList<>(); - - @Enumerated(EnumType.STRING) - private Category category; - - @OneToMany(mappedBy = "post", orphanRemoval = true) - @JsonManagedReference - private List comments; - - public void updateCommentCount() { - this.updateCommentCount(this.comments); - } - - public void addComment(Comment comment) { - comments.add(comment); - } - - public void update(PostDTO.Update dto) { - this.updateUpperClass(dto); - if (dto.studyName() != null) this.studyName = dto.studyName(); - if (dto.week() != null) this.week = dto.week(); - if (dto.part() != null) { - this.part = dto.part(); - this.parts = List.of(dto.part()); - } - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } - - public void updateEducation(PostDTO.UpdateEducation dto) { - this.updateUpperClass(dto); - this.part = null; - if (dto.parts() != null) this.parts = dto.parts(); - if (dto.cardinalNumber() != null) this.cardinalNumber = dto.cardinalNumber(); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java deleted file mode 100644 index 64c59a44..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Category.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Category { - StudyLog, - Article, - Education -} diff --git a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java b/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java deleted file mode 100644 index 1af83a71..00000000 --- a/src/main/java/com/weeth/domain/board/domain/entity/enums/Part.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.domain.entity.enums; - -public enum Part { - D, - BE, - FE, - PM, - ALL -} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt new file mode 100644 index 00000000..057fa9da --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverter.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.global.common.converter.JsonConverter +import jakarta.persistence.Converter + +@Converter +class BoardConfigConverter : JsonConverter(object : TypeReference() {}) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt new file mode 100644 index 00000000..4fccabb6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -0,0 +1,46 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.converter.BoardConfigConverter +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Convert +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "board") +class Board( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @Column(nullable = false) + var name: String, + @Enumerated(EnumType.STRING) + @Column(nullable = false) + val type: BoardType, + @Column(columnDefinition = "JSON") // Json 속성 사용으로 인한 커스텀 컨버터 적용 + @Convert(converter = BoardConfigConverter::class) + var config: BoardConfig = BoardConfig(), +) : BaseEntity() { + val isCommentEnabled: Boolean + get() = config.commentEnabled + + val isAdminOnly: Boolean + get() = config.writePermission == BoardConfig.WritePermission.ADMIN + + fun updateConfig(newConfig: BoardConfig) { + config = newConfig + } + + fun rename(newName: String) { + require(newName.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다." } + name = newName + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt new file mode 100644 index 00000000..7dac2310 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -0,0 +1,95 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.user.domain.entity.User +import com.weeth.global.common.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.FetchType +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table + +@Entity +@Table(name = "post") +class Post( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0, + @Column(nullable = false) + var title: String, + @Column(columnDefinition = "TEXT", nullable = false) + var content: String, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + val user: User, + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "board_id") + val board: Board, + @Column(nullable = false) + var commentCount: Int = 0, + @Column(nullable = false) + var likeCount: Int = 0, + @Column + var cardinalNumber: Int? = null, +) : BaseEntity() { + fun increaseCommentCount() { + commentCount++ + } + + fun decreaseCommentCount() { + check(commentCount > 0) { "comment count cannot be negative" } + commentCount-- + } + + fun increaseLikeCount() { + likeCount++ + } + + fun decreaseLikeCount() { + check(likeCount > 0) { "like count cannot be negative" } + likeCount-- + } + + fun updateContent( + newTitle: String, + newContent: String, + ) { + require(newTitle.isNotBlank()) { "title must not be blank" } + title = newTitle + content = newContent + } + + fun isOwnedBy(userId: Long): Boolean = user.id == userId + + fun update( + newTitle: String, + newContent: String, + newCardinalNumber: Int?, + ) { + updateContent(newTitle, newContent) + cardinalNumber = newCardinalNumber + } + + companion object { + fun create( + title: String, + content: String, + user: User, + board: Board, + cardinalNumber: Int? = null, + ): Post { + require(title.isNotBlank()) { "title must not be blank" } + require(content.isNotBlank()) { "content must not be blank" } + return Post( + title = title, + content = content, + user = user, + board = board, + cardinalNumber = cardinalNumber, + ) + } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt new file mode 100644 index 00000000..f992c924 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/BoardType.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.domain.entity.enums + +enum class BoardType { + NOTICE, + GALLERY, + GENERAL, + INFORMATION, +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt new file mode 100644 index 00000000..e6287a3e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/enums/Part.kt @@ -0,0 +1,9 @@ +package com.weeth.domain.board.domain.entity.enums + +enum class Part { + D, + BE, + FE, + PM, + ALL, +} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt new file mode 100644 index 00000000..39458115 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.board.domain.vo + +data class BoardConfig( + val commentEnabled: Boolean = true, + val writePermission: WritePermission = WritePermission.USER, + val isPrivate: Boolean = false, +) { + enum class WritePermission { + ADMIN, + USER, + } + +} diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt new file mode 100644 index 00000000..5b9a7c82 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.board.domain.converter + +import com.weeth.domain.board.domain.vo.BoardConfig +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class BoardConfigConverterTest : + StringSpec({ + val converter = BoardConfigConverter() + + "BoardConfig를 JSON 문자열로 변환하고 역직렬화한다" { + val config = + BoardConfig( + commentEnabled = false, + writePermission = BoardConfig.WritePermission.ADMIN, + isPrivate = true, + ) + + val json = converter.convertToDatabaseColumn(config) + val restored = converter.convertToEntityAttribute(json) + + restored shouldBe config + } + + "null DB 값은 null로 변환한다" { + converter.convertToEntityAttribute(null).shouldBeNull() + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt new file mode 100644 index 00000000..4ec457ae --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -0,0 +1,51 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class BoardEntityTest : + StringSpec({ + "isCommentEnabled는 config 값을 반영한다" { + val board = + Board( + id = 1L, + name = "공지사항", + type = BoardType.NOTICE, + config = BoardConfig(commentEnabled = false), + ) + + board.isCommentEnabled shouldBe false + } + + "rename은 빈 이름이면 예외를 던진다" { + val board = Board(id = 1L, name = "게시판", type = BoardType.GENERAL) + + shouldThrow { + board.rename(" ") + } + } + + "isAdminOnly는 writePermission이 ADMIN일 때 true를 반환한다" { + val board = + Board( + id = 2L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + ) + + board.isAdminOnly shouldBe true + } + + "updateConfig는 config를 교체한다" { + val board = Board(id = 3L, name = "일반", type = BoardType.GENERAL) + val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) + + board.updateConfig(newConfig) + + board.config shouldBe newConfig + } + }) From a23144319cca2802c128a8648bdb04b9c0d7590b Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:10:18 +0900 Subject: [PATCH 07/44] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/exception/BoardErrorCode.java | 25 ---------- .../CategoryAccessDeniedException.java | 9 ---- .../exception/NoSearchResultException.java | 9 ---- .../exception/PageNotFoundException.java | 9 ---- .../exception/PostNotFoundException.java | 9 ---- .../EducationAdminController.java | 46 ------------------- .../application/exception/BoardErrorCode.kt | 36 +++++++++++++++ .../exception/BoardNotFoundException.kt | 5 ++ .../CategoryAccessDeniedException.kt | 5 ++ .../exception/NoSearchResultException.kt | 5 ++ .../exception/PageNotFoundException.kt | 5 ++ .../exception/PostNotFoundException.kt | 5 ++ .../exception/PostNotOwnedException.kt | 5 ++ 13 files changed, 66 insertions(+), 107 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt diff --git a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java deleted file mode 100644 index d6a1851d..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/BoardErrorCode.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum BoardErrorCode implements ErrorCodeInterface { - - @ExplainError("검색 조건에 맞는 게시글이 하나도 없을 때 발생합니다.") - NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "일치하는 검색 결과를 찾을 수 없습니다."), - - @ExplainError("요청한 페이지 번호가 유효 범위를 벗어났을 때 발생합니다.") - PAGE_NOT_FOUND(2301, HttpStatus.NOT_FOUND, "존재하지 않는 페이지입니다."), - - @ExplainError("일반 유저가 어드민 전용 카테고리에 접근하려 할 때 발생합니다.") - CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "어드민 유저만 접근 가능한 카테고리입니다"); - - private final int code; - private final HttpStatus status; - private final String message; -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java b/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java deleted file mode 100644 index 3e67ae51..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class CategoryAccessDeniedException extends BaseException { - public CategoryAccessDeniedException() { - super(BoardErrorCode.CATEGORY_ACCESS_DENIED); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java b/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java deleted file mode 100644 index 475d7216..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/NoSearchResultException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class NoSearchResultException extends BaseException { - public NoSearchResultException() { - super(BoardErrorCode.NO_SEARCH_RESULT); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java deleted file mode 100644 index 7bcf73e7..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PageNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PageNotFoundException extends BaseException { - public PageNotFoundException() { - super(BoardErrorCode.PAGE_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java b/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java deleted file mode 100644 index 27e140b4..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.BaseException; - -public class PostNotFoundException extends BaseException { - public PostNotFoundException() { - super(PostErrorCode.POST_NOT_FOUND); - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java b/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java deleted file mode 100644 index 4af3cd1f..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/EducationAdminController.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.EDUCATION_UPDATED_SUCCESS; -import static com.weeth.domain.board.presentation.BoardResponseCode.POST_CREATED_SUCCESS; - -@Tag(name = "EDUCATION ADMIN", description = "[ADMIN] 공지사항 교육자료 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/admin/educations") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class EducationAdminController { - private final PostUsecase postUsecase; - - @PostMapping("/education") - @Operation(summary = "교육자료 생성") - public CommonResponse saveEducation(@RequestBody @Valid PostDTO.SaveEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.saveEducation(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @PatchMapping(value = "/{boardId}") - @Operation(summary="교육자료 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.UpdateEducation dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.updateEducation(boardId, dto, userId); - - return CommonResponse.success(EDUCATION_UPDATED_SUCCESS, response); - } -} diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt new file mode 100644 index 00000000..2328b874 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardErrorCode.kt @@ -0,0 +1,36 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.ErrorCodeInterface +import com.weeth.global.common.exception.ExplainError +import org.springframework.http.HttpStatus + +enum class BoardErrorCode( + private val code: Int, + private val status: HttpStatus, + private val message: String, +) : ErrorCodeInterface { + @ExplainError("검색 결과가 없을 때 발생합니다.") + NO_SEARCH_RESULT(2300, HttpStatus.NOT_FOUND, "검색 결과가 없습니다."), + + @ExplainError("유효하지 않은 페이지 번호를 요청할 때 발생합니다.") + PAGE_NOT_FOUND(2301, HttpStatus.BAD_REQUEST, "유효하지 않은 페이지입니다."), + + @ExplainError("ADMIN 전용 게시판에 일반 사용자가 글을 작성할 때 발생합니다.") + CATEGORY_ACCESS_DENIED(2302, HttpStatus.FORBIDDEN, "해당 카테고리에 대한 권한이 없습니다."), + + @ExplainError("게시판 ID로 조회했으나 해당 게시판이 존재하지 않을 때 발생합니다.") + BOARD_NOT_FOUND(2303, HttpStatus.NOT_FOUND, "존재하지 않는 게시판입니다."), + + @ExplainError("게시글 ID로 조회했으나 해당 게시글이 존재하지 않을 때 발생합니다.") + POST_NOT_FOUND(2304, HttpStatus.NOT_FOUND, "존재하지 않는 게시글입니다."), + + @ExplainError("게시글 작성자가 아닌 사용자가 수정/삭제를 시도할 때 발생합니다.") + POST_NOT_OWNED(2305, HttpStatus.FORBIDDEN, "게시글 작성자만 수정/삭제할 수 있습니다."), + ; + + override fun getCode(): Int = code + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt new file mode 100644 index 00000000..5bfd3f72 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/BoardNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class BoardNotFoundException : BaseException(BoardErrorCode.BOARD_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt new file mode 100644 index 00000000..4ef91e1e --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/CategoryAccessDeniedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class CategoryAccessDeniedException : BaseException(BoardErrorCode.CATEGORY_ACCESS_DENIED) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt new file mode 100644 index 00000000..0dd443b4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/NoSearchResultException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class NoSearchResultException : BaseException(BoardErrorCode.NO_SEARCH_RESULT) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt new file mode 100644 index 00000000..d14fd215 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PageNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PageNotFoundException : BaseException(BoardErrorCode.PAGE_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt new file mode 100644 index 00000000..1870190a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotFoundException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotFoundException : BaseException(BoardErrorCode.POST_NOT_FOUND) diff --git a/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt new file mode 100644 index 00000000..cbd1bb1c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/exception/PostNotOwnedException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.board.application.exception + +import com.weeth.global.common.exception.BaseException + +class PostNotOwnedException : BaseException(BoardErrorCode.POST_NOT_OWNED) From 460905918fca6d58f3901640d5f2c8233e803234 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:11:07 +0900 Subject: [PATCH 08/44] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/exception/PostErrorCode.java | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java diff --git a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java b/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java deleted file mode 100644 index 681dbb89..00000000 --- a/src/main/java/com/weeth/domain/board/application/exception/PostErrorCode.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.application.exception; - -import com.weeth.global.common.exception.ErrorCodeInterface; -import com.weeth.global.common.exception.ExplainError; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum PostErrorCode implements ErrorCodeInterface { - - @ExplainError("요청한 게시글 ID에 해당하는 게시글이 없을 때 발생합니다.") - POST_NOT_FOUND(2305, HttpStatus.NOT_FOUND, "존재하지 않는 게시물입니다."); - - private final int code; - private final HttpStatus status; - private final String message; -} From 3a76cd3dab1211deca07c687047706f914591f97 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:12:17 +0900 Subject: [PATCH 09/44] =?UTF-8?q?refactor:=20Dto=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/application/dto/PartPostDTO.java | 14 -- .../domain/board/application/dto/PostDTO.java | 131 ------------------ .../dto/request/CreatePostRequest.kt | 23 +++ .../dto/request/UpdatePostRequest.kt | 23 +++ .../dto/response/PostDetailResponse.kt | 29 ++++ .../dto/response/PostListResponse.kt | 27 ++++ .../dto/response/PostSaveResponse.kt | 8 ++ 7 files changed, 110 insertions(+), 145 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java delete mode 100644 src/main/java/com/weeth/domain/board/application/dto/PostDTO.java create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt diff --git a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java deleted file mode 100644 index 72c2680c..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PartPostDTO.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import jakarta.validation.constraints.NotNull; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; - -public record PartPostDTO( - @NotNull Part part, - @NotNull Category category, - Integer cardinalNumber, - Integer week, - String studyName -) { -} diff --git a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java b/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java deleted file mode 100644 index c0f26dd2..00000000 --- a/src/main/java/com/weeth/domain/board/application/dto/PostDTO.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.board.application.dto; - -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.file.application.dto.request.FileSaveRequest; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.enums.Position; -import com.weeth.domain.user.domain.entity.enums.Role; -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import lombok.Builder; - -import java.time.LocalDateTime; -import java.util.List; - -public class PostDTO { - - @Builder - public record Save( - @NotBlank(message = "제목 입력은 필수입니다.") String title, - @NotBlank(message = "내용 입력은 필수입니다.") String content, - @NotNull Category category, - String studyName, - int week, - @NotNull Part part, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveEducation( - @NotNull String title, - @NotNull String content, - @NotNull List parts, - @NotNull Integer cardinalNumber, - @Valid List<@NotNull FileSaveRequest> files - ) { - } - - @Builder - public record SaveResponse( - @Schema(description = "게시글 생성시 응답", example = "1") - long id - ) { - } - - @Builder - public record Update( - String title, - String content, - String studyName, - Integer week, - Part part, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record UpdateEducation( - String title, - String content, - List parts, - Integer cardinalNumber, - @Valid List files - ) { - } - - @Builder - public record Response( - Long id, - String name, - Position position, - Role role, - String title, - String content, - String studyName, - Integer week, - Integer cardinalNumber, - Part part, - List parts, - LocalDateTime time, - Integer commentCount, - List comments, - List fileUrls - ) { - } - - @Builder - public record ResponseAll( - Long id, - String name, - Part part, - Position position, - Role role, - String title, - String content, - String studyName, - int week, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - @Builder - public record ResponseEducationAll( - Long id, - String name, - List parts, - Position position, - Role role, - String title, - String content, - LocalDateTime time, - Integer commentCount, - boolean hasFile, - boolean isNew - ) { - } - - public record ResponseStudyNames( - List studyNames - ) { - } -} diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt new file mode 100644 index 00000000..e1f5805a --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreatePostRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreatePostRequest( + @field:Schema(description = "게시글 제목", example = "스터디 로그") + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:Schema(description = "게시글 내용", example = "내용입니다.") + @field:NotBlank + val content: String, + @field:Schema(description = "기수", nullable = true) + val cardinalNumber: Int? = null, + @field:Schema(description = "첨부 파일 목록", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt new file mode 100644 index 00000000..bb685e29 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdatePostRequest.kt @@ -0,0 +1,23 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class UpdatePostRequest( + @field:Schema(description = "게시글 제목") + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:Schema(description = "게시글 내용") + @field:NotBlank + val content: String, + @field:Schema(description = "기수", nullable = true) + val cardinalNumber: Int? = null, + @field:Schema(description = "첨부 파일 변경 규약: null=변경 안 함, []=전체 삭제, 배열 전달=해당 목록으로 교체", nullable = true) + @field:Valid + val files: List<@NotNull FileSaveRequest>? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt new file mode 100644 index 00000000..8be6f966 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -0,0 +1,29 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostDetailResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "작성자명") + val name: String, + @field:Schema(description = "작성자 역할") + val role: Role, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "댓글 목록") + val comments: List, + @field:Schema(description = "첨부 파일 목록") + val fileUrls: List, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt new file mode 100644 index 00000000..f54d1846 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -0,0 +1,27 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class PostListResponse( + @field:Schema(description = "게시글 ID") + val id: Long, + @field:Schema(description = "작성자명") + val name: String, + @field:Schema(description = "작성자 역할") + val role: Role, + @field:Schema(description = "제목") + val title: String, + @field:Schema(description = "내용") + val content: String, + @field:Schema(description = "수정 시각") + val time: LocalDateTime, + @field:Schema(description = "댓글 수") + val commentCount: Int, + @field:Schema(description = "파일 첨부 여부") + val hasFile: Boolean, + @field:Schema(description = "신규 게시글 여부 (24시간 이내)") + val isNew: Boolean, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt new file mode 100644 index 00000000..e13b78f0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostSaveResponse.kt @@ -0,0 +1,8 @@ +package com.weeth.domain.board.application.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PostSaveResponse( + @field:Schema(description = "게시글 ID", example = "1") + val id: Long, +) From b67693d8b3026d9028d49a434a76c5bb9a47d4e5 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:13:40 +0900 Subject: [PATCH 10/44] =?UTF-8?q?refactor:=20Repository=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/PostRepository.java | 131 ------------------ .../domain/repository/BoardRepository.kt | 6 + .../board/domain/repository/PostRepository.kt | 43 ++++++ .../domain/repository/FileRepositoryTest.kt | 6 +- 4 files changed, 52 insertions(+), 134 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt diff --git a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java b/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java deleted file mode 100644 index 20e2f949..00000000 --- a/src/main/java/com/weeth/domain/board/domain/repository/PostRepository.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.weeth.domain.board.domain.repository; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Lock; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.repository.query.Param; - -import java.util.Collection; -import java.util.List; - -public interface PostRepository extends JpaRepository { - - @Lock(LockModeType.PESSIMISTIC_WRITE) - @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("select p from Post p where p.id = :id") - Post findByIdWithLock(@Param("id") Long id); - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - ORDER BY p.id DESC - """) - Slice findRecentPart(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - ORDER BY p.id DESC - """) - Slice findRecentEducation(Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category IN ( - com.weeth.domain.board.domain.entity.enums.Category.StudyLog, - com.weeth.domain.board.domain.entity.enums.Category.Article - ) - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchPart(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT p FROM Post p - WHERE p.category = com.weeth.domain.board.domain.entity.enums.Category.Education - AND ( - LOWER(p.title) LIKE LOWER(CONCAT('%', :kw, '%')) - OR LOWER(p.content) LIKE LOWER(CONCAT('%', :kw, '%')) - ) - ORDER BY p.id DESC - """) - Slice searchEducation(@Param("kw") String kw, Pageable pageable); - - @Query(""" - SELECT DISTINCT p.studyName - FROM Post p - WHERE (:part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR p.part = :part) - AND p.studyName IS NOT NULL - ORDER BY p.studyName ASC - """) - List findDistinctStudyNamesByPart(@Param("part") Part part); - - @Query(""" - SELECT p - FROM Post p - WHERE (p.part = :part OR p.part = com.weeth.domain.board.domain.entity.enums.Part.ALL OR :part = com.weeth.domain.board.domain.entity.enums.Part.ALL - ) - AND (:category IS NULL OR p.category = :category) - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND (:studyName IS NULL OR p.studyName = :studyName) - AND (:week IS NULL OR p.week = :week) - ORDER BY p.id DESC - """) - Slice findByPartAndOptionalFilters(@Param("part") Part part, @Param("category") Category category, @Param("cardinal") Integer cardinal, @Param("studyName") String studyName, @Param("week") Integer week, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND (:cardinal IS NULL OR p.cardinalNumber = :cardinal) - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndOptionalCardinalWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber = :cardinal - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalNumberWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinal") Integer cardinal, Pageable pageable); - - @Query(""" - SELECT p - FROM Post p - WHERE p.category = :category - AND p.cardinalNumber IN :cardinals - AND ( - :partName = 'ALL' - OR FUNCTION('FIND_IN_SET', :partName, p.parts) > 0 - OR FUNCTION('FIND_IN_SET', 'ALL', p.parts) > 0 - ) - ORDER BY p.id DESC - """) - Slice findByCategoryAndCardinalInWithPart(@Param("partName") String partName, @Param("category") Category category, @Param("cardinals") Collection cardinals, Pageable pageable); -} diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt new file mode 100644 index 00000000..8e66fee5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -0,0 +1,6 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Board +import org.springframework.data.jpa.repository.JpaRepository + +interface BoardRepository : JpaRepository diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt new file mode 100644 index 00000000..52e9de42 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.board.domain.repository + +import com.weeth.domain.board.domain.entity.Post +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Slice +import org.springframework.data.jpa.repository.EntityGraph +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface PostRepository : JpaRepository { + @EntityGraph(attributePaths = ["user"]) + fun findAllByBoardId( + boardId: Long, + pageable: Pageable, + ): Slice + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query("SELECT p FROM Post p WHERE p.id = :id") + fun findByIdWithLock( + @Param("id") id: Long, + ): Post? + + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) + """, + ) + @EntityGraph(attributePaths = ["user"]) + fun searchByBoardId( + @Param("boardId") boardId: Long, + @Param("keyword") keyword: String, + pageable: Pageable, + ): Slice +} diff --git a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt index 1983e70f..ed3c34c8 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/repository/FileRepositoryTest.kt @@ -29,7 +29,7 @@ class FileRepositoryTest( fileRepository.save( createTestFile( fileName = "notice-image.png", - ownerType = FileOwnerType.NOTICE, + ownerType = FileOwnerType.POST, ownerId = 101L, status = FileStatus.UPLOADED, ), @@ -38,7 +38,7 @@ class FileRepositoryTest( val found = fileRepository.findById(saved.id).orElseThrow() found.fileName shouldBe "notice-image.png" - found.ownerType shouldBe FileOwnerType.NOTICE + found.ownerType shouldBe FileOwnerType.POST found.ownerId shouldBe 101L found.status shouldBe FileStatus.UPLOADED } @@ -50,7 +50,7 @@ class FileRepositoryTest( fileRepository.save(createTestFile("target-2.png", FileOwnerType.POST, 77L, FileStatus.UPLOADED)) fileRepository.save(createTestFile("deleted.png", FileOwnerType.POST, 77L, FileStatus.DELETED)) fileRepository.save(createTestFile("other-owner.png", FileOwnerType.POST, 78L, FileStatus.UPLOADED)) - fileRepository.save(createTestFile("other-type.png", FileOwnerType.NOTICE, 77L, FileStatus.UPLOADED)) + fileRepository.save(createTestFile("other-type.png", FileOwnerType.RECEIPT, 77L, FileStatus.UPLOADED)) val uploaded = fileRepository.findAll(FileOwnerType.POST, 77L, FileStatus.UPLOADED) val allStatus = fileRepository.findAll(FileOwnerType.POST, 77L, null) From 2967e4e8df93b4e6cfe706d17aa4f28e47b794cd Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:14:39 +0900 Subject: [PATCH 11/44] =?UTF-8?q?refactor:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/presentation/BoardResponseCode.java | 39 ------- .../board/presentation/PostController.java | 105 ------------------ .../board/presentation/BoardResponseCode.kt | 17 +++ .../board/presentation/PostController.kt | 94 ++++++++++++++++ 4 files changed, 111 insertions(+), 144 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java delete mode 100644 src/main/java/com/weeth/domain/board/presentation/PostController.java create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt diff --git a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java b/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java deleted file mode 100644 index 2ac54cad..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/BoardResponseCode.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.weeth.domain.board.presentation; -import com.weeth.global.common.response.ResponseCodeInterface; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public enum BoardResponseCode implements ResponseCodeInterface { - //NoticeAdminController 관련 - NOTICE_CREATED_SUCCESS(1300, HttpStatus.OK, "공지사항이 성공적으로 생성되었습니다."), - NOTICE_UPDATED_SUCCESS(1301, HttpStatus.OK, "공지사항이 성공적으로 수정되었습니다."), - NOTICE_DELETED_SUCCESS(1302, HttpStatus.OK, "공지사항이 성공적으로 삭제되었습니다."), - //NoticeController 관련 - NOTICE_FIND_ALL_SUCCESS(1303, HttpStatus.OK, "공지사항 목록이 성공적으로 조회되었습니다."), - NOTICE_FIND_BY_ID_SUCCESS(1304, HttpStatus.OK, "공지사항이 성공적으로 조회되었습니다."), - NOTICE_SEARCH_SUCCESS(1305, HttpStatus.OK, "공지사항 검색 결과가 성공적으로 조회되었습니다."), - //PostController 관련 - POST_CREATED_SUCCESS(1306, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), - POST_UPDATED_SUCCESS(1307, HttpStatus.OK, "파트 게시글이 성공적으로 수정되었습니다."), - POST_DELETED_SUCCESS(1308, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), - POST_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), - POST_PART_FIND_ALL_SUCCESS(1310, HttpStatus.OK, "파트별 게시글 목록이 성공적으로 조회되었습니다."), - POST_EDU_FIND_SUCCESS(1311, HttpStatus.OK, "교육 게시글 목록이 성공적으로 조회되었습니다."), - POST_FIND_BY_ID_SUCCESS(1312, HttpStatus.OK, "파트 게시글이 성공적으로 조회되었습니다."), - POST_SEARCH_SUCCESS(1313, HttpStatus.OK, "파트 게시글 검색 결과가 성공적으로 조회되었습니다."), - EDUCATION_SEARCH_SUCCESS(1314, HttpStatus.OK, "교육 자료 검색 결과가 성공적으로 조회되었습니다."), - POST_STUDY_NAMES_FIND_SUCCESS(1315, HttpStatus.OK, "스터디 이름 목록이 성공적으로 조회되었습니다."), - - EDUCATION_UPDATED_SUCCESS(1316, HttpStatus.OK, "교육자료가 성공적으로 수정되었습니다."); - - private final int code; - private final HttpStatus status; - private final String message; - - BoardResponseCode(int code, HttpStatus status, String message) { - this.code = code; - this.status = status; - this.message = message; - } -} diff --git a/src/main/java/com/weeth/domain/board/presentation/PostController.java b/src/main/java/com/weeth/domain/board/presentation/PostController.java deleted file mode 100644 index 633ce6a2..00000000 --- a/src/main/java/com/weeth/domain/board/presentation/PostController.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.weeth.domain.board.presentation; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; -import com.weeth.domain.board.application.usecase.PostUsecase; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.global.auth.annotation.CurrentUser; -import com.weeth.global.common.exception.ApiErrorCodeExample; -import com.weeth.global.common.response.CommonResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.*; - -import static com.weeth.domain.board.presentation.BoardResponseCode.*; - -@Tag(name = "BOARD", description = "게시판 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/board") -@ApiErrorCodeExample({BoardErrorCode.class, PostErrorCode.class}) -public class PostController { - - private final PostUsecase postUsecase; - - @PostMapping - @Operation(summary="파트 게시글 생성 (스터디 로그, 아티클)") - public CommonResponse save(@RequestBody @Valid PostDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { - PostDTO.SaveResponse response = postUsecase.save(dto, userId); - - return CommonResponse.success(POST_CREATED_SUCCESS, response); - } - - @GetMapping - @Operation(summary="게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPosts(@RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_FIND_ALL_SUCCESS, postUsecase.findPosts(pageNumber, pageSize)); - } - - @GetMapping("/part") - @Operation(summary="파트별 스터디 게시글 목록 조회 [무한스크롤]") - public CommonResponse> findPartPosts(@ModelAttribute @Valid PartPostDTO dto, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize) { - Slice response = postUsecase.findPartPosts(dto, pageNumber, pageSize); - - return CommonResponse.success(POST_PART_FIND_ALL_SUCCESS, response); - } - - @GetMapping("/education") - @Operation(summary="교육자료 조회 [무한스크롤]") - public CommonResponse> findEducationMaterials(@RequestParam Part part, @RequestParam(required = false) Integer cardinalNumber, @RequestParam("pageNumber") int pageNumber, @RequestParam("pageSize") int pageSize, @Parameter(hidden = true) @CurrentUser Long userId) { - - return CommonResponse.success(POST_EDU_FIND_SUCCESS, postUsecase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize)); - } - - @GetMapping("/{boardId}") - @Operation(summary="특정 게시글 조회") - public CommonResponse findPost(@PathVariable Long boardId) { - return CommonResponse.success(POST_FIND_BY_ID_SUCCESS, postUsecase.findPost(boardId)); - } - - @GetMapping("/part/studies") - @Operation(summary="파트별 스터디 이름 목록 조회") - public CommonResponse findStudyNames(@RequestParam Part part) { - - return CommonResponse.success(BoardResponseCode.POST_STUDY_NAMES_FIND_SUCCESS, postUsecase.findStudyNames(part)); - } - - @GetMapping("/search/part") - @Operation(summary="파트 게시글 검색 [무한스크롤]") - public CommonResponse> findPost(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(POST_SEARCH_SUCCESS, postUsecase.searchPost(keyword, pageNumber, pageSize)); - } - - @GetMapping("/search/education") - @Operation(summary="교육자료 검색 [무한스크롤]") - public CommonResponse> findEducation(@RequestParam String keyword, @RequestParam("pageNumber") int pageNumber, - @RequestParam("pageSize") int pageSize) { - return CommonResponse.success(EDUCATION_SEARCH_SUCCESS, postUsecase.searchEducation(keyword, pageNumber, pageSize)); - } - - @PatchMapping(value = "/{boardId}/part") - @Operation(summary="파트 게시글 수정") - public CommonResponse update(@PathVariable Long boardId, - @RequestBody @Valid PostDTO.Update dto, - @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - PostDTO.SaveResponse response = postUsecase.update(boardId, dto, userId); - - return CommonResponse.success(POST_UPDATED_SUCCESS, response); - } - - @DeleteMapping("/{boardId}") - @Operation(summary="특정 게시글 삭제") - public CommonResponse delete(@PathVariable Long boardId, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postUsecase.delete(boardId, userId); - return CommonResponse.success(POST_DELETED_SUCCESS); - } -} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt new file mode 100644 index 00000000..86543528 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.board.presentation + +import com.weeth.global.common.response.ResponseCodeInterface +import org.springframework.http.HttpStatus + +enum class BoardResponseCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ResponseCodeInterface { + POST_CREATED_SUCCESS(1301, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), + POST_UPDATED_SUCCESS(1302, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), + POST_DELETED_SUCCESS(1303, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), + POST_FIND_ALL_SUCCESS(1304, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), + POST_FIND_BY_ID_SUCCESS(1305, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), + POST_SEARCH_SUCCESS(1306, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt new file mode 100644 index 00000000..db616faf --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -0,0 +1,94 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManagePostUseCase +import com.weeth.domain.board.application.usecase.query.GetPostQueryService +import com.weeth.global.auth.annotation.CurrentUser +import com.weeth.global.auth.jwt.exception.JwtErrorCode +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Slice +import org.springframework.web.bind.annotation.DeleteMapping +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.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "BOARD", description = "게시판 API") +@RestController +@RequestMapping("/api/v4/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class PostController( + private val managePostUseCase: ManagePostUseCase, + private val getPostQueryService: GetPostQueryService, +) { + @PostMapping("/{boardId}/posts") + @Operation(summary = "게시글 작성") + fun save( + @PathVariable boardId: Long, + @RequestBody @Valid request: CreatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.POST_CREATED_SUCCESS, managePostUseCase.save(boardId, request, userId)) + + @GetMapping("/{boardId}/posts") + @Operation(summary = "게시글 목록 조회") + fun findPosts( + @PathVariable boardId: Long, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + ): CommonResponse> = + CommonResponse.success(BoardResponseCode.POST_FIND_ALL_SUCCESS, getPostQueryService.findPosts(boardId, pageNumber, pageSize)) + + @GetMapping("/posts/{postId}") + @Operation(summary = "게시글 상세 조회") + fun findPost( + @PathVariable postId: Long, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.POST_FIND_BY_ID_SUCCESS, getPostQueryService.findPost(postId)) + + @PatchMapping("/posts/{postId}") + @Operation(summary = "게시글 수정") + fun update( + @PathVariable postId: Long, + @RequestBody @Valid request: UpdatePostRequest, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.POST_UPDATED_SUCCESS, managePostUseCase.update(postId, request, userId)) + + @DeleteMapping("/posts/{postId}") + @Operation(summary = "게시글 삭제") + fun delete( + @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUser userId: Long, + ): CommonResponse { + managePostUseCase.delete(postId, userId) + return CommonResponse.success(BoardResponseCode.POST_DELETED_SUCCESS) + } + + @GetMapping("/{boardId}/posts/search") + @Operation(summary = "게시글 검색") + fun searchPosts( + @PathVariable boardId: Long, + @RequestParam keyword: String, + @RequestParam(defaultValue = "0") pageNumber: Int, + @RequestParam(defaultValue = "10") pageSize: Int, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_SEARCH_SUCCESS, + getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize), + ) +} From cfadd16d1b863ba7e8f0b6532705a5e198ddc92d Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:16:25 +0900 Subject: [PATCH 12/44] =?UTF-8?q?refactor:=20Comment=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageCommentUseCase.kt | 72 +----------------- .../domain/comment/domain/entity/Comment.kt | 25 +------ .../domain/repository/CommentReader.kt | 7 ++ .../domain/repository/CommentRepository.kt | 7 +- .../presentation/CommentResponseCode.kt | 3 - .../domain/entity/CommentEntityTest.kt | 73 +------------------ .../comment/fixture/CommentTestFixture.kt | 17 ----- 7 files changed, 13 insertions(+), 191 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt diff --git a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt index e5357bec..af074fdb 100644 --- a/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCase.kt @@ -1,10 +1,7 @@ package com.weeth.domain.comment.application.usecase.command -import com.weeth.domain.board.application.exception.NoticeNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.repository.NoticeRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest @@ -26,14 +23,11 @@ import org.springframework.transaction.annotation.Transactional class ManageCommentUseCase( private val commentRepository: CommentRepository, private val postRepository: PostRepository, - private val noticeRepository: NoticeRepository, private val userGetService: UserGetService, private val fileReader: FileReader, private val fileRepository: FileRepository, private val fileMapper: FileMapper, -) : PostCommentUsecase, - NoticeCommentUsecase { - // Todo: Board 도메인 리팩토링 후 단일 Post 대응으로 수정. +) : PostCommentUsecase { @Transactional override fun savePostComment( dto: CommentSaveRequest, @@ -88,61 +82,6 @@ class ManageCommentUseCase( post.decreaseCommentCount() } - @Transactional - override fun saveNoticeComment( - dto: CommentSaveRequest, - noticeId: Long, - userId: Long, - ) { - val user = userGetService.find(userId) - val notice = findNoticeWithLock(noticeId) - val parent = - dto.parentCommentId?.let { parentId -> - commentRepository.findByIdAndNoticeId(parentId, noticeId) ?: throw CommentNotFoundException() - } - - val comment = - Comment.createForNotice( - content = dto.content, - notice = notice, - user = user, - parent = parent, - ) - val savedComment = commentRepository.save(comment) - saveCommentFiles(savedComment, dto.files) - notice.increaseCommentCount() - } - - @Transactional - override fun updateNoticeComment( - dto: CommentUpdateRequest, - noticeId: Long, - commentId: Long, - userId: Long, - ) { - val comment = commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() - ensureOwner(comment, userId) - ensureNotDeleted(comment) - - comment.updateContent(dto.content) - replaceCommentFiles(comment, dto.files) - } - - @Transactional - override fun deleteNoticeComment( - noticeId: Long, - commentId: Long, - userId: Long, - ) { - val notice = findNoticeWithLock(noticeId) - val comment = - commentRepository.findByIdAndNoticeId(commentId, noticeId) ?: throw CommentNotFoundException() - ensureOwner(comment, userId) - - deleteComment(comment) - notice.decreaseCommentCount() - } - private fun saveCommentFiles( comment: Comment, files: List?, @@ -157,10 +96,6 @@ class ManageCommentUseCase( comment: Comment, files: List?, ) { - // 계약: - // files == null -> 첨부 유지(변경 안 함) - // files == [] -> 기존 첨부 전체 삭제 - // files == [...] -> 기존 첨부 삭제 후 전달 목록으로 교체 if (files == null) { return } @@ -174,14 +109,12 @@ class ManageCommentUseCase( throw CommentAlreadyDeletedException() } - // 자식 댓글이 없는 경우 -> 삭제 if (comment.children.isEmpty()) { deleteCommentFiles(comment) val parent = comment.parent val shouldDeleteParent = parent?.let { it.isDeleted && it.children.size == 1 } == true commentRepository.delete(comment) - // 부모 댓글이 삭제된 상태이고 유일한 자식이었던 경우 -> 부모 댓글도 삭제 if (shouldDeleteParent) { parent.let { deleteCommentFiles(it) @@ -191,7 +124,6 @@ class ManageCommentUseCase( return } - // 자식 댓글이 있는 경우 -> 댓글을 Soft Delete해 서비스에서 "삭제된 댓글"으로 표시 deleteCommentFiles(comment) comment.markAsDeleted() } @@ -222,6 +154,4 @@ class ManageCommentUseCase( } private fun findPostWithLock(postId: Long): Post = postRepository.findByIdWithLock(postId) ?: throw PostNotFoundException() - - private fun findNoticeWithLock(noticeId: Long): Notice = noticeRepository.findByIdWithLock(noticeId) ?: throw NoticeNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt index 491bbf0b..f070b906 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/entity/Comment.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.domain.entity -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.domain.vo.CommentContent import com.weeth.domain.user.domain.entity.User @@ -30,10 +29,7 @@ class Comment( var isDeleted: Boolean = false, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") - val post: Post? = null, // Todo: Board 도메인 리팩토링시 반영 - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "notice_id") - val notice: Notice? = null, // Todo: Board 도메인 리팩토링시 반영 + val post: Post, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") val user: User, @@ -65,7 +61,7 @@ class Comment( user: User, parent: Comment?, ): Comment { - require(parent == null || parent.post?.id == post.id) { + require(parent == null || parent.post.id == post.id) { "부모 댓글은 동일한 게시글에 존재해야 합니다." } return Comment( @@ -75,22 +71,5 @@ class Comment( parent = parent, ) } - - fun createForNotice( - content: String, - notice: Notice, - user: User, - parent: Comment?, - ): Comment { - require(parent == null || parent.notice?.id == notice.id) { - "부모 댓글은 동일한 공지글에 존재해야 합니다." - } - return Comment( - content = CommentContent.from(content).value, - notice = notice, - user = user, - parent = parent, - ) - } } } diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt new file mode 100644 index 00000000..81dc6a10 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentReader.kt @@ -0,0 +1,7 @@ +package com.weeth.domain.comment.domain.repository + +import com.weeth.domain.comment.domain.entity.Comment + +interface CommentReader { + fun findAllByPostId(postId: Long): List +} diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index e4ca6061..f19d67a4 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -3,14 +3,9 @@ package com.weeth.domain.comment.domain.repository import com.weeth.domain.comment.domain.entity.Comment import org.springframework.data.jpa.repository.JpaRepository -interface CommentRepository : JpaRepository { +interface CommentRepository : JpaRepository, CommentReader { fun findByIdAndPostId( id: Long, postId: Long, ): Comment? - - fun findByIdAndNoticeId( - id: Long, - noticeId: Long, - ): Comment? } diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt index 41d974e3..0a485e33 100644 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/CommentResponseCode.kt @@ -8,9 +8,6 @@ enum class CommentResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { - COMMENT_CREATED_SUCCESS(1400, HttpStatus.OK, "공지사항 댓글이 성공적으로 생성되었습니다."), - COMMENT_UPDATED_SUCCESS(1401, HttpStatus.OK, "공지사항 댓글이 성공적으로 수정되었습니다."), - COMMENT_DELETED_SUCCESS(1402, HttpStatus.OK, "공지사항 댓글이 성공적으로 삭제되었습니다."), POST_COMMENT_CREATED_SUCCESS(1403, HttpStatus.OK, "게시글 댓글이 성공적으로 생성되었습니다."), POST_COMMENT_UPDATED_SUCCESS(1404, HttpStatus.OK, "게시글 댓글이 성공적으로 수정되었습니다."), POST_COMMENT_DELETED_SUCCESS(1405, HttpStatus.OK, "게시글 댓글이 성공적으로 삭제되었습니다."), diff --git a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt index a2696047..43a51029 100644 --- a/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/domain/entity/CommentEntityTest.kt @@ -1,7 +1,5 @@ package com.weeth.domain.comment.domain.entity -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.fixture.NoticeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.fixture.CommentTestFixture import com.weeth.domain.user.fixture.UserTestFixture @@ -12,8 +10,7 @@ import io.kotest.matchers.shouldBe class CommentEntityTest : DescribeSpec({ val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) + val post = PostTestFixture.create(id = 10L, title = "title") describe("createForPost") { it("부모 없이 최상위 댓글을 생성한다") { @@ -25,15 +22,8 @@ class CommentEntityTest : comment.parent shouldBe null } - it("부모 댓글이 같은 게시글이면 대댓글로 생성된다") { - val parent = CommentTestFixture.createPostComment(id = 100L, post = post, user = user) - val child = Comment.createForPost(content = "대댓글", post = post, user = user, parent = parent) - - child.parent shouldBe parent - } - it("부모 댓글이 다른 게시글이면 예외를 던진다") { - val otherPost = PostTestFixture.createPost(id = 99L, title = "other", category = Category.StudyLog) + val otherPost = PostTestFixture.create(id = 99L, title = "other") val parent = CommentTestFixture.createPostComment(id = 100L, post = otherPost, user = user) shouldThrow { @@ -42,25 +32,6 @@ class CommentEntityTest : } } - describe("createForNotice") { - it("부모 없이 최상위 댓글을 생성한다") { - val comment = Comment.createForNotice(content = "내용", notice = notice, user = user, parent = null) - - comment.content shouldBe "내용" - comment.notice shouldBe notice - comment.parent shouldBe null - } - - it("부모 댓글이 다른 공지글이면 예외를 던진다") { - val otherNotice = NoticeTestFixture.createNotice(id = 99L, title = "other", user = user) - val parent = CommentTestFixture.createNoticeComment(id = 100L, notice = otherNotice, user = user) - - shouldThrow { - Comment.createForNotice(content = "대댓글", notice = notice, user = user, parent = parent) - } - } - } - describe("markAsDeleted") { it("isDeleted를 true로 바꾸고 내용을 대체 문구로 변경한다") { val comment = CommentTestFixture.createPostComment(post = post, user = user) @@ -71,44 +42,4 @@ class CommentEntityTest : comment.content shouldBe "삭제된 댓글입니다." } } - - describe("updateContent") { - it("내용을 새 값으로 변경한다") { - val comment = CommentTestFixture.createPostComment(content = "원래 내용", post = post, user = user) - - comment.updateContent("수정된 내용") - - comment.content shouldBe "수정된 내용" - } - - it("빈 문자열이면 예외를 던진다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - shouldThrow { - comment.updateContent("") - } - } - - it("300자를 초과하면 예외를 던진다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - shouldThrow { - comment.updateContent("a".repeat(301)) - } - } - } - - describe("isOwnedBy") { - it("작성자 ID가 일치하면 true를 반환한다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - comment.isOwnedBy(1L) shouldBe true - } - - it("작성자 ID가 다르면 false를 반환한다") { - val comment = CommentTestFixture.createPostComment(post = post, user = user) - - comment.isOwnedBy(99L) shouldBe false - } - } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt index 80f92c65..fc6481fb 100644 --- a/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/comment/fixture/CommentTestFixture.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.fixture -import com.weeth.domain.board.domain.entity.Notice import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.comment.domain.entity.Comment import com.weeth.domain.user.domain.entity.User @@ -21,20 +20,4 @@ object CommentTestFixture { parent = parent, isDeleted = isDeleted, ) - - fun createNoticeComment( - id: Long = 1L, - content: String = "테스트 댓글", - notice: Notice, - user: User, - parent: Comment? = null, - isDeleted: Boolean = false, - ) = Comment( - id = id, - content = content, - notice = notice, - user = user, - parent = parent, - isDeleted = isDeleted, - ) } From 4b187607ff9937b582018de1da4b2c58a92578b1 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:17:45 +0900 Subject: [PATCH 13/44] =?UTF-8?q?refactor:=20Comment=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/PostCommentController.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt index 98310946..0c138560 100644 --- a/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt +++ b/src/main/kotlin/com/weeth/domain/comment/presentation/PostCommentController.kt @@ -19,9 +19,9 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -@Tag(name = "COMMENT-BOARD", description = "게시판 댓글 API") +@Tag(name = "COMMENT-POST", description = "게시글 댓글 API") @RestController -@RequestMapping("/api/v1/board/{boardId}/comments") +@RequestMapping("/api/v1/posts/{postId}/comments") @ApiErrorCodeExample(CommentErrorCode::class) class PostCommentController( private val postCommentUsecase: PostCommentUsecase, @@ -30,10 +30,10 @@ class PostCommentController( @Operation(summary = "게시글 댓글 작성") fun savePostComment( @RequestBody @Valid dto: CommentSaveRequest, - @PathVariable boardId: Long, + @PathVariable postId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.savePostComment(dto, boardId, userId) + postCommentUsecase.savePostComment(dto, postId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_CREATED_SUCCESS) } @@ -44,22 +44,22 @@ class PostCommentController( ) fun updatePostComment( @RequestBody @Valid dto: CommentUpdateRequest, - @PathVariable boardId: Long, + @PathVariable postId: Long, @PathVariable commentId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.updatePostComment(dto, boardId, commentId, userId) + postCommentUsecase.updatePostComment(dto, postId, commentId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_UPDATED_SUCCESS) } @DeleteMapping("/{commentId}") @Operation(summary = "게시글 댓글 삭제") fun deletePostComment( - @PathVariable boardId: Long, + @PathVariable postId: Long, @PathVariable commentId: Long, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse { - postCommentUsecase.deletePostComment(boardId, commentId, userId) + postCommentUsecase.deletePostComment(postId, commentId, userId) return CommonResponse.success(CommentResponseCode.POST_COMMENT_DELETED_SUCCESS) } } From 36ba3187ef69becd977b0d5b4c981a0ca6b8f94a Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:17:55 +0900 Subject: [PATCH 14/44] =?UTF-8?q?refactor:=20File=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/domain/file/domain/entity/FileOwnerType.kt | 1 - .../com/weeth/domain/file/domain/repository/FileReader.kt | 7 +------ .../kotlin/com/weeth/domain/file/domain/entity/FileTest.kt | 4 ++-- .../com/weeth/domain/file/fixture/FileTestFixture.kt | 4 ++-- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt index a8028a3a..da114464 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/entity/FileOwnerType.kt @@ -2,7 +2,6 @@ package com.weeth.domain.file.domain.entity enum class FileOwnerType { POST, - NOTICE, COMMENT, RECEIPT, } diff --git a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt index c57e27e6..3c0969d6 100644 --- a/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt +++ b/src/main/kotlin/com/weeth/domain/file/domain/repository/FileReader.kt @@ -15,12 +15,7 @@ interface FileReader { ownerType: FileOwnerType, ownerIds: List, status: FileStatus? = FileStatus.UPLOADED, - ): List { - if (ownerIds.isEmpty()) { - return emptyList() - } - return ownerIds.flatMap { findAll(ownerType, it, status) } - } + ): List fun exists( ownerType: FileOwnerType, diff --git a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt index c30dfba0..11c169d0 100644 --- a/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt +++ b/src/test/kotlin/com/weeth/domain/file/domain/entity/FileTest.kt @@ -123,10 +123,10 @@ class FileTest : val file = File.createUploaded( fileName = "doc.pdf", - storageKey = "NOTICE/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_doc.pdf", fileSize = 100, contentType = "application/pdf", - ownerType = FileOwnerType.NOTICE, + ownerType = FileOwnerType.POST, ownerId = 2L, ) diff --git a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt index c7865ed1..3510d034 100644 --- a/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/file/fixture/FileTestFixture.kt @@ -9,9 +9,9 @@ object FileTestFixture { fun createFile( id: Long, fileName: String, - storageKey: StorageKey = StorageKey("NOTICE/2026-02/00000000-0000-0000-0000-000000000000_test.png"), + storageKey: StorageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_test.png"), fileSize: Long = 1024, - ownerType: FileOwnerType = FileOwnerType.NOTICE, + ownerType: FileOwnerType = FileOwnerType.POST, ownerId: Long = 1L, contentType: FileContentType = FileContentType("image/png"), ): File = From c69b6fb04bc8ae46a89047e925f1df6a9d24f675 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:18:20 +0900 Subject: [PATCH 15/44] =?UTF-8?q?feat:=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20Jso?= =?UTF-8?q?n=20Converter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/converter/JsonConverter.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt diff --git a/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt new file mode 100644 index 00000000..4cec9574 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/common/converter/JsonConverter.kt @@ -0,0 +1,21 @@ +package com.weeth.global.common.converter + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import jakarta.persistence.AttributeConverter + +abstract class JsonConverter( + private val typeRef: TypeReference, +) : AttributeConverter { + companion object { + private val objectMapper = + ObjectMapper().apply { + registerModule(KotlinModule.Builder().build()) + } + } + + override fun convertToDatabaseColumn(attribute: T?): String? = attribute?.let { objectMapper.writeValueAsString(it) } + + override fun convertToEntityAttribute(dbData: String?): T? = dbData?.let { objectMapper.readValue(it, typeRef) } +} From 071a714719f95e1e777287137741f3faf1411e52 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:19:25 +0900 Subject: [PATCH 16/44] =?UTF-8?q?refactor:=20Post=20Usecase=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManagePostUseCase.kt | 138 ++++++++++ .../usecase/query/GetPostQueryService.kt | 93 +++++++ .../usecase/command/ManagePostUseCaseTest.kt | 245 ++++++++++++++++++ .../usecase/query/GetPostQueryServiceTest.kt | 180 +++++++++++++ 4 files changed, 656 insertions(+) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt new file mode 100644 index 00000000..4f36b38c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -0,0 +1,138 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.exception.PostNotOwnedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.service.UserGetService +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManagePostUseCase( + private val postRepository: PostRepository, + private val boardRepository: BoardRepository, // 동일 도메인 + private val userGetService: UserGetService, + private val fileRepository: FileRepository, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + @Transactional + fun save( + boardId: Long, + request: CreatePostRequest, + userId: Long, + ): PostSaveResponse { + val user = userGetService.find(userId) // todo: Reader 인터페이스로 수정 + val board = findBoard(boardId) + checkWritePermission(board, user) + + val post = + Post.create( + title = request.title, + content = request.content, + user = user, + board = board, + cardinalNumber = request.cardinalNumber, + ) + + val savedPost = postRepository.save(post) + savePostFiles(savedPost, request.files) + return postMapper.toSaveResponse(savedPost) + } + + @Transactional + fun update( + postId: Long, + request: UpdatePostRequest, + userId: Long, + ): PostSaveResponse { + val post = findPost(postId) + validateOwner(post, userId) + + post.update( + newTitle = request.title, + newContent = request.content, + newCardinalNumber = request.cardinalNumber, + ) + + replacePostFiles(post, request.files) + return postMapper.toSaveResponse(post) + } + + @Transactional + fun delete( + postId: Long, + userId: Long, + ) { + val post = findPost(postId) + validateOwner(post, userId) + + markPostFilesDeleted(post.id) + postRepository.delete(post) + } + + private fun findBoard(boardId: Long): Board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + + private fun findPost(postId: Long): Post = postRepository.findByIdOrNull(postId) ?: throw PostNotFoundException() + + private fun validateOwner( + post: Post, + userId: Long, + ) { + if (!post.isOwnedBy(userId)) { + throw PostNotOwnedException() + } + } + + private fun checkWritePermission( + board: Board, + user: User, + ) { + if (board.isAdminOnly && user.role != Role.ADMIN) { + throw CategoryAccessDeniedException() + } + } + + private fun replacePostFiles( + post: Post, + files: List?, + ) { + if (files == null) { + return + } + markPostFilesDeleted(post.id) + savePostFiles(post, files) + } + + private fun savePostFiles( + post: Post, + files: List?, + ) { + val mappedFiles = fileMapper.toFileList(files, FileOwnerType.POST, post.id) + if (mappedFiles.isNotEmpty()) { + fileRepository.saveAll(mappedFiles) + } + } + + private fun markPostFilesDeleted(postId: Long) { + fileReader.findAll(FileOwnerType.POST, postId).forEach { it.markDeleted() } + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt new file mode 100644 index 00000000..3e40272b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -0,0 +1,93 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Slice +import org.springframework.data.domain.Sort +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class GetPostQueryService( + private val postRepository: PostRepository, + private val commentReader: CommentReader, + private val getCommentQueryService: GetCommentQueryService, + private val fileReader: FileReader, + private val fileMapper: FileMapper, + private val postMapper: PostMapper, +) { + fun findPost(postId: Long): PostDetailResponse { + val post = postRepository.findByIdOrNull(postId) ?: throw PostNotFoundException() + + val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) + val comments = commentReader.findAllByPostId(post.id) + val commentTree = getCommentQueryService.toCommentTreeResponses(comments) + + return postMapper.toDetailResponse(post, commentTree, files) + } + + fun findPosts( + boardId: Long, + pageNumber: Int, + pageSize: Int, + ): Slice { + validatePage(pageNumber) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.findAllByBoardId(boardId, pageable) + + val postIds = posts.content.map { it.id } + val fileExistsByPostId = buildFileExistsMap(postIds) + val now = LocalDateTime.now() + + return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + } + + fun searchPosts( + boardId: Long, + keyword: String, + pageNumber: Int, + pageSize: Int, + ): Slice { + validatePage(pageNumber) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) + + if (posts.isEmpty) { + throw NoSearchResultException() + } + + val postIds = posts.content.map { it.id } + val fileExistsByPostId = buildFileExistsMap(postIds) + val now = LocalDateTime.now() + + return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } + } + + private fun validatePage(pageNumber: Int) { + if (pageNumber < 0) { + throw PageNotFoundException() + } + } + + private fun buildFileExistsMap(postIds: List): Map { + if (postIds.isEmpty()) { + return emptyMap() + } + val filesGrouped = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } + return postIds.associateWith { filesGrouped.containsKey(it) } + } +} diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt new file mode 100644 index 00000000..766d016e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -0,0 +1,245 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreatePostRequest +import com.weeth.domain.board.application.dto.request.UpdatePostRequest +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.exception.CategoryAccessDeniedException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.file.application.dto.request.FileSaveRequest +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.service.UserGetService +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import java.util.Optional + +class ManagePostUseCaseTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val userGetService = mockk() + val fileRepository = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val useCase = + ManagePostUseCase( + postRepository, + boardRepository, + userGetService, + fileRepository, + fileReader, + fileMapper, + postMapper, + ) + + beforeTest { + clearMocks(postRepository, boardRepository, userGetService, fileRepository, fileReader, fileMapper, postMapper) + every { postRepository.save(any()) } answers { firstArg() } + every { fileMapper.toFileList(any(), any(), any()) } returns emptyList() + every { fileRepository.saveAll(any>()) } returns emptyList() + every { fileReader.findAll(any(), any(), any()) } returns emptyList() + every { postMapper.toSaveResponse(any()) } returns PostSaveResponse(1L) + every { fileRepository.delete(any()) } just runs + } + + describe("save") { + it("일반 게시판에서 게시글을 저장한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findById(10L) } returns Optional.of(board) + + val result = useCase.save(10L, request, 1L) + + result.id shouldBe 1L + verify(exactly = 1) { postRepository.save(any()) } + } + + it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = + Board( + id = 20L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findById(20L) } returns Optional.of(board) + + shouldThrow { + useCase.save(20L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + + it("cardinalNumber가 전달되면 게시글에 반영된다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 11L, name = "일반", type = BoardType.GENERAL) + val request = + CreatePostRequest( + title = "게시글", + content = "내용", + cardinalNumber = 6, + ) + + every { userGetService.find(1L) } returns user + every { boardRepository.findById(11L) } returns Optional.of(board) + + useCase.save(11L, request, 1L) + + verify { + postRepository.save( + match { + it.cardinalNumber == 6 + }, + ) + } + } + + it("존재하지 않는 boardId면 예외를 던진다") { + val user = UserTestFixture.createActiveUser1(1L) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findById(999L) } returns Optional.empty() + + shouldThrow { + useCase.save(999L, request, 1L) + } + } + } + + describe("update") { + it("files가 null이면 기존 파일을 유지한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post.create("제목", "내용", user, board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findById(1L) } returns Optional.of(post) + + useCase.update(1L, request, 1L) + + verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } + verify(exactly = 0) { fileRepository.saveAll(any>()) } + } + + it("files가 있으면 기존 파일을 soft delete 후 교체한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_old.png", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + val newFiles = + listOf( + File.createUploaded( + fileName = "new.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_new.png", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ), + ) + val request = + UpdatePostRequest( + title = "수정", + content = "수정", + files = + listOf( + FileSaveRequest( + "new.png", + "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_new.png", + 10, + "image/png", + ), + ), + ) + + every { postRepository.findById(1L) } returns Optional.of(post) + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + every { fileMapper.toFileList(request.files, FileOwnerType.POST, 1L) } returns newFiles + every { fileRepository.saveAll(newFiles) } returns newFiles + + useCase.update(1L, request, 1L) + + oldFile.status.name shouldBe "DELETED" + verify(exactly = 1) { fileRepository.saveAll(newFiles) } + } + } + + describe("delete") { + it("삭제 시 첨부 파일을 soft delete하고 게시글을 삭제한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) + val oldFile = + File.createUploaded( + fileName = "old.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_old.png", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ) + + every { postRepository.findById(1L) } returns Optional.of(post) + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + every { postRepository.delete(post) } just runs + + useCase.delete(1L, 1L) + + oldFile.status.name shouldBe "DELETED" + verify(exactly = 1) { postRepository.delete(post) } + } + } + + describe("owner validation") { + it("작성자가 아니면 수정 시 예외를 던진다") { + val owner = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = owner, board = board) + val request = UpdatePostRequest(title = "수정", content = "수정") + + every { postRepository.findById(1L) } returns Optional.of(post) + + shouldThrow { + useCase.update(1L, request, 2L) + } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt new file mode 100644 index 00000000..4f33c692 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -0,0 +1,180 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.exception.NoSearchResultException +import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.PostNotFoundException +import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.PostRepository +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService +import com.weeth.domain.comment.domain.repository.CommentReader +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.application.mapper.FileMapper +import com.weeth.domain.file.domain.entity.File +import com.weeth.domain.file.domain.entity.FileOwnerType +import com.weeth.domain.file.domain.entity.FileStatus +import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.fixture.UserTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.SliceImpl +import java.time.LocalDateTime +import java.util.Optional + +class GetPostQueryServiceTest : + DescribeSpec({ + val postRepository = mockk() + val commentReader = mockk() + val getCommentQueryService = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val queryService = + GetPostQueryService( + postRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + + beforeTest { + clearMocks( + postRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + } + + describe("findPost") { + it("존재하지 않는 게시글이면 예외를 던진다") { + every { postRepository.findById(1L) } returns Optional.empty() + + shouldThrow { + queryService.findPost(1L) + } + } + + it("댓글/파일을 포함한 상세 응답을 반환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board, commentCount = 1) + val comments = listOf(mockk()) + val fileResponses = + listOf( + FileResponse( + fileId = 1L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + val files = + listOf( + File.createUploaded( + fileName = "a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = 1L, + ), + ) + val detail = + com.weeth.domain.board.application.dto.response.PostDetailResponse( + id = 1L, + name = "적순", + role = Role.USER, + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 1, + comments = comments, + fileUrls = fileResponses, + ) + + every { postRepository.findById(1L) } returns Optional.of(post) + every { commentReader.findAllByPostId(1L) } returns emptyList() + every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns files + every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail + every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() + + val result = queryService.findPost(1L) + + result.id shouldBe 1L + result.comments.size shouldBe 1 + result.fileUrls.size shouldBe 1 + } + } + + describe("searchPosts") { + it("검색 결과가 없으면 예외를 던진다") { + val pageable = PageRequest.of(0, 10) + every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) + + shouldThrow { + queryService.searchPosts(1L, "키워드", 0, 10) + } + } + } + + describe("validatePage") { + it("음수 페이지면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, -1, 10) + } + } + } + + describe("findPosts") { + it("목록 조회 시 mapper를 통해 응답으로 변환한다") { + val user = UserTestFixture.createActiveUser1(1L) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val post = Post(id = 10L, title = "제목", content = "내용", user = user, board = board) + val pageable = PageRequest.of(0, 10) + val postSlice = SliceImpl(listOf(post), pageable, false) + val response = + com.weeth.domain.board.application.dto.response.PostListResponse( + id = 10L, + name = "적순", + role = Role.USER, + title = "제목", + content = "내용", + time = LocalDateTime.now(), + commentCount = 0, + hasFile = false, + isNew = false, + ) + + every { postRepository.findAllByBoardId(1L, any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { fileReader.findAll(FileOwnerType.POST, 10L, any()) } returns emptyList() + every { postMapper.toListResponse(any(), any(), any()) } returns response + + val result = queryService.findPosts(1L, 0, 10) + + result.content.size shouldBe 1 + result.content.first().id shouldBe 10L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(10L), any()) } + } + } + }) From b077df53a097f9112d4263c530bbdc60b8d73e18 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:19:50 +0900 Subject: [PATCH 17/44] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=A1=B4=20Post=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/PostUseCaseImpl.java | 265 ---------------- .../application/usecase/PostUsecase.java | 35 --- .../domain/service/PostDeleteService.java | 18 -- .../board/domain/service/PostFindService.java | 88 ------ .../board/domain/service/PostSaveService.java | 17 -- .../domain/service/PostUpdateService.java | 19 -- .../usecase/PostUseCaseImplTest.kt | 288 ------------------ 7 files changed, 730 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java delete mode 100644 src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostFindService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java delete mode 100644 src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java delete mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java deleted file mode 100644 index e0546d38..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ /dev/null @@ -1,265 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException; -import com.weeth.domain.board.application.exception.NoSearchResultException; -import com.weeth.domain.board.application.exception.PageNotFoundException; -import com.weeth.domain.board.application.mapper.PostMapper; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.service.PostDeleteService; -import com.weeth.domain.board.domain.service.PostFindService; -import com.weeth.domain.board.domain.service.PostSaveService; -import com.weeth.domain.board.domain.service.PostUpdateService; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService; -import com.weeth.domain.comment.domain.entity.Comment; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.file.application.mapper.FileMapper; -import com.weeth.domain.file.domain.entity.File; -import com.weeth.domain.file.domain.entity.FileOwnerType; -import com.weeth.domain.file.domain.repository.FileReader; -import com.weeth.domain.file.domain.repository.FileRepository; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import com.weeth.domain.user.domain.entity.User; -import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.CardinalGetService; -import com.weeth.domain.user.domain.service.UserCardinalGetService; -import com.weeth.domain.user.domain.service.UserGetService; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; - -@Service -@RequiredArgsConstructor -public class PostUseCaseImpl implements PostUsecase { - - private final PostSaveService postSaveService; - private final PostFindService postFindService; - private final PostUpdateService postUpdateService; - private final PostDeleteService postDeleteService; - - private final UserGetService userGetService; - private final UserCardinalGetService userCardinalGetService; - private final CardinalGetService cardinalGetService; - - private final FileRepository fileRepository; - private final FileReader fileReader; - - private final PostMapper mapper; - private final FileMapper fileMapper; - private final GetCommentQueryService getCommentQueryService; - - @Override - @Transactional - public PostDTO.SaveResponse save(PostDTO.Save request, Long userId) { - User user = userGetService.find(userId); - - if (request.category() == Category.Education - && !user.hasRole(Role.ADMIN)) { - throw new CategoryAccessDeniedException(); - } - - cardinalGetService.findByUserSide(request.cardinalNumber()); - Post post = mapper.fromPostDto(request, user); - Post savedPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, savedPost.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(savedPost); - } - - @Override - @Transactional - public PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId) { - User user = userGetService.find(userId); - - Post post = mapper.fromEducationDto(request, user); - Post saverPost = postSaveService.save(post); - - List files = fileMapper.toFileList(request.files(), FileOwnerType.POST, saverPost.getId()); - fileRepository.saveAll(files); - - return mapper.toSaveResponse(saverPost); - } - - @Override - public PostDTO.Response findPost(Long postId) { - Post post = postFindService.find(postId); - - List response = getFiles(postId).stream() - .map(fileMapper::toFileResponse) - .toList(); - - return mapper.toPostDto(post, response, filterParentComments(post.getComments())); - } - - @Override - public Slice findPosts(int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findRecentPosts(pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.findByPartAndOptionalFilters(dto.part(), dto.category(), dto.cardinalNumber(), dto.studyName(), dto.week(), pageable); - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize) { - User user = userGetService.find(userId); - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - - if (user.hasRole(Role.ADMIN)) { - - return postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) - .map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - if (cardinalNumber != null) { - if (userCardinalGetService.notContains(user, cardinalGetService.findByUserSide(cardinalNumber))) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinal(part, cardinalNumber, pageable); - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - List userCardinals = userCardinalGetService.getCardinalNumbers(user); - if (userCardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - Slice posts = postFindService.findEducationByCardinals(part, userCardinals, pageable); - - return posts.map(post -> mapper.toEducationAll(post, checkFileExistsByPost(post.getId()))); - } - - @Override - public PostDTO.ResponseStudyNames findStudyNames(Part part) { - List names = postFindService.findByPart(part); - - return mapper.toStudyNames(names); - } - - @Override - public Slice searchPost(String keyword, int pageNumber, int pageSize){ - validatePageNumber(pageNumber); - - keyword = keyword.strip(); // 문자열 앞뒤 공백 제거 - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.search(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toAll(post, checkFileExistsByPost(post.id))); - } - - @Override - public Slice searchEducation(String keyword, int pageNumber, int pageSize) { - validatePageNumber(pageNumber); - - keyword = keyword.strip(); - - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - Slice posts = postFindService.searchEducation(keyword, pageable); - - if(posts.isEmpty()){ - throw new NoSearchResultException(); - } - - return posts.map(post->mapper.toEducationAll(post, checkFileExistsByPost(post.id))); - } - - @Override - @Transactional - public PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); - fileRepository.saveAll(files); - } - - postUpdateService.update(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) { - Post post = validateOwner(postId, userId); - - if (dto.files() != null) { - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - List files = fileMapper.toFileList(dto.files(), FileOwnerType.POST, post.getId()); - fileRepository.saveAll(files); - } - - postUpdateService.updateEducation(post, dto); - - return mapper.toSaveResponse(post); - } - - @Override - @Transactional - public void delete(Long postId, Long userId) { - validateOwner(postId, userId); - - List fileList = getFiles(postId); - fileRepository.deleteAll(fileList); - - postDeleteService.delete(postId); - } - - private List getFiles(Long postId) { - return fileReader.findAll(FileOwnerType.POST, postId, null); - } - - private Post validateOwner(Long postId, Long userId) { - Post post = postFindService.find(postId); - - if (!post.getUser().getId().equals(userId)) { - throw new UserNotMatchException(); - } - return post; - } - - public boolean checkFileExistsByPost(Long postId){ - return fileReader.exists(FileOwnerType.POST, postId, null); - } - - private List filterParentComments(List comments) { - return getCommentQueryService.toCommentTreeResponses(comments); - } - - private void validatePageNumber(int pageNumber){ - if (pageNumber < 0) { - throw new PageNotFoundException(); - } - } - -} diff --git a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java b/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java deleted file mode 100644 index ea365a32..00000000 --- a/src/main/java/com/weeth/domain/board/application/usecase/PostUsecase.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.weeth.domain.board.application.usecase; - -import com.weeth.domain.board.application.dto.PartPostDTO; -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.user.application.exception.UserNotMatchException; -import org.springframework.data.domain.Slice; - - -public interface PostUsecase { - - PostDTO.SaveResponse save(PostDTO.Save request, Long userId); - - PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId); - - PostDTO.Response findPost(Long postId); - - Slice findPosts(int pageNumber, int pageSize); - - Slice findPartPosts(PartPostDTO dto, int pageNumber, int pageSize); - - Slice findEducationPosts(Long userId, Part part, Integer cardinalNumber, int pageNumber, int pageSize); - - PostDTO.ResponseStudyNames findStudyNames(Part part); - - PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) throws UserNotMatchException; - - PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) throws UserNotMatchException; - - void delete(Long postId, Long userId) throws UserNotMatchException; - - Slice searchPost(String keyword, int pageNumber, int pageSize); - - Slice searchEducation(String keyword, int pageNumber, int pageSize); -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java b/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java deleted file mode 100644 index 25266a05..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostDeleteService.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import jakarta.transaction.Transactional; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostDeleteService { - - private final PostRepository postRepository; - - public void delete(Long postId) { - postRepository.deleteById(postId); - } - -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java b/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java deleted file mode 100644 index f813c135..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostFindService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import com.weeth.domain.board.application.exception.PostNotFoundException; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.entity.enums.Category; -import com.weeth.domain.board.domain.entity.enums.Part; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostFindService { - - private final PostRepository postRepository; - - public Post find(Long postId){ - return postRepository.findById(postId) - .orElseThrow(PostNotFoundException::new); - } - - public List find(){ - return postRepository.findAll(); - } - - public List findByPart(Part part) { - return postRepository.findDistinctStudyNamesByPart(part); - } - - public Slice findRecentPosts(Pageable pageable) { - return postRepository.findRecentPart(pageable); - } - - public Slice findRecentEducationPosts(Pageable pageable) { - return postRepository.findRecentEducation(pageable); - } - - public Slice search(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentPosts(pageable); - } - return postRepository.searchPart(keyword.strip(), pageable); - } - - public Slice searchEducation(String keyword, Pageable pageable) { - if(keyword == null || keyword.isEmpty()){ - return findRecentEducationPosts(pageable); - } - return postRepository.searchEducation(keyword.strip(), pageable); - } - - public Slice findByPartAndOptionalFilters(Part part, Category category, Integer cardinalNumber, String studyName, Integer week, Pageable pageable) { - - return postRepository.findByPartAndOptionalFilters( - part, category, cardinalNumber, studyName, week, pageable - ); - } - - public Slice findEducationByCardinals(Part part, Collection cardinals, Pageable pageable) { - if (cardinals == null || cardinals.isEmpty()) { - return new SliceImpl<>(Collections.emptyList(), pageable, false); - } - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalInWithPart(partName, Category.Education, cardinals, pageable); - } - - public Slice findEducationByCardinal(Part part, int cardinalNumber, Pageable pageable) { - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndCardinalNumberWithPart(partName, Category.Education, cardinalNumber, pageable); - } - - public Slice findByCategory(Part part, Category category, Integer cardinal, int pageNumber, int pageSize) { - Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")); - String partName = (part != null ? part.name() : Part.ALL.name()); - - return postRepository.findByCategoryAndOptionalCardinalWithPart(partName, category, cardinal, pageable); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java b/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java deleted file mode 100644 index c1abc88e..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostSaveService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.board.domain.repository.PostRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostSaveService { - - private final PostRepository postRepository; - - public Post save(Post post) { - return postRepository.save(post); - } -} diff --git a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java b/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java deleted file mode 100644 index e5c95e93..00000000 --- a/src/main/java/com/weeth/domain/board/domain/service/PostUpdateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.weeth.domain.board.domain.service; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PostUpdateService { - - public void update(Post post, PostDTO.Update dto){ - post.update(dto); - } - - public void updateEducation(Post post, PostDTO.UpdateEducation dto){ - post.updateEducation(dto); - } -} diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt deleted file mode 100644 index d7028b35..00000000 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/PostUseCaseImplTest.kt +++ /dev/null @@ -1,288 +0,0 @@ -package com.weeth.domain.board.application.usecase - -import com.weeth.domain.board.application.dto.PartPostDTO -import com.weeth.domain.board.application.dto.PostDTO -import com.weeth.domain.board.application.exception.CategoryAccessDeniedException -import com.weeth.domain.board.application.mapper.PostMapper -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part -import com.weeth.domain.board.domain.service.PostDeleteService -import com.weeth.domain.board.domain.service.PostFindService -import com.weeth.domain.board.domain.service.PostSaveService -import com.weeth.domain.board.domain.service.PostUpdateService -import com.weeth.domain.board.fixture.PostTestFixture -import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService -import com.weeth.domain.file.application.mapper.FileMapper -import com.weeth.domain.file.domain.entity.FileOwnerType -import com.weeth.domain.file.domain.repository.FileReader -import com.weeth.domain.file.domain.repository.FileRepository -import com.weeth.domain.file.domain.vo.StorageKey -import com.weeth.domain.file.fixture.FileTestFixture -import com.weeth.domain.user.domain.service.CardinalGetService -import com.weeth.domain.user.domain.service.UserCardinalGetService -import com.weeth.domain.user.domain.service.UserGetService -import com.weeth.domain.user.fixture.CardinalTestFixture -import com.weeth.domain.user.fixture.UserTestFixture -import io.kotest.assertions.throwables.shouldNotThrowAny -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.DescribeSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.collections.shouldContainExactly -import io.kotest.matchers.collections.shouldHaveSize -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.springframework.data.domain.PageRequest -import org.springframework.data.domain.Pageable -import org.springframework.data.domain.SliceImpl -import org.springframework.data.domain.Sort - -class PostUseCaseImplTest : - DescribeSpec({ - - val postSaveService = mockk() - val postFindService = mockk() - val postUpdateService = mockk() - val postDeleteService = mockk() - val userGetService = mockk() - val userCardinalGetService = mockk() - val cardinalGetService = mockk() - val fileRepository = mockk(relaxed = true) - val fileReader = mockk() - val mapper = mockk() - val fileMapper = mockk() - val getCommentQueryService = mockk() - - val postUseCase = - PostUseCaseImpl( - postSaveService, - postFindService, - postUpdateService, - postDeleteService, - userGetService, - userCardinalGetService, - cardinalGetService, - fileRepository, - fileReader, - mapper, - fileMapper, - getCommentQueryService, - ) - - describe("saveEducation") { - it("교육 게시글 저장 성공") { - val userId = 1L - val postId = 1L - - val request = PostDTO.SaveEducation("제목1", "내용", listOf(Part.BE), 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(postId, "제목1", Category.Education) - - every { userGetService.find(userId) } returns user - every { mapper.fromEducationDto(request, user) } returns post - every { postSaveService.save(post) } returns post - every { fileMapper.toFileList(request.files(), FileOwnerType.POST, postId) } returns listOf() - every { mapper.toSaveResponse(post) } returns PostDTO.SaveResponse(postId) - - val response = postUseCase.saveEducation(request, userId) - - response.id() shouldBe postId - verify { userGetService.find(userId) } - verify { postSaveService.save(post) } - verify { mapper.toSaveResponse(post) } - } - } - - describe("save") { - context("관리자 권한이 없는 사용자가 교육 게시글 생성 시") { - it("예외를 던진다") { - val userId = 1L - val request = PostDTO.Save("제목", "내용", Category.Education, null, 1, Part.BE, 1, listOf()) - val user = UserTestFixture.createActiveUser1(1L) - - every { userGetService.find(userId) } returns user - - shouldThrow { - postUseCase.save(request, userId) - } - } - } - } - - describe("findPartPosts") { - it("특정 파트와 주차 조건으로 게시글 목록 조회 성공") { - val dto = PartPostDTO(Part.BE, Category.Education, 1, 2, "스터디1") - val pageNumber = 0 - val pageSize = 5 - val user = UserTestFixture.createActiveUser1() - - val post2 = - PostTestFixture.createEducationPost( - 2L, - user, - "게시글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post2)) - val response2 = PostTestFixture.createResponseAll(post2) - - every { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } returns postSlice - - every { mapper.toAll(post2, false) } returns response2 - every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false - - val result = postUseCase.findPartPosts(dto, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 1 - result.content[0].title() shouldBe "게시글2" - result.content[0].hasFile().shouldBeFalse() - - verify { - postFindService.findByPartAndOptionalFilters( - dto.part(), - dto.category(), - dto.cardinalNumber(), - dto.studyName(), - dto.week(), - PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")), - ) - } - } - } - - describe("findEducationPosts") { - it("관리자 권한 사용자가 교육 게시글 목록 조회 시 성공적으로 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 1 - val pageNumber = 0 - val pageSize = 5 - - val adminUser = UserTestFixture.createAdmin(userId) - - val post1 = - PostTestFixture.createEducationPost( - 1L, - adminUser, - "교육글1", - Category.Education, - listOf(Part.BE), - 1, - 1, - ) - val post2 = - PostTestFixture.createEducationPost( - 2L, - adminUser, - "교육글2", - Category.Education, - listOf(Part.BE), - 1, - 2, - ) - val postSlice = SliceImpl(listOf(post1, post2)) - - val response1 = PostTestFixture.createResponseEducationAll(post1, false) - val response2 = PostTestFixture.createResponseEducationAll(post2, false) - - every { userGetService.find(userId) } returns adminUser - every { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } returns postSlice - every { mapper.toEducationAll(post1, false) } returns response1 - every { mapper.toEducationAll(post2, false) } returns response2 - every { fileReader.exists(FileOwnerType.POST, post1.id, null) } returns false - every { fileReader.exists(FileOwnerType.POST, post2.id, null) } returns false - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content shouldHaveSize 2 - result.content.map { it.title() } shouldContainExactly listOf("교육글1", "교육글2") - - verify { postFindService.findByCategory(part, Category.Education, cardinalNumber, pageNumber, pageSize) } - verify { mapper.toEducationAll(post1, false) } - verify { mapper.toEducationAll(post2, false) } - } - - it("본인이 속하지 않은 교육 자료를 검색하면 빈 리스트를 반환한다") { - val userId = 1L - val part = Part.BE - val cardinalNumber = 3 - val pageNumber = 0 - val pageSize = 5 - - val user = UserTestFixture.createActiveUser1(userId) - val cardinal = CardinalTestFixture.createCardinal(cardinalNumber = 1, year = 2025, semester = 1) - - every { userGetService.find(userId) } returns user - every { cardinalGetService.findByUserSide(cardinalNumber) } returns cardinal - every { userCardinalGetService.notContains(user, cardinal) } returns true - - val result = postUseCase.findEducationPosts(userId, part, cardinalNumber, pageNumber, pageSize) - - result.shouldNotBeNull() - result.content.shouldBeEmpty() - result.hasNext().shouldBeFalse() - - verify { userGetService.find(userId) } - verify { cardinalGetService.findByUserSide(cardinalNumber) } - verify { userCardinalGetService.notContains(user, cardinal) } - verify(exactly = 0) { postFindService.findEducationByCardinal(any(), any(), any()) } - } - } - - describe("findStudyNames") { - it("스터디가 없을 시 예외가 발생하지 않는다") { - val part = Part.BE - val emptyNames = listOf() - val expectedResponse = PostDTO.ResponseStudyNames(emptyNames) - - every { postFindService.findByPart(part) } returns emptyNames - every { mapper.toStudyNames(emptyNames) } returns expectedResponse - - shouldNotThrowAny { - postUseCase.findStudyNames(part) - } - - verify { postFindService.findByPart(part) } - verify { mapper.toStudyNames(emptyNames) } - } - } - - describe("checkFileExistsByPost") { - it("파일이 존재하는 경우 true를 반환한다") { - val postId = 1L - val file = - FileTestFixture.createFile( - postId, - "파일1", - storageKey = StorageKey("POST/2026-02/00000000-0000-0000-0000-000000000000_url1"), - ownerType = FileOwnerType.POST, - ownerId = postId, - ) - - every { fileReader.exists(FileOwnerType.POST, postId, null) } returns true - - val fileExists = postUseCase.checkFileExistsByPost(postId) - - fileExists.shouldBeTrue() - verify { fileReader.exists(FileOwnerType.POST, postId, null) } - } - } - }) From 31223e479dfe0aa00511de5e4f33299e3e97b661 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:20:09 +0900 Subject: [PATCH 18/44] =?UTF-8?q?refactor:=20Post=20Mapper=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/application/mapper/PostMapper.java | 74 ------------- .../board/application/mapper/PostMapper.kt | 48 ++++++++ .../application/mapper/PostMapperTest.kt | 103 +++++++++++++----- 3 files changed, 122 insertions(+), 103 deletions(-) delete mode 100644 src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java create mode 100644 src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt diff --git a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java b/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java deleted file mode 100644 index db3924b5..00000000 --- a/src/main/java/com/weeth/domain/board/application/mapper/PostMapper.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.weeth.domain.board.application.mapper; - -import com.weeth.domain.board.application.dto.PostDTO; -import com.weeth.domain.board.domain.entity.Post; -import com.weeth.domain.comment.application.dto.response.CommentResponse; -import com.weeth.domain.comment.application.mapper.CommentMapper; -import com.weeth.domain.file.application.dto.response.FileResponse; -import com.weeth.domain.user.domain.entity.User; -import org.mapstruct.*; - -import java.util.List; - -@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, uses = CommentMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE, imports = { java.time.LocalDateTime.class }) -public interface PostMapper { - - @Mappings({ - @Mapping(target = "id", ignore = true), - @Mapping(target = "createdAt", ignore = true), - @Mapping(target = "modifiedAt", ignore = true), - @Mapping(target = "user", source = "user"), - @Mapping(target = "part", source = "dto.part"), - @Mapping(target = "parts", expression = "java(List.of(dto.part()))"), - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - }) - Post fromPostDto(PostDTO.Save dto, User user); - - @Mapping(target = "id", ignore = true) - @Mapping(target = "createdAt", ignore = true) - @Mapping(target = "modifiedAt", ignore = true) - @Mapping(target = "user", source = "user") - @Mapping(target = "part", ignore = true) - @Mapping(target = "parts", source = "dto.parts") - @Mapping(target = "cardinalNumber", source = "dto.cardinalNumber") - @Mapping(target = "category", constant = "Education") - Post fromEducationDto(PostDTO.SaveEducation dto, User user); - - PostDTO.SaveResponse toSaveResponse(Post post); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseAll toAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "id", source = "post.id"), - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "parts", source = "post.parts"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "commentCount", source = "post.commentCount"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "hasFile", expression = "java(fileExists)"), - @Mapping(target = "isNew", expression = "java(post.getCreatedAt().isAfter(LocalDateTime.now().minusHours(24)))") - }) - PostDTO.ResponseEducationAll toEducationAll(Post post, boolean fileExists); - - @Mappings({ - @Mapping(target = "name", source = "post.user.name"), - @Mapping(target = "position", source = "post.user.position"), - @Mapping(target = "role", source = "post.user.role"), - @Mapping(target = "time", source = "post.createdAt"), - @Mapping(target = "comments", source = "comments") - }) - PostDTO.Response toPostDto(Post post, List fileUrls, List comments); - - default PostDTO.ResponseStudyNames toStudyNames(List studyNames) { - return new PostDTO.ResponseStudyNames(studyNames); - } -} diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt new file mode 100644 index 00000000..061abace --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -0,0 +1,48 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.PostDetailResponse +import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.dto.response.PostSaveResponse +import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class PostMapper { + fun toSaveResponse(post: Post) = PostSaveResponse(id = post.id) + + fun toDetailResponse( + post: Post, + comments: List, + files: List, + ) = PostDetailResponse( + id = post.id, + name = post.user.name, + role = post.user.role, + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + comments = comments, + fileUrls = files, + ) + + fun toListResponse( + post: Post, + hasFile: Boolean, + now: LocalDateTime, + ) = PostListResponse( + id = post.id, + name = post.user.name, + role = post.user.role, + title = post.title, + content = post.content, + time = post.modifiedAt, + commentCount = post.commentCount, + hasFile = hasFile, + isNew = post.createdAt.isAfter(now.minusHours(24)), + ) + +} diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index f8cbe2d4..6722d09c 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -1,38 +1,83 @@ package com.weeth.domain.board.application.mapper import com.weeth.domain.board.domain.entity.Post +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.comment.application.dto.response.CommentResponse +import com.weeth.domain.file.application.dto.response.FileResponse +import com.weeth.domain.file.domain.entity.FileStatus import com.weeth.domain.user.domain.entity.User -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.nulls.shouldNotBeNull +import com.weeth.domain.user.domain.entity.enums.Position +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe -import org.mapstruct.factory.Mappers +import io.mockk.every +import io.mockk.mockk +import java.time.LocalDateTime class PostMapperTest : - StringSpec({ - - val mapper = Mappers.getMapper(PostMapper::class.java) - - "Post를 PostDTO.SaveResponse로 변환" { - val testUser = - User - .builder() - .id(1L) - .name("테스트유저") - .email("test@weeth.com") - .build() - - val testPost = - Post - .builder() - .id(1L) - .title("테스트 게시글") - .user(testUser) - .content("테스트 내용입니다.") - .build() - - val response = mapper.toSaveResponse(testPost) - - response.shouldNotBeNull() - response.id() shouldBe testPost.id + DescribeSpec({ + val mapper = PostMapper() + val now = LocalDateTime.now() + val user = mockk() + val post = mockk() + + every { user.name } returns "테스터" + every { user.position } returns Position.BE + every { user.role } returns Role.USER + + every { post.id } returns 1L + every { post.title } returns "제목" + every { post.content } returns "내용" + every { post.user } returns user + every { post.commentCount } returns 2 + every { post.createdAt } returns now.minusHours(1) + every { post.modifiedAt } returns now + + describe("toListResponse") { + it("24시간 이내 생성된 게시글은 isNew=true") { + val response = mapper.toListResponse(post, hasFile = true, now = now) + + response.id shouldBe 1L + response.hasFile shouldBe true + response.isNew shouldBe true + } } + + describe("toDetailResponse") { + it("댓글/파일 목록을 포함해 상세 응답으로 변환한다") { + val comments = + listOf( + CommentResponse( + id = 10L, + name = "댓글작성자", + position = Position.BE, + role = Role.USER, + content = "댓글", + time = LocalDateTime.now(), + fileUrls = emptyList(), + children = emptyList(), + ), + ) + val files = + listOf( + FileResponse( + fileId = 5L, + fileName = "a.png", + fileUrl = "https://cdn/a.png", + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_a.png", + fileSize = 100, + contentType = "image/png", + status = FileStatus.UPLOADED, + ), + ) + + val response = mapper.toDetailResponse(post, comments, files) + + response.id shouldBe 1L + response.commentCount shouldBe 2 + response.comments.size shouldBe 1 + response.fileUrls.size shouldBe 1 + } + } + }) From bf777d34b3526fa13837b8d821919f5043c8588a Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:20:22 +0900 Subject: [PATCH 19/44] refactor: TestFixture --- .../board/domain/entity/PostEntityTest.kt | 55 ++++++++++++ .../domain/board/fixture/BoardTestFixture.kt | 31 +++++++ .../domain/board/fixture/PostTestFixture.kt | 89 +++---------------- 3 files changed, 99 insertions(+), 76 deletions(-) create mode 100644 src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt new file mode 100644 index 00000000..4763185c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -0,0 +1,55 @@ +package com.weeth.domain.board.domain.entity + +import com.weeth.domain.board.fixture.PostTestFixture +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe + +class PostEntityTest : + StringSpec({ + "increaseCommentCount는 댓글 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseCommentCount() + + post.commentCount shouldBe 1 + } + + "decreaseCommentCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseCommentCount() + } + } + + "update는 게시글 필드를 갱신한다" { + val post = PostTestFixture.create() + + post.update( + newTitle = "변경", + newContent = "변경 내용", + newCardinalNumber = 7, + ) + + post.title shouldBe "변경" + post.cardinalNumber shouldBe 7 + } + + "increaseLikeCount는 좋아요 수를 1 증가시킨다" { + val post = PostTestFixture.create() + + post.increaseLikeCount() + + post.likeCount shouldBe 1 + } + + "decreaseLikeCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseLikeCount() + } + } + + }) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt new file mode 100644 index 00000000..7719083e --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -0,0 +1,31 @@ +package com.weeth.domain.board.fixture + +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.vo.BoardConfig + +object BoardTestFixture { + fun create( + id: Long = 1L, + name: String = "일반 게시판", + type: BoardType = BoardType.GENERAL, + config: BoardConfig = BoardConfig(), + ): Board = + Board( + id = id, + name = name, + type = type, + config = config, + ) + + fun createNoticeBoard( + id: Long = 2L, + name: String = "공지사항", + ): Board = + create( + id = id, + name = name, + type = BoardType.NOTICE, + config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + ) +} diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt index d7662651..2c6b6754 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/PostTestFixture.kt @@ -1,84 +1,21 @@ package com.weeth.domain.board.fixture -import com.weeth.domain.board.application.dto.PostDTO import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role -import java.time.LocalDateTime +import com.weeth.domain.user.fixture.UserTestFixture object PostTestFixture { - fun createPost( - id: Long, - title: String, - category: Category, + fun create( + id: Long = 2L, + title: String = "게시글", + content: String = "내용", + user: User = UserTestFixture.createActiveUser1(1L), ): Post = - Post - .builder() - .id(id) - .title(title) - .content("내용") - .comments(ArrayList()) - .commentCount(0) - .category(category) - .build() - - fun createEducationPost( - id: Long, - user: User, - title: String, - category: Category, - parts: List, - cardinalNumber: Int, - week: Int, - ): Post = - Post - .builder() - .id(id) - .user(user) - .title(title) - .content("내용") - .parts(parts) - .cardinalNumber(cardinalNumber) - .week(week) - .commentCount(0) - .category(Category.Education) - .comments(ArrayList()) - .build() - - fun createResponseAll(post: Post): PostDTO.ResponseAll = - PostDTO.ResponseAll - .builder() - .id(post.id) - .part(post.part) - .role(Role.USER) - .title(post.title) - .content(post.content) - .studyName(post.studyName) - .week(post.week) - .time(LocalDateTime.now()) - .commentCount(post.commentCount) - .hasFile(false) - .isNew(false) - .build() - - fun createResponseEducationAll( - post: Post, - fileExists: Boolean, - ): PostDTO.ResponseEducationAll = - PostDTO.ResponseEducationAll - .builder() - .id(post.id) - .name(post.user.name) - .parts(post.parts) - .position(post.user.position) - .role(post.user.role) - .title(post.title) - .content(post.content) - .time(post.createdAt) - .commentCount(post.commentCount) - .hasFile(fileExists) - .isNew(false) - .build() + Post( + id = id, + title = title, + content = content, + user = user, + board = BoardTestFixture.create(), + ) } From 452dc368ab062c101d5794aa834fcfd786355374 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:21:30 +0900 Subject: [PATCH 20/44] =?UTF-8?q?refactor:=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20Not?= =?UTF-8?q?ice=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/controller/ExceptionDocController.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java b/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java index 771f1457..95b7c199 100644 --- a/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java +++ b/src/main/java/com/weeth/global/common/controller/ExceptionDocController.java @@ -5,8 +5,6 @@ import com.weeth.domain.account.application.exception.AccountErrorCode; import com.weeth.domain.attendance.application.exception.AttendanceErrorCode; import com.weeth.domain.board.application.exception.BoardErrorCode; -import com.weeth.domain.board.application.exception.NoticeErrorCode; -import com.weeth.domain.board.application.exception.PostErrorCode; import com.weeth.domain.comment.application.exception.CommentErrorCode; import com.weeth.domain.penalty.application.exception.PenaltyErrorCode; import com.weeth.domain.schedule.application.exception.EventErrorCode; @@ -37,7 +35,7 @@ public void attendanceErrorCodes() { @GetMapping("/board") @Operation(summary = "Board 도메인 에러 코드 목록") - @ApiErrorCodeExample({BoardErrorCode.class, NoticeErrorCode.class, PostErrorCode.class, CommentErrorCode.class}) + @ApiErrorCodeExample({BoardErrorCode.class, CommentErrorCode.class}) public void boardErrorCodes() { } @@ -59,7 +57,6 @@ public void scheduleErrorCodes() { public void userErrorCodes() { } - //todo: SAS 관련 예외도 추가 @GetMapping("/auth") @Operation(summary = "인증/인가 에러 코드 목록") @ApiErrorCodeExample({JwtErrorCode.class}) From 68f580c1c6c4a9bc4a802b8961a2bf125f3ee2d2 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Wed, 18 Feb 2026 22:29:02 +0900 Subject: [PATCH 21/44] =?UTF-8?q?refactor:=20Comment=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=82=AC=ED=95=AD=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/CommentConcurrencyTest.kt | 49 +-- .../command/ManageCommentUseCaseTest.kt | 389 ++---------------- .../query/CommentQueryPerformanceTest.kt | 71 ++-- .../query/GetCommentQueryServiceTest.kt | 33 +- 4 files changed, 106 insertions(+), 436 deletions(-) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 4f746bea..8c2b6ac9 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -2,9 +2,10 @@ package com.weeth.domain.comment.application.usecase.command import com.weeth.config.QueryCountUtil import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.domain.entity.Comment @@ -32,6 +33,7 @@ import java.util.concurrent.atomic.AtomicReference @Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) class CommentConcurrencyTest( private val postCommentUsecase: PostCommentUsecase, + private val boardRepository: BoardRepository, private val postRepository: PostRepository, private val userRepository: UserRepository, private val commentRepository: CommentRepository, @@ -62,28 +64,33 @@ class CommentConcurrencyTest( ) } - fun createPost(title: String): Post = - postRepository.save( - Post - .builder() - .title(title) - .content("내용") - .comments(ArrayList()) - .commentCount(0) - .category(Category.StudyLog) - .cardinalNumber(1) - .week(1) - .part(Part.ALL) - .parts(listOf(Part.ALL)) - .build(), + fun createPost( + title: String, + user: User, + ): Post { + val board = + boardRepository.save( + Board( + name = "concurrency-board", + type = BoardType.GENERAL, + ), + ) + return postRepository.save( + Post( + title = title, + content = "내용", + user = user, + board = board, + ), ) + } fun runConcurrentSave( threadCount: Int, saveAction: (postId: Long, userId: Long, index: Int) -> Unit, ): ConcurrencyResult { val users = createUsers(threadCount) - val post = createPost("동시성 테스트 게시글") + val post = createPost("동시성 테스트 게시글", users.first()) val executor = Executors.newFixedThreadPool(threadCount) val latch = CountDownLatch(threadCount) val successCount = AtomicInteger(0) @@ -135,6 +142,7 @@ class CommentConcurrencyTest( afterEach { commentRepository.deleteAllInBatch() postRepository.deleteAllInBatch() + boardRepository.deleteAllInBatch() userRepository.deleteAllInBatch() } @@ -158,8 +166,6 @@ class CommentConcurrencyTest( } describe("동시성 해소 방식별 성능 비교") { - // TODO(board-refactor): Board 도메인 구조 개편(댓글 카운트 책임/저장 구조 변경) 이후 - // 이 비교 시나리오는 동일 조건으로 다시 측정해 기준선을 재작성한다. it("PESSIMISTIC_WRITE와 Atomic Increment를 측정한다").config(enabled = runPerformanceTests) { val threadCount = 30 @@ -191,9 +197,6 @@ class CommentConcurrencyTest( ) } - println("[pessimistic] $pessimisticResult") - println("[atomic] $atomicResult") - pessimisticResult.failCount shouldBe 0 atomicResult.failCount shouldBe 0 pessimisticResult.postCommentCount shouldBe threadCount @@ -211,8 +214,6 @@ class AtomicCommentCountCommand( private val entityManager: EntityManager, private val transactionTemplate: TransactionTemplate, ) { - // TODO(board-refactor): 현재는 동시성 비교 실험용 테스트 전용 커맨드. - // Board 리팩토링 후 실제 카운트 갱신 구조에 맞춰 제거 또는 대체한다. fun savePostCommentWithAtomicIncrement( dto: CommentSaveRequest, postId: Long, diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt index a928c7f2..008c6ba8 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/ManageCommentUseCaseTest.kt @@ -1,9 +1,6 @@ package com.weeth.domain.comment.application.usecase.command -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.repository.NoticeRepository import com.weeth.domain.board.domain.repository.PostRepository -import com.weeth.domain.board.fixture.NoticeTestFixture import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.dto.request.CommentSaveRequest import com.weeth.domain.comment.application.dto.request.CommentUpdateRequest @@ -26,15 +23,15 @@ import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify -import org.springframework.test.util.ReflectionTestUtils class ManageCommentUseCaseTest : DescribeSpec({ val commentRepository = mockk(relaxUnitFun = true) val postRepository = mockk() - val noticeRepository = mockk() val userGetService = mockk() val fileReader = mockk() val fileRepository = mockk(relaxed = true) @@ -44,7 +41,6 @@ class ManageCommentUseCaseTest : ManageCommentUseCase( commentRepository, postRepository, - noticeRepository, userGetService, fileReader, fileRepository, @@ -52,24 +48,17 @@ class ManageCommentUseCaseTest : ) beforeTest { - clearMocks( - commentRepository, - postRepository, - noticeRepository, - userGetService, - fileReader, - fileRepository, - fileMapper, - ) + clearMocks(commentRepository, postRepository, userGetService, fileReader, fileRepository, fileMapper) every { fileMapper.toFileList(any(), FileOwnerType.COMMENT, any()) } returns emptyList() every { commentRepository.save(any()) } answers { firstArg() } every { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } returns emptyList() + every { commentRepository.delete(any()) } just runs } describe("savePostComment") { - it("최상위 댓글 저장 성공 시 댓글 수를 증가시킨다") { + it("최상위 댓글 저장 시 댓글 수가 증가한다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = user) val dto = CommentSaveRequest(parentCommentId = null, content = "최상위 댓글", files = null) every { userGetService.find(1L) } returns user @@ -78,57 +67,14 @@ class ManageCommentUseCaseTest : useCase.savePostComment(dto, postId = 10L, userId = 1L) post.commentCount shouldBe 1 - verify { commentRepository.save(any()) } + verify(exactly = 1) { commentRepository.save(any()) } verify(exactly = 0) { commentRepository.findByIdAndPostId(any(), any()) } } - it("대댓글 저장 성공 시 같은 게시글 경계를 검증하고 댓글 수를 증가시킨다") { + it("부모 댓글이 존재하지 않으면 예외를 던진다") { val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val parent = Comment(id = 100L, content = "parent", post = post, user = user) - val dto = - CommentSaveRequest( - parentCommentId = 100L, - content = "child", - files = - listOf( - FileSaveRequest( - "f.png", - "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", - 100L, - "image/png", - ), - ), - ) - val mappedFiles = - listOf( - File.createUploaded( - fileName = "f.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174000_f.png", - fileSize = 100L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = 999L, - ), - ) - - every { userGetService.find(1L) } returns user - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(100L, 10L) } returns parent - every { fileMapper.toFileList(dto.files, FileOwnerType.COMMENT, any()) } returns mappedFiles - - useCase.savePostComment(dto, postId = 10L, userId = 1L) - - post.commentCount shouldBe 1 - verify { commentRepository.findByIdAndPostId(100L, 10L) } - verify { commentRepository.save(any()) } - verify { fileRepository.saveAll(mappedFiles) } - } - - it("부모 댓글이 다른 리소스면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val dto = CommentSaveRequest(parentCommentId = 999L, content = "child", files = null) + val post = PostTestFixture.create(id = 10L, user = user) + val dto = CommentSaveRequest(parentCommentId = 999L, content = "대댓글", files = null) every { userGetService.find(1L) } returns user every { postRepository.findByIdWithLock(10L) } returns post @@ -137,15 +83,13 @@ class ManageCommentUseCaseTest : shouldThrow { useCase.savePostComment(dto, postId = 10L, userId = 1L) } - - verify(exactly = 0) { commentRepository.save(any()) } } } describe("updatePostComment") { it("작성자가 아니면 예외를 던진다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = owner) val comment = Comment(id = 200L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest(content = "new", files = null) @@ -154,28 +98,11 @@ class ManageCommentUseCaseTest : shouldThrow { useCase.updatePostComment(dto, postId = 10L, commentId = 200L, userId = 2L) } - - verify(exactly = 0) { fileRepository.saveAll(any>()) } } - it("files가 null이면 기존 첨부를 유지한다") { + it("files가 있으면 기존 파일은 삭제되고 새 파일이 저장된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 201L, content = "old", post = post, user = owner) - val dto = CommentUpdateRequest(content = "new content", files = null) - - every { commentRepository.findByIdAndPostId(201L, 10L) } returns comment - - useCase.updatePostComment(dto, postId = 10L, commentId = 201L, userId = 1L) - - comment.content shouldBe "new content" - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("files가 있으면 기존 파일을 삭제하고 새 파일을 저장한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = owner) val comment = Comment(id = 202L, content = "old", post = post, user = owner) val dto = CommentUpdateRequest( @@ -219,57 +146,31 @@ class ManageCommentUseCaseTest : oldFile.status.name shouldBe "DELETED" verify { fileRepository.saveAll(listOf(newFile)) } } + } - it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 204L, content = "old", post = post, user = owner) - val dto = CommentUpdateRequest(content = "new content", files = emptyList()) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174004_old2.png", - fileSize = 300L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) - - every { commentRepository.findByIdAndPostId(204L, 10L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 204L, any()) } returns listOf(oldFile) - - useCase.updatePostComment(dto, postId = 10L, commentId = 204L, userId = 1L) - - oldFile.status.name shouldBe "DELETED" - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("삭제된 댓글은 수정할 수 없다") { + describe("deletePostComment") { + it("리프 댓글 삭제 시 hard delete 되고 댓글 수가 감소한다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 203L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) - val dto = CommentUpdateRequest(content = "new content", files = null) + val post = PostTestFixture.create(id = 10L, user = owner, title = "title") + post.commentCount = 1 + val comment = Comment(id = 310L, content = "leaf", post = post, user = owner) - every { commentRepository.findByIdAndPostId(203L, 10L) } returns comment + every { postRepository.findByIdWithLock(10L) } returns post + every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment - shouldThrow { - useCase.updatePostComment(dto, postId = 10L, commentId = 203L, userId = 1L) - } + useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } + post.commentCount shouldBe 0 + verify(exactly = 1) { commentRepository.delete(comment) } } - } - describe("deletePostComment") { - it("자식이 있는 댓글 삭제 시 soft delete 하고 댓글 수를 감소시킨다") { + it("자식이 있는 댓글 삭제 시 soft delete 된다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) + val post = PostTestFixture.create(id = 10L, user = owner) + post.commentCount = 2 val comment = Comment(id = 300L, content = "target", post = post, user = owner) - val child = - Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) + val child = Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) comment.children.add(child) every { postRepository.findByIdWithLock(10L) } returns post @@ -283,241 +184,17 @@ class ManageCommentUseCaseTest : verify(exactly = 0) { commentRepository.delete(comment) } } - it("이미 삭제된 댓글을 다시 삭제하면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) - - val comment = - Comment(id = 300L, content = "target", post = post, user = owner, isDeleted = true) - val child = - Comment(id = 301L, content = "child", post = post, user = owner, parent = comment) - comment.children.add(child) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment - - shouldThrow { - useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) - } - - post.commentCount shouldBe 2 - } - - it("이미 삭제된 댓글은 자식이 없어도 예외를 던진다") { + it("이미 삭제된 댓글은 삭제할 수 없다") { val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - val comment = Comment(id = 300L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) + val post = PostTestFixture.create(id = 10L, user = owner) + val comment = Comment(id = 320L, content = "삭제된 댓글입니다.", post = post, user = owner, isDeleted = true) every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(300L, 10L) } returns comment + every { commentRepository.findByIdAndPostId(320L, 10L) } returns comment shouldThrow { - useCase.deletePostComment(postId = 10L, commentId = 300L, userId = 1L) + useCase.deletePostComment(postId = 10L, commentId = 320L, userId = 1L) } - - verify(exactly = 0) { commentRepository.delete(any()) } - } - - it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 1) - val comment = Comment(id = 310L, content = "리프", post = post, user = owner) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(310L, 10L) } returns comment - - useCase.deletePostComment(postId = 10L, commentId = 310L, userId = 1L) - - post.commentCount shouldBe 0 - verify { commentRepository.delete(comment) } - } - - it("부모가 삭제됐어도 자식이 2개 이상이면 부모를 삭제하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 2) - - val parent = - Comment( - id = 400L, - content = "삭제된 댓글입니다.", - post = post, - user = owner, - isDeleted = true, - ) - val child1 = Comment(id = 401L, content = "첫째", post = post, user = owner, parent = parent) - val child2 = Comment(id = 402L, content = "둘째", post = post, user = owner, parent = parent) - parent.children.add(child1) - parent.children.add(child2) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(401L, 10L) } returns child1 - - useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) - - verify { commentRepository.delete(child1) } - verify(exactly = 0) { commentRepository.delete(parent) } - } - - it("리프 댓글 삭제 시 부모가 삭제 상태이고 마지막 자식이면 부모까지 물리 삭제한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) - ReflectionTestUtils.setField(post, "commentCount", 1) - - val parent = - Comment( - id = 400L, - content = "삭제된 댓글입니다.", - post = post, - user = owner, - isDeleted = true, - ) - val child = Comment(id = 401L, content = "leaf", post = post, user = owner, parent = parent) - parent.children.add(child) - - every { postRepository.findByIdWithLock(10L) } returns post - every { commentRepository.findByIdAndPostId(401L, 10L) } returns child - val childFile = - File.createUploaded( - fileName = "a", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174005_a.png", - fileSize = 100L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = 401L, - ) - every { fileReader.findAll(FileOwnerType.COMMENT, 401L, any()) } returns - listOf( - childFile, - ) - every { fileReader.findAll(FileOwnerType.COMMENT, 400L, any()) } returns emptyList() - - useCase.deletePostComment(postId = 10L, commentId = 401L, userId = 1L) - - post.commentCount shouldBe 0 - childFile.status.name shouldBe "DELETED" - verify { commentRepository.delete(child) } - verify { commentRepository.delete(parent) } - } - } - - describe("saveNoticeComment") { - it("공지 댓글 생성도 동일하게 lock 기반으로 처리한다") { - val user = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = user) - val dto = CommentSaveRequest(parentCommentId = null, content = "notice comment", files = null) - - every { userGetService.find(1L) } returns user - every { noticeRepository.findByIdWithLock(11L) } returns notice - - useCase.saveNoticeComment(dto, noticeId = 11L, userId = 1L) - - notice.commentCount shouldBe 1 - verify { noticeRepository.findByIdWithLock(11L) } - verify { commentRepository.save(any()) } - } - } - - describe("updateNoticeComment") { - it("작성자가 아니면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 500L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "new", files = null) - - every { commentRepository.findByIdAndNoticeId(500L, 11L) } returns comment - - shouldThrow { - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 500L, userId = 2L) - } - } - - it("작성자이면 내용을 변경한다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 501L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "updated", files = null) - - every { commentRepository.findByIdAndNoticeId(501L, 11L) } returns comment - - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 501L, userId = 1L) - - comment.content shouldBe "updated" - } - - it("삭제된 댓글은 수정할 수 없다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = - Comment(id = 502L, content = "삭제된 댓글입니다.", notice = notice, user = owner, isDeleted = true) - val dto = CommentUpdateRequest(content = "updated", files = null) - - every { commentRepository.findByIdAndNoticeId(502L, 11L) } returns comment - - shouldThrow { - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 502L, userId = 1L) - } - - verify(exactly = 0) { fileReader.findAll(FileOwnerType.COMMENT, any(), any()) } - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - - it("files가 빈 배열이면 기존 파일을 전체 삭제하고 새 파일은 저장하지 않는다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 503L, content = "old", notice = notice, user = owner) - val dto = CommentUpdateRequest(content = "updated", files = emptyList()) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "COMMENT/2026-02/123e4567-e89b-12d3-a456-426614174006_old3.png", - fileSize = 400L, - contentType = "image/png", - ownerType = FileOwnerType.COMMENT, - ownerId = comment.id, - ) - - every { commentRepository.findByIdAndNoticeId(503L, 11L) } returns comment - every { fileReader.findAll(FileOwnerType.COMMENT, 503L, any()) } returns listOf(oldFile) - - useCase.updateNoticeComment(dto, noticeId = 11L, commentId = 503L, userId = 1L) - - oldFile.status.name shouldBe "DELETED" - verify(exactly = 0) { fileRepository.saveAll(any>()) } - } - } - - describe("deleteNoticeComment") { - it("자식 없는 리프 댓글 삭제 시 hard delete하고 댓글 수를 감소시킨다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - ReflectionTestUtils.setField(notice, "commentCount", 1) - val comment = Comment(id = 600L, content = "리프", notice = notice, user = owner) - - every { noticeRepository.findByIdWithLock(11L) } returns notice - every { commentRepository.findByIdAndNoticeId(600L, 11L) } returns comment - - useCase.deleteNoticeComment(noticeId = 11L, commentId = 600L, userId = 1L) - - notice.commentCount shouldBe 0 - verify { commentRepository.delete(comment) } - } - - it("작성자가 아니면 예외를 던진다") { - val owner = UserTestFixture.createActiveUser1(1L) - val notice = NoticeTestFixture.createNotice(id = 11L, title = "notice", user = owner) - val comment = Comment(id = 601L, content = "리프", notice = notice, user = owner) - - every { noticeRepository.findByIdWithLock(11L) } returns notice - every { commentRepository.findByIdAndNoticeId(601L, 11L) } returns comment - - shouldThrow { - useCase.deleteNoticeComment(noticeId = 11L, commentId = 601L, userId = 2L) - } - - verify(exactly = 0) { commentRepository.delete(any()) } } } }) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt index d0639567..fc84aa54 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/CommentQueryPerformanceTest.kt @@ -2,9 +2,10 @@ package com.weeth.domain.comment.application.usecase.query import com.weeth.config.QueryCountUtil import com.weeth.config.TestContainersConfig +import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post -import com.weeth.domain.board.domain.entity.enums.Category -import com.weeth.domain.board.domain.entity.enums.Part +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper @@ -36,6 +37,7 @@ import java.util.UUID @Tag("performance") class CommentQueryPerformanceTest( private val userRepository: UserRepository, + private val boardRepository: BoardRepository, private val postRepository: PostRepository, private val commentRepository: CommentRepository, private val fileRepository: FileRepository, @@ -43,38 +45,48 @@ class CommentQueryPerformanceTest( ) : DescribeSpec({ val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false + fun createUser(): User = + userRepository.save( + User + .builder() + .name("perf-user") + .email("perf-user@test.com") + .status(Status.ACTIVE) + .position(Position.BE) + .role(Role.USER) + .build(), + ) + + fun createBoard(): Board = + boardRepository.save( + Board( + name = "perf-board", + type = BoardType.GENERAL, + ), + ) + + fun createPost( + user: User, + board: Board, + ): Post = + postRepository.save( + Post( + title = "query-performance", + content = "measure comment query performance", + user = user, + board = board, + cardinalNumber = 4, + ), + ) + fun setupData( rootCount: Int, childrenPerRoot: Int, filesPerComment: Int, ): List { - val user = - userRepository.save( - User - .builder() - .name("perf-user") - .email("perf-user@test.com") - .status(Status.ACTIVE) - .position(Position.BE) - .role(Role.USER) - .build(), - ) - val post = - postRepository.save( - Post - .builder() - .user(user) - .title("query-performance") - .content("measure comment query performance") - .category(Category.StudyLog) - .part(Part.BE) - .parts(listOf(Part.BE)) - .cardinalNumber(4) - .week(1) - .comments(ArrayList()) - .commentCount(0) - .build(), - ) + val user = createUser() + val board = createBoard() + val post = createPost(user, board) val commentIds = mutableListOf() repeat(rootCount) { rootIdx -> @@ -206,7 +218,6 @@ private class LegacyCommentQueryService( fileRepository .findAll(FileOwnerType.COMMENT, comment.id) .map(fileMapper::toFileResponse) - ?: emptyList() return commentMapper.toCommentDto(comment, children, files) } diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt index d1638bea..023faf4c 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/query/GetCommentQueryServiceTest.kt @@ -1,6 +1,5 @@ package com.weeth.domain.comment.application.usecase.query -import com.weeth.domain.board.domain.entity.enums.Category import com.weeth.domain.board.fixture.PostTestFixture import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.mapper.CommentMapper @@ -24,10 +23,10 @@ class GetCommentQueryServiceTest : val fileReader = mockk() val fileMapper = mockk() val commentMapper = mockk() - val assembler = GetCommentQueryService(fileReader, fileMapper, commentMapper) + val service = GetCommentQueryService(fileReader, fileMapper, commentMapper) val user = UserTestFixture.createActiveUser1(1L) - val post = PostTestFixture.createPost(id = 10L, title = "title", category = Category.StudyLog) + val post = PostTestFixture.create(id = 10L, user = user) beforeTest { clearMocks(fileReader, fileMapper, commentMapper) @@ -49,28 +48,28 @@ class GetCommentQueryServiceTest : describe("toCommentTreeResponses") { it("빈 리스트면 빈 리스트를 반환하고 파일 조회를 하지 않는다") { - val result = assembler.toCommentTreeResponses(emptyList()) + val result = service.toCommentTreeResponses(emptyList()) result shouldBe emptyList() verify(exactly = 0) { fileReader.findAll(any(), any(), any()) } verify(exactly = 0) { fileReader.findAll(any(), any>(), any()) } } - it("최상위 댓글만 있을 때 파일 조회를 1회 수행하고 트리를 반환한다") { + it("최상위 댓글만 있을 때 파일 조회를 1회 수행한다") { val comment = CommentTestFixture.createPostComment(id = 1L, post = post, user = user) val response = stubResponse(1L) every { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } returns emptyList() every { commentMapper.toCommentDto(comment, emptyList(), emptyList()) } returns response - val result = assembler.toCommentTreeResponses(listOf(comment)) + val result = service.toCommentTreeResponses(listOf(comment)) result.size shouldBe 1 result[0].id shouldBe 1L verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(1L), any()) } } - it("부모-자식 구조가 있을 때 자식이 부모에 중첩된 트리로 조립된다") { + it("부모-자식 구조를 트리로 조립한다") { val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) val childResponse = stubResponse(11L) @@ -80,30 +79,12 @@ class GetCommentQueryServiceTest : every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse - val result = assembler.toCommentTreeResponses(listOf(parent, child)) + val result = service.toCommentTreeResponses(listOf(parent, child)) result.size shouldBe 1 result[0].id shouldBe 10L result[0].children.size shouldBe 1 result[0].children[0].id shouldBe 11L - verify(exactly = 1) { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } - } - - it("자식 댓글은 최상위 목록에 포함되지 않는다") { - val parent = CommentTestFixture.createPostComment(id = 10L, post = post, user = user) - val child = CommentTestFixture.createPostComment(id = 11L, post = post, user = user, parent = parent) - val childResponse = stubResponse(11L) - val parentResponse = stubResponse(10L, children = listOf(childResponse)) - - every { fileReader.findAll(FileOwnerType.COMMENT, listOf(10L, 11L), any()) } returns emptyList() - every { commentMapper.toCommentDto(child, emptyList(), emptyList()) } returns childResponse - every { commentMapper.toCommentDto(parent, listOf(childResponse), emptyList()) } returns parentResponse - - val result = assembler.toCommentTreeResponses(listOf(parent, child)) - - // 최상위에는 parent만 있어야 함 - result.size shouldBe 1 - result.none { it.id == 11L } shouldBe true } } }) From 24cdacf078c31ad2e9a92c8184294681a039beb6 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 09:27:28 +0900 Subject: [PATCH 22/44] =?UTF-8?q?refactor:=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9D=80=20User.Role=EC=9D=84=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/weeth/domain/board/domain/entity/Board.kt | 3 ++- .../com/weeth/domain/board/domain/vo/BoardConfig.kt | 9 +++------ .../weeth/domain/board/domain/entity/BoardEntityTest.kt | 3 ++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 4fccabb6..b5b579b1 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -3,6 +3,7 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.converter.BoardConfigConverter import com.weeth.domain.board.domain.entity.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.common.entity.BaseEntity import jakarta.persistence.Column import jakarta.persistence.Convert @@ -33,7 +34,7 @@ class Board( get() = config.commentEnabled val isAdminOnly: Boolean - get() = config.writePermission == BoardConfig.WritePermission.ADMIN + get() = config.writePermission == Role.ADMIN fun updateConfig(newConfig: BoardConfig) { config = newConfig diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt index 39458115..557ed9cf 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -1,13 +1,10 @@ package com.weeth.domain.board.domain.vo +import com.weeth.domain.user.domain.entity.enums.Role + data class BoardConfig( val commentEnabled: Boolean = true, - val writePermission: WritePermission = WritePermission.USER, + val writePermission: Role = Role.USER, val isPrivate: Boolean = false, ) { - enum class WritePermission { - ADMIN, - USER, - } - } diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 4ec457ae..4154ea0c 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -2,6 +2,7 @@ package com.weeth.domain.board.domain.entity import com.weeth.domain.board.domain.entity.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.shouldBe @@ -34,7 +35,7 @@ class BoardEntityTest : id = 2L, name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + config = BoardConfig(writePermission = Role.ADMIN), ) board.isAdminOnly shouldBe true From 395688c3ca8dee725632a332056df6ee6f27c775 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:12:46 +0900 Subject: [PATCH 23/44] =?UTF-8?q?refactor:=20admin=20api=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/weeth/global/config/SecurityConfig.java | 2 +- .../com/weeth/global/config/swagger/SwaggerConfig.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java index e8175593..282c0569 100644 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ b/src/main/java/com/weeth/global/config/SecurityConfig.java @@ -78,7 +78,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return new AuthorizationDecision(allowed); }) .requestMatchers("/actuator/health").permitAll() - .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v1/admin/**", "/api/v4/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .exceptionHandling(exceptionHandling -> diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java index afd607e6..1a8eec1e 100644 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java @@ -18,8 +18,8 @@ import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import lombok.RequiredArgsConstructor; -import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -41,7 +41,7 @@ - Domain Error: **2xxx** - Server Error: **3xxx** - Client Error: **4xxx** - + ## 도메인별 코드 범위 | Domain | Success | Error | |--------|---------|------| @@ -54,7 +54,7 @@ | Schedule | 17xx | 27xx | | User | 18xx | 28xx | | Auth/JWT (Global) | - | 29xx | - + > 각 API의 상세 응답 예시는 Swagger의 **Responses** 섹션에서 확인하세요. """ ) @@ -83,7 +83,7 @@ public OpenAPI openAPI() { public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin") - .pathsToMatch("/api/v1/admin/**") + .pathsToMatch("/api/v1/admin/**", "/api/v4/admin/**") .addOperationCustomizer(operationCustomizer()) .build(); } From e3f3a859d749891ec238ceacf0f940d4263619fb Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:30:37 +0900 Subject: [PATCH 24/44] =?UTF-8?q?refactor:=20soft=20delete=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/ManagePostUseCase.kt | 7 +++---- .../com/weeth/domain/board/domain/entity/Board.kt | 10 ++++++++++ .../com/weeth/domain/board/domain/entity/Post.kt | 10 ++++++++++ .../domain/board/domain/repository/BoardRepository.kt | 8 +++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 4f36b38c..6a04d54e 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -20,7 +20,6 @@ import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.service.UserGetService -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -86,12 +85,12 @@ class ManagePostUseCase( validateOwner(post, userId) markPostFilesDeleted(post.id) - postRepository.delete(post) + post.markDeleted() } - private fun findBoard(boardId: Long): Board = boardRepository.findByIdOrNull(boardId) ?: throw BoardNotFoundException() + private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() - private fun findPost(postId: Long): Post = postRepository.findByIdOrNull(postId) ?: throw PostNotFoundException() + private fun findPost(postId: Long): Post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() private fun validateOwner( post: Post, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index b5b579b1..57126214 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -29,6 +29,8 @@ class Board( @Column(columnDefinition = "JSON") // Json 속성 사용으로 인한 커스텀 컨버터 적용 @Convert(converter = BoardConfigConverter::class) var config: BoardConfig = BoardConfig(), + @Column(nullable = false) + var isDeleted: Boolean = false, ) : BaseEntity() { val isCommentEnabled: Boolean get() = config.commentEnabled @@ -44,4 +46,12 @@ class Board( require(newName.isNotBlank()) { "게시판 이름은 공백이 될 수 없습니다." } name = newName } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index 7dac2310..6849b430 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -34,6 +34,8 @@ class Post( var likeCount: Int = 0, @Column var cardinalNumber: Int? = null, + @Column(nullable = false) + var isDeleted: Boolean = false, ) : BaseEntity() { fun increaseCommentCount() { commentCount++ @@ -73,6 +75,14 @@ class Post( cardinalNumber = newCardinalNumber } + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } + companion object { fun create( title: String, diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt index 8e66fee5..268cd084 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -3,4 +3,10 @@ package com.weeth.domain.board.domain.repository import com.weeth.domain.board.domain.entity.Board import org.springframework.data.jpa.repository.JpaRepository -interface BoardRepository : JpaRepository +interface BoardRepository : JpaRepository { + fun findAllByIsDeletedFalseOrderByIdAsc(): List + + fun findByIdAndIsDeletedFalse(id: Long): Board? + + fun findAllByOrderByIdAsc(): List +} From 4e4f63831f4d3bcb5a76192fae477aa6b1a158da Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:31:14 +0900 Subject: [PATCH 25/44] =?UTF-8?q?refactor:=20role=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/board/domain/converter/BoardConfigConverterTest.kt | 3 ++- .../kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt index 5b9a7c82..d5fc1c17 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -1,6 +1,7 @@ package com.weeth.domain.board.domain.converter import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe @@ -13,7 +14,7 @@ class BoardConfigConverterTest : val config = BoardConfig( commentEnabled = false, - writePermission = BoardConfig.WritePermission.ADMIN, + writePermission = Role.ADMIN, isPrivate = true, ) diff --git a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt index 7719083e..dae5c900 100644 --- a/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -3,6 +3,7 @@ package com.weeth.domain.board.fixture import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.enums.BoardType import com.weeth.domain.board.domain.vo.BoardConfig +import com.weeth.domain.user.domain.entity.enums.Role object BoardTestFixture { fun create( @@ -26,6 +27,6 @@ object BoardTestFixture { id = id, name = name, type = BoardType.NOTICE, - config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + config = BoardConfig(writePermission = Role.ADMIN), ) } From 591ca781da9981797bb33e2dc16fd0b6e2ab6fdd Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:33:49 +0900 Subject: [PATCH 26/44] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=ED=8C=90/?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EA=B6=8C=ED=95=9C=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JwtAuthenticationProcessingFilter.java | 31 ++++----- .../resolver/CurrentUserArgumentResolver.java | 20 +++--- .../weeth/global/config/SecurityConfig.java | 4 +- .../com/weeth/global/config/WebMvcConfig.java | 9 +-- .../dto/request/CreateBoardRequest.kt | 24 +++++++ .../dto/request/UpdateBoardRequest.kt | 17 +++++ .../dto/response/BoardDetailResponse.kt | 24 +++++++ .../dto/response/BoardListResponse.kt | 13 ++++ .../board/application/mapper/BoardMapper.kt | 38 +++++++++++ .../usecase/command/ManageBoardUseCase.kt | 66 +++++++++++++++++++ .../usecase/query/GetBoardQueryService.kt | 50 ++++++++++++++ .../usecase/query/GetPostQueryService.kt | 41 ++++++++++-- .../board/domain/repository/PostRepository.kt | 29 ++++++-- .../presentation/BoardAdminController.kt | 61 +++++++++++++++++ .../board/presentation/BoardController.kt | 43 ++++++++++++ .../board/presentation/BoardResponseCode.kt | 5 ++ .../board/presentation/PostController.kt | 20 ++++-- .../usecase/command/ManagePostUseCaseTest.kt | 25 ++++--- .../usecase/query/GetPostQueryServiceTest.kt | 49 +++++++++++--- 19 files changed, 493 insertions(+), 76 deletions(-) create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt create mode 100644 src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt diff --git a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java index c0ecba1f..7490ca02 100644 --- a/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -4,35 +4,29 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import com.weeth.domain.user.domain.entity.User; import com.weeth.domain.user.domain.entity.enums.Role; -import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.jwt.exception.TokenNotFoundException; +import com.weeth.global.auth.model.AuthenticatedUser; import com.weeth.global.auth.jwt.service.JwtProvider; import com.weeth.global.auth.jwt.service.JwtService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; -import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; @RequiredArgsConstructor @Slf4j public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter { private static final String NO_CHECK_URL = "/api/v1/login"; - private final String DUMMY = "DUMMY_PASSWORD"; private final JwtProvider jwtProvider; private final JwtService jwtService; - private final UserGetService userGetService; - - private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -59,18 +53,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse public void saveAuthentication(String accessToken) { - String email = jwtService.extractEmail(accessToken).get(); - Role role = Role.valueOf(jwtService.extractRole(accessToken).get()); - - UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() - .username(email) - .password(DUMMY) - .roles(role.name()) - .build(); + Long userId = jwtService.extractId(accessToken).orElseThrow(TokenNotFoundException::new); + String email = jwtService.extractEmail(accessToken).orElseThrow(TokenNotFoundException::new); + Role role = Role.valueOf(jwtService.extractRole(accessToken).orElseThrow(TokenNotFoundException::new)); + AuthenticatedUser principal = new AuthenticatedUser(userId, email, role); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetailsUser, null, - authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities())); + new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java index b4fb497d..49c801eb 100644 --- a/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java +++ b/src/main/java/com/weeth/global/auth/resolver/CurrentUserArgumentResolver.java @@ -2,8 +2,7 @@ import com.weeth.global.auth.annotation.CurrentUser; import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; -import com.weeth.global.auth.jwt.service.JwtService; -import lombok.RequiredArgsConstructor; +import com.weeth.global.auth.model.AuthenticatedUser; import org.springframework.core.MethodParameter; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; @@ -13,13 +12,8 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import java.util.Optional; - -@RequiredArgsConstructor public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - private final JwtService jwtService; - @Override public boolean supportsParameter(MethodParameter parameter) { // parameter가 해당 resolver를 지원하는 여부 확인 boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUser.class); // @CurrentUser이 존재하는가? @@ -31,13 +25,15 @@ public boolean supportsParameter(MethodParameter parameter) { // parameter가 public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // 인증 객체 가져오기 - if (authentication instanceof AnonymousAuthenticationToken) { // 익명 인증 토큰의 인스턴스라면 0 반환 + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { throw new AnonymousAuthenticationException(); } - String token = Optional.ofNullable(webRequest.getHeader("Authorization")) - .map(accessToken -> accessToken.replace("Bearer ", "")).get(); + Object principal = authentication.getPrincipal(); + if (principal instanceof AuthenticatedUser authenticatedUser) { + return authenticatedUser.id(); + } - return jwtService.extractId(token).get(); // 토큰에서 userId 조회 + throw new AnonymousAuthenticationException(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java index 282c0569..223c0e71 100644 --- a/src/main/java/com/weeth/global/config/SecurityConfig.java +++ b/src/main/java/com/weeth/global/config/SecurityConfig.java @@ -1,7 +1,6 @@ package com.weeth.global.config; import com.fasterxml.jackson.databind.ObjectMapper; -import com.weeth.domain.user.domain.service.UserGetService; import com.weeth.global.auth.authentication.CustomAccessDeniedHandler; import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint; import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; @@ -39,7 +38,6 @@ public class SecurityConfig { private final JwtProvider jwtProvider; private final JwtService jwtService; private final JwtManageUseCase jwtManageUseCase; - private final UserGetService userGetService; private final ObjectMapper objectMapper; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; @@ -112,6 +110,6 @@ public PasswordEncoder passwordEncoder() { @Bean public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() { - return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService, userGetService); + return new JwtAuthenticationProcessingFilter(jwtProvider, jwtService); } } diff --git a/src/main/java/com/weeth/global/config/WebMvcConfig.java b/src/main/java/com/weeth/global/config/WebMvcConfig.java index 4d1fd0de..d0127ba9 100644 --- a/src/main/java/com/weeth/global/config/WebMvcConfig.java +++ b/src/main/java/com/weeth/global/config/WebMvcConfig.java @@ -1,8 +1,7 @@ package com.weeth.global.config; -import com.weeth.global.auth.jwt.service.JwtService; import com.weeth.global.auth.resolver.CurrentUserArgumentResolver; -import lombok.RequiredArgsConstructor; +import com.weeth.global.auth.resolver.CurrentUserRoleArgumentResolver; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -10,13 +9,11 @@ import java.util.List; @Configuration -@RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { - private final JwtService jwtService; - @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(new CurrentUserArgumentResolver(jwtService)); + resolvers.add(new CurrentUserArgumentResolver()); + resolvers.add(new CurrentUserRoleArgumentResolver()); } } diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt new file mode 100644 index 00000000..905ac2d4 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/CreateBoardRequest.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class CreateBoardRequest( + @field:Schema(description = "게시판 이름", example = "공지사항") + @field:NotBlank + @field:Size(max = 100) + val name: String, + @field:Schema(description = "게시판 타입", example = "NOTICE") + @field:NotNull + var type: BoardType, + @field:Schema(description = "댓글 허용 여부", example = "true") + val commentEnabled: Boolean = true, + @field:Schema(description = "게시글 작성 권한", example = "USER") + val writePermission: Role = Role.USER, + @field:Schema(description = "비공개 게시판 여부", example = "false") + val isPrivate: Boolean = false, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt new file mode 100644 index 00000000..0712551b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/request/UpdateBoardRequest.kt @@ -0,0 +1,17 @@ +package com.weeth.domain.board.application.dto.request + +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Size + +data class UpdateBoardRequest( + @field:Schema(description = "게시판 이름", example = "새 공지사항", nullable = true) + @field:Size(max = 100) + val name: String? = null, + @field:Schema(description = "댓글 허용 여부", example = "true", nullable = true) + val commentEnabled: Boolean? = null, + @field:Schema(description = "게시글 작성 권한", example = "USER", nullable = true) + val writePermission: Role? = null, + @field:Schema(description = "비공개 게시판 여부", example = "false", nullable = true) + val isPrivate: Boolean? = null, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt new file mode 100644 index 00000000..d42d623b --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardDetailResponse.kt @@ -0,0 +1,24 @@ +package com.weeth.domain.board.application.dto.response + +import com.fasterxml.jackson.annotation.JsonInclude +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.user.domain.entity.enums.Role +import io.swagger.v3.oas.annotations.media.Schema + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BoardDetailResponse( + @field:Schema(description = "게시판 ID") + val id: Long, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 타입") + val type: BoardType, + @field:Schema(description = "댓글 허용 여부") + val commentEnabled: Boolean, + @field:Schema(description = "게시글 작성 권한") + val writePermission: Role, + @field:Schema(description = "비공개 게시판 여부") + val isPrivate: Boolean, + @field:Schema(description = "삭제 여부 (관리자 페이지에서만 값 존재)") + val isDeleted: Boolean?, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt new file mode 100644 index 00000000..0024a619 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/BoardListResponse.kt @@ -0,0 +1,13 @@ +package com.weeth.domain.board.application.dto.response + +import com.weeth.domain.board.domain.entity.enums.BoardType +import io.swagger.v3.oas.annotations.media.Schema + +data class BoardListResponse( + @field:Schema(description = "게시판 ID") + val id: Long, + @field:Schema(description = "게시판 이름") + val name: String, + @field:Schema(description = "게시판 타입") + val type: BoardType, +) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt new file mode 100644 index 00000000..08c7934d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/BoardMapper.kt @@ -0,0 +1,38 @@ +package com.weeth.domain.board.application.mapper + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.domain.entity.Board +import org.springframework.stereotype.Component + +@Component +class BoardMapper { + fun toListResponse(board: Board) = + BoardListResponse( + id = board.id, + name = board.name, + type = board.type, + ) + + fun toDetailResponse(board: Board) = + BoardDetailResponse( + id = board.id, + name = board.name, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + isDeleted = null, // public api에서 삭제 여부는 보여주지 않음 + ) + + fun toDetailResponseForAdmin(board: Board) = + BoardDetailResponse( + id = board.id, + name = board.name, + type = board.type, + commentEnabled = board.config.commentEnabled, + writePermission = board.config.writePermission, + isPrivate = board.config.isPrivate, + isDeleted = board.isDeleted, + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt new file mode 100644 index 00000000..f974c710 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -0,0 +1,66 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.board.domain.vo.BoardConfig +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ManageBoardUseCase( + private val boardRepository: BoardRepository, + private val boardMapper: BoardMapper, +) { + @Transactional + fun create(request: CreateBoardRequest): BoardDetailResponse { + val board = + Board( + name = request.name, + type = request.type, + config = + BoardConfig( + commentEnabled = request.commentEnabled, + writePermission = request.writePermission, + isPrivate = request.isPrivate, + ), + ) + val savedBoard = boardRepository.save(board) + return boardMapper.toDetailResponse(savedBoard) + } + + @Transactional + fun update( + boardId: Long, + request: UpdateBoardRequest, + ): BoardDetailResponse { + val board = findBoard(boardId) + + request.name?.let { board.rename(it) } + + if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { + board.updateConfig( + board.config.copy( + commentEnabled = request.commentEnabled ?: board.config.commentEnabled, + writePermission = request.writePermission ?: board.config.writePermission, + isPrivate = request.isPrivate ?: board.config.isPrivate, + ), + ) + } + + return boardMapper.toDetailResponse(board) + } + + @Transactional + fun delete(boardId: Long) { + val board = findBoard(boardId) + board.markDeleted() + } + + private fun findBoard(boardId: Long): Board = + boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt new file mode 100644 index 00000000..4d13b4ad --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -0,0 +1,50 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class GetBoardQueryService( + private val boardRepository: BoardRepository, + private val boardMapper: BoardMapper, +) { + fun findBoards(role: Role): List { + val isAdmin = isAdmin(role) + return boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .filter { canAccessBoard(it.config.isPrivate, isAdmin) } + .map(boardMapper::toListResponse) + } + + fun findBoard( + boardId: Long, + role: Role, + ): BoardDetailResponse { + val isAdmin = isAdmin(role) + val board = + boardRepository + .findByIdAndIsDeletedFalse(boardId) + ?.takeIf { canAccessBoard(it.config.isPrivate, isAdmin) } + ?: throw BoardNotFoundException() + return boardMapper.toDetailResponse(board) + } + + fun findAllBoardsForAdmin(): List = + boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .map(boardMapper::toDetailResponseForAdmin) + + private fun canAccessBoard( + isPrivate: Boolean, + isAdmin: Boolean, + ): Boolean = isAdmin || !isPrivate + + private fun isAdmin(role: Role): Boolean = role == Role.ADMIN +} diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 3e40272b..62f7d866 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -2,20 +2,22 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.dto.response.PostDetailResponse import com.weeth.domain.board.application.dto.response.PostListResponse +import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService import com.weeth.domain.comment.domain.repository.CommentReader import com.weeth.domain.file.application.mapper.FileMapper import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader +import com.weeth.domain.user.domain.entity.enums.Role import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Slice import org.springframework.data.domain.Sort -import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDateTime @@ -24,14 +26,22 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class GetPostQueryService( private val postRepository: PostRepository, + private val boardRepository: BoardRepository, private val commentReader: CommentReader, private val getCommentQueryService: GetCommentQueryService, private val fileReader: FileReader, private val fileMapper: FileMapper, private val postMapper: PostMapper, ) { - fun findPost(postId: Long): PostDetailResponse { - val post = postRepository.findByIdOrNull(postId) ?: throw PostNotFoundException() + fun findPost( + postId: Long, + role: Role, + ): PostDetailResponse { + val isAdmin = isAdmin(role) + val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + if (!canAccessBoard(post.board.config.isPrivate, isAdmin)) { + throw PostNotFoundException() + } val files = fileReader.findAll(FileOwnerType.POST, post.id).map(fileMapper::toFileResponse) val comments = commentReader.findAllByPostId(post.id) @@ -44,10 +54,13 @@ class GetPostQueryService( boardId: Long, pageNumber: Int, pageSize: Int, + role: Role, ): Slice { validatePage(pageNumber) + val isAdmin = isAdmin(role) + validateBoardVisibility(boardId, isAdmin) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) - val posts = postRepository.findAllByBoardId(boardId, pageable) + val posts = postRepository.findAllActiveByBoardId(boardId, pageable) val postIds = posts.content.map { it.id } val fileExistsByPostId = buildFileExistsMap(postIds) @@ -61,8 +74,11 @@ class GetPostQueryService( keyword: String, pageNumber: Int, pageSize: Int, + role: Role, ): Slice { validatePage(pageNumber) + val isAdmin = isAdmin(role) + validateBoardVisibility(boardId, isAdmin) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) @@ -90,4 +106,21 @@ class GetPostQueryService( val filesGrouped = fileReader.findAll(FileOwnerType.POST, postIds).groupBy { it.ownerId } return postIds.associateWith { filesGrouped.containsKey(it) } } + + private fun validateBoardVisibility( + boardId: Long, + isAdmin: Boolean, + ) { + val board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + if (!canAccessBoard(board.config.isPrivate, isAdmin)) { + throw BoardNotFoundException() + } + } + + private fun canAccessBoard( + isPrivate: Boolean, + isAdmin: Boolean, + ): Boolean = isAdmin || !isPrivate + + private fun isAdmin(role: Role): Boolean = role == Role.ADMIN } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt index 52e9de42..1f5a64c3 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -14,27 +14,48 @@ import org.springframework.data.repository.query.Param interface PostRepository : JpaRepository { @EntityGraph(attributePaths = ["user"]) - fun findAllByBoardId( - boardId: Long, + @Query( + """ + SELECT p + FROM Post p + WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) + fun findAllActiveByBoardId( + @Param("boardId") boardId: Long, pageable: Pageable, ): Slice + fun findByIdAndIsDeletedFalse(id: Long): Post? + @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) - @Query("SELECT p FROM Post p WHERE p.id = :id") + @Query( + """ + SELECT p + FROM Post p + WHERE p.id = :id + AND p.isDeleted = false + AND p.board.isDeleted = false + """, + ) fun findByIdWithLock( @Param("id") id: Long, ): Post? + @EntityGraph(attributePaths = ["user"]) @Query( """ SELECT p FROM Post p WHERE p.board.id = :boardId + AND p.isDeleted = false + AND p.board.isDeleted = false AND (LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))) """, ) - @EntityGraph(attributePaths = ["user"]) fun searchByBoardId( @Param("boardId") boardId: Long, @Param("keyword") keyword: String, diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt new file mode 100644 index 00000000..9ed701f9 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardAdminController.kt @@ -0,0 +1,61 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.command.ManageBoardUseCase +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.DeleteMapping +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.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Board-Admin", description = "Board Admin API") +@RestController +@RequestMapping("/api/v4/admin/board") +@PreAuthorize("hasRole('ADMIN')") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardAdminController( + private val manageBoardUseCase: ManageBoardUseCase, + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 전체 목록 조회 (삭제/비공개 포함)") + fun findAllBoards(): CommonResponse> = + CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findAllBoardsForAdmin()) + + @PostMapping + @Operation(summary = "게시판 생성") + fun createBoard( + @RequestBody @Valid request: CreateBoardRequest, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.BOARD_CREATED_SUCCESS, manageBoardUseCase.create(request)) + + @PatchMapping("/{boardId}") + @Operation(summary = "게시판 설정/이름 수정") + fun updateBoard( + @PathVariable boardId: Long, + @RequestBody @Valid request: UpdateBoardRequest, + ): CommonResponse = + CommonResponse.success(BoardResponseCode.BOARD_UPDATED_SUCCESS, manageBoardUseCase.update(boardId, request)) + + @DeleteMapping("/{boardId}") + @Operation(summary = "게시판 삭제") + fun deleteBoard( + @PathVariable boardId: Long, + ): CommonResponse { + manageBoardUseCase.delete(boardId) + return CommonResponse.success(BoardResponseCode.BOARD_DELETED_SUCCESS) + } +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt new file mode 100644 index 00000000..d96b08dd --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -0,0 +1,43 @@ +package com.weeth.domain.board.presentation + +import com.weeth.domain.board.application.dto.response.BoardDetailResponse +import com.weeth.domain.board.application.dto.response.BoardListResponse +import com.weeth.domain.board.application.exception.BoardErrorCode +import com.weeth.domain.board.application.usecase.query.GetBoardQueryService +import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUserRole +import com.weeth.global.common.exception.ApiErrorCodeExample +import com.weeth.global.common.response.CommonResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Board", description = "Board API") +@RestController +@RequestMapping("/api/v4/boards") +@ApiErrorCodeExample(BoardErrorCode::class) +class BoardController( + private val getBoardQueryService: GetBoardQueryService, +) { + @GetMapping + @Operation(summary = "게시판 목록 조회") + fun findBoards( + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + CommonResponse.success(BoardResponseCode.BOARD_FIND_ALL_SUCCESS, getBoardQueryService.findBoards(role)) + + @GetMapping("/{boardId}") + @Operation(summary = "게시판 상세 조회") + fun findBoard( + @PathVariable boardId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse = + CommonResponse.success( + BoardResponseCode.BOARD_FIND_BY_ID_SUCCESS, + getBoardQueryService.findBoard(boardId, role), + ) +} diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt index 86543528..2f45b492 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -8,10 +8,15 @@ enum class BoardResponseCode( override val status: HttpStatus, override val message: String, ) : ResponseCodeInterface { + BOARD_CREATED_SUCCESS(1300, HttpStatus.OK, "게시판이 성공적으로 생성되었습니다."), POST_CREATED_SUCCESS(1301, HttpStatus.OK, "게시글이 성공적으로 생성되었습니다."), POST_UPDATED_SUCCESS(1302, HttpStatus.OK, "게시글이 성공적으로 수정되었습니다."), POST_DELETED_SUCCESS(1303, HttpStatus.OK, "게시글이 성공적으로 삭제되었습니다."), POST_FIND_ALL_SUCCESS(1304, HttpStatus.OK, "게시글 목록이 성공적으로 조회되었습니다."), POST_FIND_BY_ID_SUCCESS(1305, HttpStatus.OK, "게시글이 성공적으로 조회되었습니다."), POST_SEARCH_SUCCESS(1306, HttpStatus.OK, "게시글 검색 결과가 성공적으로 조회되었습니다."), + BOARD_UPDATED_SUCCESS(1307, HttpStatus.OK, "게시판이 성공적으로 수정되었습니다."), + BOARD_DELETED_SUCCESS(1308, HttpStatus.OK, "게시판이 성공적으로 삭제되었습니다."), + BOARD_FIND_ALL_SUCCESS(1309, HttpStatus.OK, "게시판 목록이 성공적으로 조회되었습니다."), + BOARD_FIND_BY_ID_SUCCESS(1310, HttpStatus.OK, "게시판이 성공적으로 조회되었습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index db616faf..5de97ae8 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -8,8 +8,9 @@ import com.weeth.domain.board.application.dto.response.PostSaveResponse import com.weeth.domain.board.application.exception.BoardErrorCode import com.weeth.domain.board.application.usecase.command.ManagePostUseCase import com.weeth.domain.board.application.usecase.query.GetPostQueryService +import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.global.auth.annotation.CurrentUser -import com.weeth.global.auth.jwt.exception.JwtErrorCode +import com.weeth.global.auth.annotation.CurrentUserRole import com.weeth.global.common.exception.ApiErrorCodeExample import com.weeth.global.common.response.CommonResponse import io.swagger.v3.oas.annotations.Operation @@ -50,15 +51,20 @@ class PostController( @PathVariable boardId: Long, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse> = - CommonResponse.success(BoardResponseCode.POST_FIND_ALL_SUCCESS, getPostQueryService.findPosts(boardId, pageNumber, pageSize)) + CommonResponse.success( + BoardResponseCode.POST_FIND_ALL_SUCCESS, + getPostQueryService.findPosts(boardId, pageNumber, pageSize, role), + ) @GetMapping("/posts/{postId}") @Operation(summary = "게시글 상세 조회") fun findPost( @PathVariable postId: Long, + @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse = - CommonResponse.success(BoardResponseCode.POST_FIND_BY_ID_SUCCESS, getPostQueryService.findPost(postId)) + CommonResponse.success(BoardResponseCode.POST_FIND_BY_ID_SUCCESS, getPostQueryService.findPost(postId, role)) @PatchMapping("/posts/{postId}") @Operation(summary = "게시글 수정") @@ -67,7 +73,10 @@ class PostController( @RequestBody @Valid request: UpdatePostRequest, @Parameter(hidden = true) @CurrentUser userId: Long, ): CommonResponse = - CommonResponse.success(BoardResponseCode.POST_UPDATED_SUCCESS, managePostUseCase.update(postId, request, userId)) + CommonResponse.success( + BoardResponseCode.POST_UPDATED_SUCCESS, + managePostUseCase.update(postId, request, userId), + ) @DeleteMapping("/posts/{postId}") @Operation(summary = "게시글 삭제") @@ -86,9 +95,10 @@ class PostController( @RequestParam keyword: String, @RequestParam(defaultValue = "0") pageNumber: Int, @RequestParam(defaultValue = "10") pageSize: Int, + @Parameter(hidden = true) @CurrentUserRole role: Role, ): CommonResponse> = CommonResponse.success( BoardResponseCode.POST_SEARCH_SUCCESS, - getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize), + getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize, role), ) } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 766d016e..625ff1fc 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -30,7 +30,6 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify -import java.util.Optional class ManagePostUseCaseTest : DescribeSpec({ @@ -70,7 +69,7 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userGetService.find(1L) } returns user - every { boardRepository.findById(10L) } returns Optional.of(board) + every { boardRepository.findByIdAndIsDeletedFalse(10L) } returns board val result = useCase.save(10L, request, 1L) @@ -85,12 +84,12 @@ class ManagePostUseCaseTest : id = 20L, name = "공지", type = BoardType.NOTICE, - config = BoardConfig(writePermission = BoardConfig.WritePermission.ADMIN), + config = BoardConfig(writePermission = Role.ADMIN), ) val request = CreatePostRequest(title = "제목", content = "내용") every { userGetService.find(1L) } returns user - every { boardRepository.findById(20L) } returns Optional.of(board) + every { boardRepository.findByIdAndIsDeletedFalse(20L) } returns board shouldThrow { useCase.save(20L, request, 1L) @@ -110,7 +109,7 @@ class ManagePostUseCaseTest : ) every { userGetService.find(1L) } returns user - every { boardRepository.findById(11L) } returns Optional.of(board) + every { boardRepository.findByIdAndIsDeletedFalse(11L) } returns board useCase.save(11L, request, 1L) @@ -128,7 +127,7 @@ class ManagePostUseCaseTest : val request = CreatePostRequest(title = "제목", content = "내용") every { userGetService.find(1L) } returns user - every { boardRepository.findById(999L) } returns Optional.empty() + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null shouldThrow { useCase.save(999L, request, 1L) @@ -143,7 +142,7 @@ class ManagePostUseCaseTest : val post = Post.create("제목", "내용", user, board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findById(1L) } returns Optional.of(post) + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post useCase.update(1L, request, 1L) @@ -190,7 +189,7 @@ class ManagePostUseCaseTest : ), ) - every { postRepository.findById(1L) } returns Optional.of(post) + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) every { fileMapper.toFileList(request.files, FileOwnerType.POST, 1L) } returns newFiles every { fileRepository.saveAll(newFiles) } returns newFiles @@ -203,7 +202,7 @@ class ManagePostUseCaseTest : } describe("delete") { - it("삭제 시 첨부 파일을 soft delete하고 게시글을 삭제한다") { + it("삭제 시 첨부 파일과 게시글을 soft delete한다") { val user = UserTestFixture.createActiveUser1(1L) val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) @@ -217,14 +216,14 @@ class ManagePostUseCaseTest : ownerId = 1L, ) - every { postRepository.findById(1L) } returns Optional.of(post) + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) - every { postRepository.delete(post) } just runs useCase.delete(1L, 1L) oldFile.status.name shouldBe "DELETED" - verify(exactly = 1) { postRepository.delete(post) } + post.isDeleted shouldBe true + verify(exactly = 0) { postRepository.delete(any()) } } } @@ -235,7 +234,7 @@ class ManagePostUseCaseTest : val post = Post(id = 1L, title = "제목", content = "내용", user = owner, board = board) val request = UpdatePostRequest(title = "수정", content = "수정") - every { postRepository.findById(1L) } returns Optional.of(post) + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post shouldThrow { useCase.update(1L, request, 2L) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 4f33c692..c96c8fcd 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -2,11 +2,13 @@ package com.weeth.domain.board.application.usecase.query import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException +import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.entity.Board import com.weeth.domain.board.domain.entity.Post import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository import com.weeth.domain.board.domain.repository.PostRepository import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.comment.application.usecase.query.GetCommentQueryService @@ -29,11 +31,11 @@ import io.mockk.verify import org.springframework.data.domain.PageRequest import org.springframework.data.domain.SliceImpl import java.time.LocalDateTime -import java.util.Optional class GetPostQueryServiceTest : DescribeSpec({ val postRepository = mockk() + val boardRepository = mockk() val commentReader = mockk() val getCommentQueryService = mockk() val fileReader = mockk() @@ -43,6 +45,7 @@ class GetPostQueryServiceTest : val queryService = GetPostQueryService( postRepository, + boardRepository, commentReader, getCommentQueryService, fileReader, @@ -53,6 +56,7 @@ class GetPostQueryServiceTest : beforeTest { clearMocks( postRepository, + boardRepository, commentReader, getCommentQueryService, fileReader, @@ -63,10 +67,10 @@ class GetPostQueryServiceTest : describe("findPost") { it("존재하지 않는 게시글이면 예외를 던진다") { - every { postRepository.findById(1L) } returns Optional.empty() + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null shouldThrow { - queryService.findPost(1L) + queryService.findPost(1L, Role.USER) } } @@ -111,28 +115,53 @@ class GetPostQueryServiceTest : fileUrls = fileResponses, ) - every { postRepository.findById(1L) } returns Optional.of(post) + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { commentReader.findAllByPostId(1L) } returns emptyList() every { getCommentQueryService.toCommentTreeResponses(any()) } returns comments every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns files every { postMapper.toDetailResponse(post, comments, fileResponses) } returns detail every { fileMapper.toFileResponse(files.first()) } returns fileResponses.first() - val result = queryService.findPost(1L) + val result = queryService.findPost(1L, Role.USER) result.id shouldBe 1L result.comments.size shouldBe 1 result.fileUrls.size shouldBe 1 } + + it("비공개 게시판 게시글은 일반/익명에게 노출하지 않는다") { + val user = UserTestFixture.createActiveUser1(1L) + val privateBoard = Board(id = 2L, name = "비공개", type = BoardType.GENERAL) + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = privateBoard, commentCount = 0) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } } describe("searchPosts") { it("검색 결과가 없으면 예외를 던진다") { val pageable = PageRequest.of(0, 10) + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board every { postRepository.searchByBoardId(1L, "키워드", any()) } returns SliceImpl(emptyList(), pageable, false) shouldThrow { - queryService.searchPosts(1L, "키워드", 0, 10) + queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) + } + } + + it("비공개 게시판은 일반/익명이 검색할 수 없다") { + val privateBoard = Board(id = 1L, name = "비공개", type = BoardType.GENERAL) + privateBoard.updateConfig(privateBoard.config.copy(isPrivate = true)) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns privateBoard + + shouldThrow { + queryService.searchPosts(1L, "키워드", 0, 10, Role.USER) } } } @@ -140,7 +169,7 @@ class GetPostQueryServiceTest : describe("validatePage") { it("음수 페이지면 예외를 던진다") { shouldThrow { - queryService.findPosts(1L, -1, 10) + queryService.findPosts(1L, -1, 10, Role.USER) } } } @@ -165,12 +194,12 @@ class GetPostQueryServiceTest : isNew = false, ) - every { postRepository.findAllByBoardId(1L, any()) } returns postSlice + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() - every { fileReader.findAll(FileOwnerType.POST, 10L, any()) } returns emptyList() every { postMapper.toListResponse(any(), any(), any()) } returns response - val result = queryService.findPosts(1L, 0, 10) + val result = queryService.findPosts(1L, 0, 10, Role.USER) result.content.size shouldBe 1 result.content.first().id shouldBe 10L From 1429624d80413d51df5997b834e2e1e322a96a6e Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:36:29 +0900 Subject: [PATCH 27/44] =?UTF-8?q?refactor:=20lint=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/domain/board/application/mapper/PostMapper.kt | 1 - .../board/application/usecase/command/ManageBoardUseCase.kt | 3 +-- .../kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt | 3 +-- .../domain/comment/domain/repository/CommentRepository.kt | 4 +++- .../weeth/domain/board/application/mapper/PostMapperTest.kt | 1 - .../application/usecase/query/GetPostQueryServiceTest.kt | 2 +- .../com/weeth/domain/board/domain/entity/PostEntityTest.kt | 1 - 7 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt index 061abace..c984d77f 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -44,5 +44,4 @@ class PostMapper { hasFile = hasFile, isNew = post.createdAt.isAfter(now.minusHours(24)), ) - } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index f974c710..c1598531 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -61,6 +61,5 @@ class ManageBoardUseCase( board.markDeleted() } - private fun findBoard(boardId: Long): Board = - boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt index 557ed9cf..6926c827 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -6,5 +6,4 @@ data class BoardConfig( val commentEnabled: Boolean = true, val writePermission: Role = Role.USER, val isPrivate: Boolean = false, -) { -} +) diff --git a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt index f19d67a4..3728d37d 100644 --- a/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt +++ b/src/main/kotlin/com/weeth/domain/comment/domain/repository/CommentRepository.kt @@ -3,7 +3,9 @@ package com.weeth.domain.comment.domain.repository import com.weeth.domain.comment.domain.entity.Comment import org.springframework.data.jpa.repository.JpaRepository -interface CommentRepository : JpaRepository, CommentReader { +interface CommentRepository : + JpaRepository, + CommentReader { fun findByIdAndPostId( id: Long, postId: Long, diff --git a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt index 6722d09c..dda26cba 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/mapper/PostMapperTest.kt @@ -79,5 +79,4 @@ class PostMapperTest : response.fileUrls.size shouldBe 1 } } - }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index c96c8fcd..4ec600ff 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -1,8 +1,8 @@ package com.weeth.domain.board.application.usecase.query +import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.NoSearchResultException import com.weeth.domain.board.application.exception.PageNotFoundException -import com.weeth.domain.board.application.exception.BoardNotFoundException import com.weeth.domain.board.application.exception.PostNotFoundException import com.weeth.domain.board.application.mapper.PostMapper import com.weeth.domain.board.domain.entity.Board diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt index 4763185c..bf5e79e1 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -51,5 +51,4 @@ class PostEntityTest : post.decreaseLikeCount() } } - }) From 1bcc59e05fe701997ba7146f53c8adc100cc4d56 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:36:58 +0900 Subject: [PATCH 28/44] =?UTF-8?q?refactor:=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/annotation/CurrentUserRole.java | 11 +++++ .../global/auth/model/AuthenticatedUser.java | 10 ++++ .../CurrentUserRoleArgumentResolver.java | 48 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java create mode 100644 src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java create mode 100644 src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java diff --git a/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java new file mode 100644 index 00000000..56643824 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/annotation/CurrentUserRole.java @@ -0,0 +1,11 @@ +package com.weeth.global.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUserRole { +} diff --git a/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java new file mode 100644 index 00000000..b79c8800 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/model/AuthenticatedUser.java @@ -0,0 +1,10 @@ +package com.weeth.global.auth.model; + +import com.weeth.domain.user.domain.entity.enums.Role; + +public record AuthenticatedUser( + Long id, + String email, + Role role +) { +} diff --git a/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java new file mode 100644 index 00000000..063be6a1 --- /dev/null +++ b/src/main/java/com/weeth/global/auth/resolver/CurrentUserRoleArgumentResolver.java @@ -0,0 +1,48 @@ +package com.weeth.global.auth.resolver; + +import com.weeth.domain.user.domain.entity.enums.Role; +import com.weeth.global.auth.annotation.CurrentUserRole; +import com.weeth.global.auth.jwt.exception.AnonymousAuthenticationException; +import com.weeth.global.auth.model.AuthenticatedUser; +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class CurrentUserRoleArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAnnotation = parameter.hasParameterAnnotation(CurrentUserRole.class); + boolean parameterType = Role.class.isAssignableFrom(parameter.getParameterType()); + return hasAnnotation && parameterType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || authentication instanceof AnonymousAuthenticationToken) { + throw new AnonymousAuthenticationException(); + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof AuthenticatedUser authenticatedUser) { + return authenticatedUser.role(); + } + + for (GrantedAuthority authority : authentication.getAuthorities()) { + String role = authority.getAuthority(); + if (role != null && role.startsWith("ROLE_")) { + return Role.valueOf(role.substring("ROLE_".length())); + } + } + + throw new AnonymousAuthenticationException(); + } +} From d4349a402c7a2b1ea3d2cf74f7fa03066c5f87f6 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:39:27 +0900 Subject: [PATCH 29/44] =?UTF-8?q?docs:=20todo=20=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/application/dto/response/UserResponseDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java index 50112da1..9ec2b286 100644 --- a/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java +++ b/src/main/java/com/weeth/domain/user/application/dto/response/UserResponseDto.java @@ -87,4 +87,4 @@ public record UserInfo( Role role ) { } -} +} //todo: User 전역 dto 구현 (id, 이름, role) From c29bb0497daddc11d0b363dcd5c527a4fb3c9cda Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 10:48:18 +0900 Subject: [PATCH 30/44] =?UTF-8?q?test:=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManageBoardUseCaseTest.kt | 82 +++++++++++++++++++ .../usecase/query/GetBoardQueryServiceTest.kt | 75 +++++++++++++++++ .../board/domain/entity/BoardEntityTest.kt | 10 +++ .../board/domain/entity/PostEntityTest.kt | 10 +++ 4 files changed, 177 insertions(+) create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt create mode 100644 src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt new file mode 100644 index 00000000..4f34b708 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCaseTest.kt @@ -0,0 +1,82 @@ +package com.weeth.domain.board.application.usecase.command + +import com.weeth.domain.board.application.dto.request.CreateBoardRequest +import com.weeth.domain.board.application.dto.request.UpdateBoardRequest +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class ManageBoardUseCaseTest : + DescribeSpec({ + val boardRepository = mockk() + val boardMapper = BoardMapper() + val useCase = ManageBoardUseCase(boardRepository, boardMapper) + + beforeTest { + every { boardRepository.save(any()) } answers { firstArg() } + } + + describe("create") { + it("요청값으로 게시판과 설정을 생성한다") { + val request = + CreateBoardRequest( + name = "운영공지", + type = BoardType.NOTICE, + commentEnabled = false, + writePermission = Role.ADMIN, + isPrivate = true, + ) + + val result = useCase.create(request) + + result.name shouldBe "운영공지" + result.type shouldBe BoardType.NOTICE + result.commentEnabled shouldBe false + result.writePermission shouldBe Role.ADMIN + result.isPrivate shouldBe true + } + } + + describe("update") { + it("일부 필드만 전달되면 해당 필드만 갱신한다") { + val board = Board(id = 1L, name = "기존", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + val result = useCase.update(1L, UpdateBoardRequest(name = "변경", isPrivate = true)) + + result.name shouldBe "변경" + result.commentEnabled shouldBe true + result.writePermission shouldBe Role.USER + result.isPrivate shouldBe true + } + + it("존재하지 않는 게시판이면 예외를 던진다") { + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + + shouldThrow { + useCase.update(999L, UpdateBoardRequest(name = "변경")) + } + } + } + + describe("delete") { + it("게시판을 soft delete 처리한다") { + val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + every { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + + useCase.delete(1L) + + board.isDeleted shouldBe true + verify(exactly = 0) { boardRepository.delete(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt new file mode 100644 index 00000000..b87d1482 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -0,0 +1,75 @@ +package com.weeth.domain.board.application.usecase.query + +import com.weeth.domain.board.application.exception.BoardNotFoundException +import com.weeth.domain.board.application.mapper.BoardMapper +import com.weeth.domain.board.domain.entity.Board +import com.weeth.domain.board.domain.entity.enums.BoardType +import com.weeth.domain.board.domain.repository.BoardRepository +import com.weeth.domain.user.domain.entity.enums.Role +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk + +class GetBoardQueryServiceTest : + DescribeSpec({ + val boardRepository = mockk() + val boardMapper = BoardMapper() + val queryService = GetBoardQueryService(boardRepository, boardMapper) + + describe("findBoards") { + it("일반 사용자에게는 공개 게시판만 반환한다") { + val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + + val result = queryService.findBoards(Role.USER) + + result shouldHaveSize 1 + result.first().id shouldBe 1L + } + + it("관리자에게는 비공개 게시판도 포함해 반환한다") { + val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) + val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + + every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) + + val result = queryService.findBoards(Role.ADMIN) + + result shouldHaveSize 2 + } + } + + describe("findBoard") { + it("일반 사용자가 비공개 게시판 상세를 조회하면 예외를 던진다") { + val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard + + shouldThrow { + queryService.findBoard(2L, Role.USER) + } + } + + it("관리자는 비공개 게시판 상세를 조회할 수 있다") { + val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } + every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard + + val result = queryService.findBoard(2L, Role.ADMIN) + + result.id shouldBe 2L + result.isPrivate shouldBe true + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index 4154ea0c..ac87ae02 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -49,4 +49,14 @@ class BoardEntityTest : board.config shouldBe newConfig } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val board = Board(id = 4L, name = "운영", type = BoardType.GENERAL) + + board.markDeleted() + board.isDeleted shouldBe true + + board.restore() + board.isDeleted shouldBe false + } }) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt index bf5e79e1..222cb9a5 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -51,4 +51,14 @@ class PostEntityTest : post.decreaseLikeCount() } } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val post = PostTestFixture.create() + + post.markDeleted() + post.isDeleted shouldBe true + + post.restore() + post.isDeleted shouldBe false + } }) From bda56c96b51c983ae773d049174ac810fe3af608 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:00:23 +0900 Subject: [PATCH 31/44] =?UTF-8?q?test:=20=EB=8C=93=EA=B8=80=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/CommentConcurrencyTest.kt | 89 ++++++++++++++----- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 8c2b6ac9..bd41081b 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -25,8 +25,10 @@ import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.support.TransactionTemplate import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors +import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToLong @SpringBootTest @ActiveProfiles("test") @@ -52,6 +54,14 @@ class CommentConcurrencyTest( val firstError: String?, ) + data class BenchmarkSummary( + val label: String, + val medianElapsedMs: Double, + val medianQueryCount: Long, + val medianThroughput: Double, + val allElapsedMs: List, + ) + fun createUsers(size: Int): List = (1..size).map { i -> userRepository.save( @@ -139,6 +149,41 @@ class CommentConcurrencyTest( ) } + fun benchmark( + label: String, + rounds: Int, + threadCount: Int, + saveAction: (postId: Long, userId: Long, index: Int) -> Unit, + ): BenchmarkSummary { + val results = (1..rounds).map { runConcurrentSave(threadCount, saveAction) } + results.forEach { r -> + r.failCount shouldBe 0 + r.postCommentCount shouldBe threadCount + r.actualCommentCount shouldBe threadCount + } + + val elapsedSorted = results.map { it.elapsedTimeMs }.sorted() + val querySorted = results.map { it.queryCount }.sorted() + val medianElapsedMs = elapsedSorted[elapsedSorted.size / 2] + val medianQueryCount = querySorted[querySorted.size / 2] + val medianThroughput = threadCount / (medianElapsedMs / 1000.0) + + println( + "[CommentBenchmark][$label] rounds=$rounds, threadCount=$threadCount, " + + "medianElapsedMs=${medianElapsedMs.roundToLong()}, " + + "medianThroughput=${"%.2f".format(medianThroughput)} ops/s, " + + "medianQueryCount=$medianQueryCount, allElapsedMs=${elapsedSorted.map { it.roundToLong() }}", + ) + + return BenchmarkSummary( + label = label, + medianElapsedMs = medianElapsedMs, + medianQueryCount = medianQueryCount, + medianThroughput = medianThroughput, + allElapsedMs = elapsedSorted, + ) + } + afterEach { commentRepository.deleteAllInBatch() postRepository.deleteAllInBatch() @@ -166,11 +211,12 @@ class CommentConcurrencyTest( } describe("동시성 해소 방식별 성능 비교") { - it("PESSIMISTIC_WRITE와 Atomic Increment를 측정한다").config(enabled = runPerformanceTests) { + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다").config(enabled = runPerformanceTests) { val threadCount = 30 + val rounds = 5 - val pessimisticResult = - runConcurrentSave(threadCount) { postId, userId, index -> + val pessimisticSummary = + benchmark("pessimistic", rounds, threadCount) { postId, userId, index -> postCommentUsecase.savePostComment( dto = CommentSaveRequest( @@ -183,8 +229,8 @@ class CommentConcurrencyTest( ) } - val atomicResult = - runConcurrentSave(threadCount) { postId, userId, index -> + val atomicSummary = + benchmark("atomic", rounds, threadCount) { postId, userId, index -> atomicCommentCountCommand.savePostCommentWithAtomicIncrement( dto = CommentSaveRequest( @@ -197,20 +243,21 @@ class CommentConcurrencyTest( ) } - pessimisticResult.failCount shouldBe 0 - atomicResult.failCount shouldBe 0 - pessimisticResult.postCommentCount shouldBe threadCount - pessimisticResult.actualCommentCount shouldBe threadCount - atomicResult.postCommentCount shouldBe threadCount - atomicResult.actualCommentCount shouldBe threadCount + println( + "[CommentBenchmark][compare] " + + "atomicMedian=${atomicSummary.medianElapsedMs.roundToLong()}ms, " + + "pessimisticMedian=${pessimisticSummary.medianElapsedMs.roundToLong()}ms, " + + "atomicThroughput=${"%.2f".format(atomicSummary.medianThroughput)} ops/s, " + + "pessimisticThroughput=${"%.2f".format(pessimisticSummary.medianThroughput)} ops/s", + ) + val winner = if (atomicSummary.medianElapsedMs < pessimisticSummary.medianElapsedMs) "atomic" else "pessimistic" + println("[CommentBenchmark][winner] $winner") } } }) class AtomicCommentCountCommand( private val commentRepository: CommentRepository, - private val postRepository: PostRepository, - private val userRepository: UserRepository, private val entityManager: EntityManager, private val transactionTemplate: TransactionTemplate, ) { @@ -219,14 +266,14 @@ class AtomicCommentCountCommand( postId: Long, userId: Long, ) { - val maxRetries = 10 + val maxRetries = 20 var lastError: Exception? = null repeat(maxRetries) { attempt -> try { transactionTemplate.executeWithoutResult { - val user = userRepository.findById(userId).orElseThrow() - val post = postRepository.findById(postId).orElseThrow() + val user = entityManager.getReference(User::class.java, userId) + val post = entityManager.getReference(Post::class.java, postId) val parent = dto.parentCommentId?.let { parentId -> commentRepository.findByIdAndPostId(parentId, postId) ?: throw IllegalArgumentException("parent not found") @@ -250,10 +297,12 @@ class AtomicCommentCountCommand( } catch (e: Exception) { lastError = e val deadlock = e.message?.contains("Deadlock found", ignoreCase = true) == true - if (!deadlock || attempt == maxRetries - 1) { + val lockWaitTimeout = e.message?.contains("Lock wait timeout exceeded", ignoreCase = true) == true + if ((!deadlock && !lockWaitTimeout) || attempt == maxRetries - 1) { throw e } - Thread.sleep(10) + val backoffMs = ThreadLocalRandom.current().nextLong(10, 40) + Thread.sleep(backoffMs) } } @@ -266,15 +315,11 @@ class CommentConcurrencyBenchmarkConfig { @Bean fun atomicCommentCountCommand( commentRepository: CommentRepository, - postRepository: PostRepository, - userRepository: UserRepository, entityManager: EntityManager, transactionManager: PlatformTransactionManager, ): AtomicCommentCountCommand = AtomicCommentCountCommand( commentRepository = commentRepository, - postRepository = postRepository, - userRepository = userRepository, entityManager = entityManager, transactionTemplate = TransactionTemplate(transactionManager), ) From 5f021b134a5dae49badf917cff94e57ea7470445 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:02:31 +0900 Subject: [PATCH 32/44] =?UTF-8?q?docs:=20todo=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/weeth/domain/board/presentation/PostController.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index 5de97ae8..d11c7bfb 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -101,4 +101,6 @@ class PostController( BoardResponseCode.POST_SEARCH_SUCCESS, getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize, role), ) + + // todo: 좋아요 관련 API 추가 } From 1ffb796cec7b737ef0c940ecc4ebe111f084375f Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:12:48 +0900 Subject: [PATCH 33/44] =?UTF-8?q?refactor:=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/PostDetailResponse.kt | 1 - .../dto/response/PostListResponse.kt | 1 - .../usecase/query/GetBoardQueryService.kt | 18 +++------- .../usecase/query/GetPostQueryService.kt | 35 +++++++++---------- .../weeth/domain/board/domain/entity/Board.kt | 2 ++ .../board/presentation/BoardController.kt | 2 +- .../board/presentation/PostController.kt | 2 +- .../usecase/query/GetPostQueryServiceTest.kt | 12 +++++++ .../board/domain/entity/BoardEntityTest.kt | 13 +++++++ 9 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt index 8be6f966..62963ee6 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -2,7 +2,6 @@ package com.weeth.domain.board.application.dto.response import com.weeth.domain.comment.application.dto.response.CommentResponse import com.weeth.domain.file.application.dto.response.FileResponse -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt index f54d1846..10729f2f 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -1,6 +1,5 @@ package com.weeth.domain.board.application.dto.response -import com.weeth.domain.user.domain.entity.enums.Position import com.weeth.domain.user.domain.entity.enums.Role import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDateTime diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt index 4d13b4ad..e63bf21d 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -15,23 +15,20 @@ class GetBoardQueryService( private val boardRepository: BoardRepository, private val boardMapper: BoardMapper, ) { - fun findBoards(role: Role): List { - val isAdmin = isAdmin(role) - return boardRepository + fun findBoards(role: Role): List = + boardRepository .findAllByIsDeletedFalseOrderByIdAsc() - .filter { canAccessBoard(it.config.isPrivate, isAdmin) } + .filter { it.isAccessibleBy(role) } .map(boardMapper::toListResponse) - } fun findBoard( boardId: Long, role: Role, ): BoardDetailResponse { - val isAdmin = isAdmin(role) val board = boardRepository .findByIdAndIsDeletedFalse(boardId) - ?.takeIf { canAccessBoard(it.config.isPrivate, isAdmin) } + ?.takeIf { it.isAccessibleBy(role) } ?: throw BoardNotFoundException() return boardMapper.toDetailResponse(board) } @@ -40,11 +37,4 @@ class GetBoardQueryService( boardRepository .findAllByIsDeletedFalseOrderByIdAsc() .map(boardMapper::toDetailResponseForAdmin) - - private fun canAccessBoard( - isPrivate: Boolean, - isAdmin: Boolean, - ): Boolean = isAdmin || !isPrivate - - private fun isAdmin(role: Role): Boolean = role == Role.ADMIN } diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 62f7d866..589d0652 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -33,13 +33,16 @@ class GetPostQueryService( private val fileMapper: FileMapper, private val postMapper: PostMapper, ) { + companion object { + private const val MAX_PAGE_SIZE = 50 + } + fun findPost( postId: Long, role: Role, ): PostDetailResponse { - val isAdmin = isAdmin(role) val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() - if (!canAccessBoard(post.board.config.isPrivate, isAdmin)) { + if (!post.board.isAccessibleBy(role)) { throw PostNotFoundException() } @@ -56,9 +59,8 @@ class GetPostQueryService( pageSize: Int, role: Role, ): Slice { - validatePage(pageNumber) - val isAdmin = isAdmin(role) - validateBoardVisibility(boardId, isAdmin) + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, role) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.findAllActiveByBoardId(boardId, pageable) @@ -76,9 +78,8 @@ class GetPostQueryService( pageSize: Int, role: Role, ): Slice { - validatePage(pageNumber) - val isAdmin = isAdmin(role) - validateBoardVisibility(boardId, isAdmin) + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, role) val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) val posts = postRepository.searchByBoardId(boardId, keyword.trim(), pageable) @@ -93,8 +94,11 @@ class GetPostQueryService( return posts.map { postMapper.toListResponse(it, fileExistsByPostId[it.id] == true, now) } } - private fun validatePage(pageNumber: Int) { - if (pageNumber < 0) { + private fun validatePage( + pageNumber: Int, + pageSize: Int, + ) { + if (pageNumber < 0 || pageSize !in 1..MAX_PAGE_SIZE) { throw PageNotFoundException() } } @@ -109,18 +113,11 @@ class GetPostQueryService( private fun validateBoardVisibility( boardId: Long, - isAdmin: Boolean, + role: Role, ) { val board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() - if (!canAccessBoard(board.config.isPrivate, isAdmin)) { + if (!board.isAccessibleBy(role)) { throw BoardNotFoundException() } } - - private fun canAccessBoard( - isPrivate: Boolean, - isAdmin: Boolean, - ): Boolean = isAdmin || !isPrivate - - private fun isAdmin(role: Role): Boolean = role == Role.ADMIN } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index 57126214..f5e50bfc 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -38,6 +38,8 @@ class Board( val isAdminOnly: Boolean get() = config.writePermission == Role.ADMIN + fun isAccessibleBy(role: Role): Boolean = role == Role.ADMIN || !config.isPrivate + fun updateConfig(newConfig: BoardConfig) { config = newConfig } diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt index d96b08dd..7fa127e0 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardController.kt @@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -@Tag(name = "Board", description = "Board API") +@Tag(name = "BOARD", description = "게시판 API") @RestController @RequestMapping("/api/v4/boards") @ApiErrorCodeExample(BoardErrorCode::class) diff --git a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt index d11c7bfb..6209d1a0 100644 --- a/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -28,7 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController -@Tag(name = "BOARD", description = "게시판 API") +@Tag(name = "BOARD", description = "게시글 API") @RestController @RequestMapping("/api/v4/boards") @ApiErrorCodeExample(BoardErrorCode::class) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 4ec600ff..36c24b2c 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -172,6 +172,18 @@ class GetPostQueryServiceTest : queryService.findPosts(1L, -1, 10, Role.USER) } } + + it("pageSize가 0이면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, 0, 0, Role.USER) + } + } + + it("pageSize가 최대값을 초과하면 예외를 던진다") { + shouldThrow { + queryService.findPosts(1L, 0, 51, Role.USER) + } + } } describe("findPosts") { diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index ac87ae02..b0c5e4c6 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -41,6 +41,19 @@ class BoardEntityTest : board.isAdminOnly shouldBe true } + "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { + val privateBoard = + Board( + id = 20L, + name = "운영", + type = BoardType.NOTICE, + config = BoardConfig(isPrivate = true), + ) + + privateBoard.isAccessibleBy(Role.ADMIN) shouldBe true + privateBoard.isAccessibleBy(Role.USER) shouldBe false + } + "updateConfig는 config를 교체한다" { val board = Board(id = 3L, name = "일반", type = BoardType.GENERAL) val newConfig = BoardConfig(commentEnabled = false, isPrivate = true) From 9e6cf8f0864e1e72c4993368648ba5dcd2341d99 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:17:16 +0900 Subject: [PATCH 34/44] =?UTF-8?q?refactor:=20ktlint=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/swagger/SwaggerConfig.java | 2 +- .../usecase/query/GetBoardQueryServiceTest.kt | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java index 1a8eec1e..ad5958f1 100644 --- a/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java @@ -92,7 +92,7 @@ public GroupedOpenApi adminApi() { public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public") - .pathsToExclude("/api/v1/admin/**") + .pathsToExclude("/api/v1/admin/**", "/api/v4/admin/**") .addOperationCustomizer(operationCustomizer()) .build(); } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt index b87d1482..59e4a964 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -22,9 +22,10 @@ class GetBoardQueryServiceTest : describe("findBoards") { it("일반 사용자에게는 공개 게시판만 반환한다") { val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { - updateConfig(config.copy(isPrivate = true)) - } + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) @@ -36,9 +37,10 @@ class GetBoardQueryServiceTest : it("관리자에게는 비공개 게시판도 포함해 반환한다") { val publicBoard = Board(id = 1L, name = "일반", type = BoardType.GENERAL) - val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { - updateConfig(config.copy(isPrivate = true)) - } + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } every { boardRepository.findAllByIsDeletedFalseOrderByIdAsc() } returns listOf(publicBoard, privateBoard) @@ -50,9 +52,10 @@ class GetBoardQueryServiceTest : describe("findBoard") { it("일반 사용자가 비공개 게시판 상세를 조회하면 예외를 던진다") { - val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { - updateConfig(config.copy(isPrivate = true)) - } + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard shouldThrow { @@ -61,9 +64,10 @@ class GetBoardQueryServiceTest : } it("관리자는 비공개 게시판 상세를 조회할 수 있다") { - val privateBoard = Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { - updateConfig(config.copy(isPrivate = true)) - } + val privateBoard = + Board(id = 2L, name = "운영", type = BoardType.NOTICE).apply { + updateConfig(config.copy(isPrivate = true)) + } every { boardRepository.findByIdAndIsDeletedFalse(2L) } returns privateBoard val result = queryService.findBoard(2L, Role.ADMIN) From 55417abfdedb0d9b492c756dec54a0e380d63eb0 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:21:34 +0900 Subject: [PATCH 35/44] =?UTF-8?q?test:=20CI=EC=97=90=EC=84=9C=20=EB=8F=8C?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/CommentConcurrencyTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index bd41081b..4f757cc6 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -16,6 +16,7 @@ import com.weeth.domain.user.domain.repository.UserRepository import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe import jakarta.persistence.EntityManager +import org.junit.jupiter.api.Tag import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean @@ -33,6 +34,7 @@ import kotlin.math.roundToLong @SpringBootTest @ActiveProfiles("test") @Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) +@Tag("performance") class CommentConcurrencyTest( private val postCommentUsecase: PostCommentUsecase, private val boardRepository: BoardRepository, From 1e39d9cf757a61788882f73f65cce00390a1978e Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:28:33 +0900 Subject: [PATCH 36/44] =?UTF-8?q?test:=20redis=20test=20container=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/com/weeth/config/TestContainersConfig.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt index 23c5dd1f..70c0403d 100644 --- a/src/test/kotlin/com/weeth/config/TestContainersConfig.kt +++ b/src/test/kotlin/com/weeth/config/TestContainersConfig.kt @@ -3,6 +3,7 @@ package com.weeth.config import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.testcontainers.service.connection.ServiceConnection import org.springframework.context.annotation.Bean +import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.MySQLContainer import org.testcontainers.utility.DockerImageName @@ -12,7 +13,14 @@ class TestContainersConfig { @ServiceConnection fun mysqlContainer(): MySQLContainer<*> = MySQLContainer(DockerImageName.parse(MYSQL_IMAGE)) + @Bean + @ServiceConnection(name = "redis") + fun redisContainer(): GenericContainer<*> = + GenericContainer(DockerImageName.parse(REDIS_IMAGE)) + .withExposedPorts(6379) + companion object { private const val MYSQL_IMAGE = "mysql:8.0.41" + private const val REDIS_IMAGE = "redis:7.2.7" } } From 67540cf086dee0c19e447c741ee712f345ebbc8c Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 11:33:50 +0900 Subject: [PATCH 37/44] =?UTF-8?q?test:=20CI=EC=97=90=20Redis=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb0cfdd4..e1d4298c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,16 @@ on: jobs: ci: runs-on: ubuntu-latest + services: + redis: + image: redis:7.2 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout From 52bfa7dccf4c15b72dbe07c816eb6092a286b1f5 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:25:24 +0900 Subject: [PATCH 38/44] =?UTF-8?q?refactor:=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/query/GetPostQueryService.kt | 2 +- .../usecase/query/GetPostQueryServiceTest.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt index 589d0652..25d8d747 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -42,7 +42,7 @@ class GetPostQueryService( role: Role, ): PostDetailResponse { val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() - if (!post.board.isAccessibleBy(role)) { + if (post.board.isDeleted || !post.board.isAccessibleBy(role)) { throw PostNotFoundException() } diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt index 36c24b2c..d5c7819c 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -141,6 +141,18 @@ class GetPostQueryServiceTest : queryService.findPost(1L, Role.USER) } } + + it("삭제된 게시판의 게시글은 조회할 수 없다") { + val user = UserTestFixture.createActiveUser1(1L) + val deletedBoard = Board(id = 3L, name = "삭제", type = BoardType.GENERAL, isDeleted = true) + val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = deletedBoard, commentCount = 0) + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } } describe("searchPosts") { From 4a57b52ddc5a9d83a37206ae5e12039f5aa1f192 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:26:05 +0900 Subject: [PATCH 39/44] =?UTF-8?q?refactor:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=8B=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt index 6849b430..9545169c 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -60,6 +60,7 @@ class Post( newContent: String, ) { require(newTitle.isNotBlank()) { "title must not be blank" } + require(newContent.isNotBlank()) { "content must not be blank" } title = newTitle content = newContent } From b83b505a1b068c768c3b6f14786eec7dad9bf580 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:26:09 +0900 Subject: [PATCH 40/44] =?UTF-8?q?refactor:=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=8B=9C=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/board/domain/entity/PostEntityTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt index 222cb9a5..944623b2 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -33,9 +33,22 @@ class PostEntityTest : ) post.title shouldBe "변경" + post.content shouldBe "변경 내용" post.cardinalNumber shouldBe 7 } + "update는 content가 공백이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.update( + newTitle = "변경", + newContent = " ", + newCardinalNumber = null, + ) + } + } + "increaseLikeCount는 좋아요 수를 1 증가시킨다" { val post = PostTestFixture.create() From 84f345e5ef236dbbb9e8dad2d6f550284189bd2d Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:28:41 +0900 Subject: [PATCH 41/44] =?UTF-8?q?test:=20=ED=97=AC=ED=8D=BC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManagePostUseCaseTest.kt | 88 ++++++++++++------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt index 625ff1fc..9374e211 100644 --- a/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -18,7 +18,9 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.domain.entity.User import com.weeth.domain.user.domain.entity.enums.Role +import com.weeth.domain.user.domain.entity.enums.Status import com.weeth.domain.user.domain.service.UserGetService import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -52,6 +54,32 @@ class ManagePostUseCaseTest : postMapper, ) + fun createUploadedPostFile( + fileName: String, + ownerId: Long = 1L, + ): File = + File.createUploaded( + fileName = fileName, + storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_$fileName", + fileSize = 10, + contentType = "image/png", + ownerType = FileOwnerType.POST, + ownerId = ownerId, + ) + + fun createUser( + id: Long = 1L, + role: Role = Role.USER, + ): User = + User + .builder() + .id(id) + .name("적순") + .email("test1@test.com") + .status(Status.ACTIVE) + .role(role) + .build() + beforeTest { clearMocks(postRepository, boardRepository, userGetService, fileRepository, fileReader, fileMapper, postMapper) every { postRepository.save(any()) } answers { firstArg() } @@ -64,7 +92,7 @@ class ManagePostUseCaseTest : describe("save") { it("일반 게시판에서 게시글을 저장한다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest(title = "제목", content = "내용") @@ -78,7 +106,7 @@ class ManagePostUseCaseTest : } it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = Board( id = 20L, @@ -98,8 +126,29 @@ class ManagePostUseCaseTest : verify(exactly = 0) { postRepository.save(any()) } } + it("비공개 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + Board( + id = 21L, + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(21L) } returns board + + shouldThrow { + useCase.save(21L, request, 1L) + } + + verify(exactly = 0) { postRepository.save(any()) } + } + it("cardinalNumber가 전달되면 게시글에 반영된다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val board = Board(id = 11L, name = "일반", type = BoardType.GENERAL) val request = CreatePostRequest( @@ -123,7 +172,7 @@ class ManagePostUseCaseTest : } it("존재하지 않는 boardId면 예외를 던진다") { - val user = UserTestFixture.createActiveUser1(1L) + val user = createUser(1L, Role.USER) val request = CreatePostRequest(title = "제목", content = "내용") every { userGetService.find(1L) } returns user @@ -154,25 +203,10 @@ class ManagePostUseCaseTest : val user = UserTestFixture.createActiveUser1(1L) val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_old.png", - fileSize = 10, - contentType = "image/png", - ownerType = FileOwnerType.POST, - ownerId = 1L, - ) + val oldFile = createUploadedPostFile("old.png") val newFiles = listOf( - File.createUploaded( - fileName = "new.png", - storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440001_new.png", - fileSize = 10, - contentType = "image/png", - ownerType = FileOwnerType.POST, - ownerId = 1L, - ), + createUploadedPostFile("new.png"), ) val request = UpdatePostRequest( @@ -197,6 +231,8 @@ class ManagePostUseCaseTest : useCase.update(1L, request, 1L) oldFile.status.name shouldBe "DELETED" + post.title shouldBe "수정" + post.content shouldBe "수정" verify(exactly = 1) { fileRepository.saveAll(newFiles) } } } @@ -206,15 +242,7 @@ class ManagePostUseCaseTest : val user = UserTestFixture.createActiveUser1(1L) val board = Board(id = 1L, name = "일반", type = BoardType.GENERAL) val post = Post(id = 1L, title = "제목", content = "내용", user = user, board = board) - val oldFile = - File.createUploaded( - fileName = "old.png", - storageKey = "POST/2026-02/550e8400-e29b-41d4-a716-446655440000_old.png", - fileSize = 10, - contentType = "image/png", - ownerType = FileOwnerType.POST, - ownerId = 1L, - ) + val oldFile = createUploadedPostFile("old.png") every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) From d6eafd92570eda7d504472124a101bc0e3ab590c Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:29:21 +0900 Subject: [PATCH 42/44] =?UTF-8?q?docs:=20todo=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../board/application/usecase/command/ManageBoardUseCase.kt | 2 ++ .../board/application/usecase/command/ManagePostUseCase.kt | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt index c1598531..e6a559c5 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -40,9 +40,11 @@ class ManageBoardUseCase( ): BoardDetailResponse { val board = findBoard(boardId) + // TODO: PATCH 규칙 - 요청 값이 현재 값과 다를 때만 반영하도록 수정 필요 request.name?.let { board.rename(it) } if (request.commentEnabled != null || request.writePermission != null || request.isPrivate != null) { + // TODO: PATCH 규칙 - 각 필드별로 변경 여부를 비교해 바뀐 값만 업데이트하도록 수정 필요 board.updateConfig( board.config.copy( commentEnabled = request.commentEnabled ?: board.config.commentEnabled, diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 6a04d54e..14bd05ab 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -66,6 +66,7 @@ class ManagePostUseCase( val post = findPost(postId) validateOwner(post, userId) + // TODO: PATCH 규칙 - title/content/cardinalNumber는 실제 변경된 경우에만 반영하도록 수정 필요 post.update( newTitle = request.title, newContent = request.content, From 473745692752d03816eecac4895978790bb546a8 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:32:20 +0900 Subject: [PATCH 43/44] =?UTF-8?q?test:=20gradle=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/command/CommentConcurrencyTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt index 4f757cc6..7c718db8 100644 --- a/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -44,7 +44,6 @@ class CommentConcurrencyTest( private val entityManager: EntityManager, private val atomicCommentCountCommand: AtomicCommentCountCommand, ) : DescribeSpec({ - val runPerformanceTests = System.getProperty("runPerformanceTests")?.toBoolean() ?: false data class ConcurrencyResult( val successCount: Int, @@ -213,7 +212,7 @@ class CommentConcurrencyTest( } describe("동시성 해소 방식별 성능 비교") { - it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다").config(enabled = runPerformanceTests) { + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다") { val threadCount = 30 val rounds = 5 From b692fdad618e8c2d4b9ef156794ad255f9f6c057 Mon Sep 17 00:00:00 2001 From: hyxklee Date: Thu, 19 Feb 2026 20:40:52 +0900 Subject: [PATCH 44/44] =?UTF-8?q?refactor:=20=EA=B8=80=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/command/ManagePostUseCase.kt | 4 +- .../weeth/domain/board/domain/entity/Board.kt | 5 ++ .../board/domain/entity/BoardEntityTest.kt | 46 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt index 14bd05ab..afe3bf21 100644 --- a/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt @@ -18,7 +18,6 @@ import com.weeth.domain.file.domain.entity.FileOwnerType import com.weeth.domain.file.domain.repository.FileReader import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.user.domain.entity.User -import com.weeth.domain.user.domain.entity.enums.Role import com.weeth.domain.user.domain.service.UserGetService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -106,7 +105,8 @@ class ManagePostUseCase( board: Board, user: User, ) { - if (board.isAdminOnly && user.role != Role.ADMIN) { + val userRole = user.role ?: throw CategoryAccessDeniedException() + if (!board.canWriteBy(userRole)) { throw CategoryAccessDeniedException() } } diff --git a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt index f5e50bfc..34894e3f 100644 --- a/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -38,8 +38,13 @@ class Board( val isAdminOnly: Boolean get() = config.writePermission == Role.ADMIN + val isRestricted: Boolean + get() = isAdminOnly || config.isPrivate + fun isAccessibleBy(role: Role): Boolean = role == Role.ADMIN || !config.isPrivate + fun canWriteBy(role: Role): Boolean = isAccessibleBy(role) && (!isAdminOnly || role == Role.ADMIN) + fun updateConfig(newConfig: BoardConfig) { config = newConfig } diff --git a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt index b0c5e4c6..4d93eb43 100644 --- a/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -41,6 +41,34 @@ class BoardEntityTest : board.isAdminOnly shouldBe true } + "isRestricted는 ADMIN 전용 또는 비공개 게시판이면 true를 반환한다" { + val adminOnlyBoard = + Board( + id = 21L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val privateBoard = + Board( + id = 22L, + name = "비공개", + type = BoardType.GENERAL, + config = BoardConfig(isPrivate = true), + ) + val publicBoard = + Board( + id = 23L, + name = "일반", + type = BoardType.GENERAL, + config = BoardConfig(), + ) + + adminOnlyBoard.isRestricted shouldBe true + privateBoard.isRestricted shouldBe true + publicBoard.isRestricted shouldBe false + } + "isAccessibleBy는 비공개 게시판을 ADMIN에게만 허용한다" { val privateBoard = Board( @@ -54,6 +82,24 @@ class BoardEntityTest : privateBoard.isAccessibleBy(Role.USER) shouldBe false } + "canWriteBy는 비공개/관리자 전용 설정을 모두 고려한다" { + val privateBoard = Board(id = 24L, name = "비공개", type = BoardType.GENERAL, config = BoardConfig(isPrivate = true)) + val adminOnlyBoard = + Board( + id = 25L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val publicBoard = Board(id = 26L, name = "일반", type = BoardType.GENERAL, config = BoardConfig()) + + privateBoard.canWriteBy(Role.USER) shouldBe false + privateBoard.canWriteBy(Role.ADMIN) shouldBe true + adminOnlyBoard.canWriteBy(Role.USER) shouldBe false + adminOnlyBoard.canWriteBy(Role.ADMIN) shouldBe true + publicBoard.canWriteBy(Role.USER) shouldBe true + } + "updateConfig는 config를 교체한다" { val board = Board(id = 3L, name = "일반", type = BoardType.GENERAL) val newConfig = BoardConfig(commentEnabled = false, isPrivate = true)