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 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/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/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/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/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/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; -} 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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) 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/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/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/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/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(); + } +} 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}) diff --git a/src/main/java/com/weeth/global/config/SecurityConfig.java b/src/main/java/com/weeth/global/config/SecurityConfig.java index e8175593..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; @@ -78,7 +76,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 -> @@ -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/java/com/weeth/global/config/swagger/SwaggerConfig.java b/src/main/java/com/weeth/global/config/swagger/SwaggerConfig.java index afd607e6..ad5958f1 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(); } @@ -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/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/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/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/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/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/dto/response/PostDetailResponse.kt b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt new file mode 100644 index 00000000..62963ee6 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostDetailResponse.kt @@ -0,0 +1,28 @@ +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.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..10729f2f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/dto/response/PostListResponse.kt @@ -0,0 +1,26 @@ +package com.weeth.domain.board.application.dto.response + +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, +) 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) 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/mapper/PostMapper.kt b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt new file mode 100644 index 00000000..c984d77f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/mapper/PostMapper.kt @@ -0,0 +1,47 @@ +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/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..e6a559c5 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManageBoardUseCase.kt @@ -0,0 +1,67 @@ +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) + + // 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, + 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/command/ManagePostUseCase.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCase.kt new file mode 100644 index 00000000..afe3bf21 --- /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.service.UserGetService +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) + + // TODO: PATCH 규칙 - title/content/cardinalNumber는 실제 변경된 경우에만 반영하도록 수정 필요 + 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) + post.markDeleted() + } + + private fun findBoard(boardId: Long): Board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + + private fun findPost(postId: Long): Post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + + private fun validateOwner( + post: Post, + userId: Long, + ) { + if (!post.isOwnedBy(userId)) { + throw PostNotOwnedException() + } + } + + private fun checkWritePermission( + board: Board, + user: User, + ) { + val userRole = user.role ?: throw CategoryAccessDeniedException() + if (!board.canWriteBy(userRole)) { + 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/GetBoardQueryService.kt b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt new file mode 100644 index 00000000..e63bf21d --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryService.kt @@ -0,0 +1,40 @@ +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 = + boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .filter { it.isAccessibleBy(role) } + .map(boardMapper::toListResponse) + + fun findBoard( + boardId: Long, + role: Role, + ): BoardDetailResponse { + val board = + boardRepository + .findByIdAndIsDeletedFalse(boardId) + ?.takeIf { it.isAccessibleBy(role) } + ?: throw BoardNotFoundException() + return boardMapper.toDetailResponse(board) + } + + fun findAllBoardsForAdmin(): List = + boardRepository + .findAllByIsDeletedFalseOrderByIdAsc() + .map(boardMapper::toDetailResponseForAdmin) +} 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..25d8d747 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryService.kt @@ -0,0 +1,123 @@ +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.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@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, +) { + companion object { + private const val MAX_PAGE_SIZE = 50 + } + + fun findPost( + postId: Long, + role: Role, + ): PostDetailResponse { + val post = postRepository.findByIdAndIsDeletedFalse(postId) ?: throw PostNotFoundException() + if (post.board.isDeleted || !post.board.isAccessibleBy(role)) { + 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, + role: Role, + ): Slice { + validatePage(pageNumber, pageSize) + validateBoardVisibility(boardId, role) + val pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.DESC, "id")) + val posts = postRepository.findAllActiveByBoardId(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, + role: Role, + ): Slice { + 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) + + 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, + pageSize: Int, + ) { + if (pageNumber < 0 || pageSize !in 1..MAX_PAGE_SIZE) { + 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) } + } + + private fun validateBoardVisibility( + boardId: Long, + role: Role, + ) { + val board = boardRepository.findByIdAndIsDeletedFalse(boardId) ?: throw BoardNotFoundException() + if (!board.isAccessibleBy(role)) { + throw BoardNotFoundException() + } + } +} 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..34894e3f --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Board.kt @@ -0,0 +1,64 @@ +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 +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(), + @Column(nullable = false) + var isDeleted: Boolean = false, +) : BaseEntity() { + val isCommentEnabled: Boolean + get() = config.commentEnabled + + 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 + } + + fun rename(newName: String) { + 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 new file mode 100644 index 00000000..9545169c --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/entity/Post.kt @@ -0,0 +1,106 @@ +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, + @Column(nullable = false) + var isDeleted: Boolean = false, +) : 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" } + require(newContent.isNotBlank()) { "content 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 + } + + fun markDeleted() { + isDeleted = true + } + + fun restore() { + isDeleted = false + } + + 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/repository/BoardRepository.kt b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt new file mode 100644 index 00000000..268cd084 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/BoardRepository.kt @@ -0,0 +1,12 @@ +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 { + fun findAllByIsDeletedFalseOrderByIdAsc(): List + + fun findByIdAndIsDeletedFalse(id: Long): Board? + + fun findAllByOrderByIdAsc(): List +} 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..1f5a64c3 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/repository/PostRepository.kt @@ -0,0 +1,64 @@ +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"]) + @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 + 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, '%'))) + """, + ) + fun searchByBoardId( + @Param("boardId") boardId: Long, + @Param("keyword") keyword: String, + pageable: Pageable, + ): Slice +} 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..6926c827 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/domain/vo/BoardConfig.kt @@ -0,0 +1,9 @@ +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: Role = Role.USER, + val isPrivate: Boolean = false, +) 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..7fa127e0 --- /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 = "게시판 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 new file mode 100644 index 00000000..2f45b492 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/BoardResponseCode.kt @@ -0,0 +1,22 @@ +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 { + 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 new file mode 100644 index 00000000..6209d1a0 --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/board/presentation/PostController.kt @@ -0,0 +1,106 @@ +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.domain.user.domain.entity.enums.Role +import com.weeth.global.auth.annotation.CurrentUser +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 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, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + 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, role)) + + @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, + @Parameter(hidden = true) @CurrentUserRole role: Role, + ): CommonResponse> = + CommonResponse.success( + BoardResponseCode.POST_SEARCH_SUCCESS, + getPostQueryService.searchPosts(boardId, keyword, pageNumber, pageSize, role), + ) + + // todo: 좋아요 관련 API 추가 +} 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, ) 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..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,21 +109,21 @@ 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) - // 부모 댓글이 삭제된 상태이고 자식 댓글이 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 } - // 자식 댓글이 있는 경우 -> 댓글을 Soft Delete해 서비스에서 "삭제된 댓글"으로 표시 deleteCommentFiles(comment) comment.markAsDeleted() } @@ -219,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/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/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..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,14 +3,11 @@ 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/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/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) } } 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/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) } +} 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" } } 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..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 @@ -1,38 +1,82 @@ 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 + } } }) 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/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) } - } - } - }) 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/command/ManagePostUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt new file mode 100644 index 00000000..9374e211 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/command/ManagePostUseCaseTest.kt @@ -0,0 +1,272 @@ +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.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 +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 + +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, + ) + + 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() } + 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 = createUser(1L, Role.USER) + val board = Board(id = 10L, name = "일반", type = BoardType.GENERAL) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(10L) } returns board + + val result = useCase.save(10L, request, 1L) + + result.id shouldBe 1L + verify(exactly = 1) { postRepository.save(any()) } + } + + it("ADMIN 전용 게시판에 일반 사용자가 작성하면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val board = + Board( + id = 20L, + name = "공지", + type = BoardType.NOTICE, + config = BoardConfig(writePermission = Role.ADMIN), + ) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(20L) } returns board + + shouldThrow { + useCase.save(20L, request, 1L) + } + + 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 = createUser(1L, Role.USER) + val board = Board(id = 11L, name = "일반", type = BoardType.GENERAL) + val request = + CreatePostRequest( + title = "게시글", + content = "내용", + cardinalNumber = 6, + ) + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(11L) } returns board + + useCase.save(11L, request, 1L) + + verify { + postRepository.save( + match { + it.cardinalNumber == 6 + }, + ) + } + } + + it("존재하지 않는 boardId면 예외를 던진다") { + val user = createUser(1L, Role.USER) + val request = CreatePostRequest(title = "제목", content = "내용") + + every { userGetService.find(1L) } returns user + every { boardRepository.findByIdAndIsDeletedFalse(999L) } returns null + + 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.findByIdAndIsDeletedFalse(1L) } returns 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 = createUploadedPostFile("old.png") + val newFiles = + listOf( + createUploadedPostFile("new.png"), + ) + 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.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 + + useCase.update(1L, request, 1L) + + oldFile.status.name shouldBe "DELETED" + post.title shouldBe "수정" + post.content shouldBe "수정" + 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 = createUploadedPostFile("old.png") + + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns post + every { fileReader.findAll(FileOwnerType.POST, 1L, any()) } returns listOf(oldFile) + + useCase.delete(1L, 1L) + + oldFile.status.name shouldBe "DELETED" + post.isDeleted shouldBe true + verify(exactly = 0) { postRepository.delete(any()) } + } + } + + 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.findByIdAndIsDeletedFalse(1L) } returns post + + shouldThrow { + useCase.update(1L, request, 2L) + } + } + } + }) 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..59e4a964 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetBoardQueryServiceTest.kt @@ -0,0 +1,79 @@ +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/application/usecase/query/GetPostQueryServiceTest.kt b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt new file mode 100644 index 00000000..d5c7819c --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/application/usecase/query/GetPostQueryServiceTest.kt @@ -0,0 +1,233 @@ +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.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 +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 + +class GetPostQueryServiceTest : + DescribeSpec({ + val postRepository = mockk() + val boardRepository = mockk() + val commentReader = mockk() + val getCommentQueryService = mockk() + val fileReader = mockk() + val fileMapper = mockk() + val postMapper = mockk() + + val queryService = + GetPostQueryService( + postRepository, + boardRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + + beforeTest { + clearMocks( + postRepository, + boardRepository, + commentReader, + getCommentQueryService, + fileReader, + fileMapper, + postMapper, + ) + } + + describe("findPost") { + it("존재하지 않는 게시글이면 예외를 던진다") { + every { postRepository.findByIdAndIsDeletedFalse(1L) } returns null + + shouldThrow { + queryService.findPost(1L, Role.USER) + } + } + + 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.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, 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) + } + } + + 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") { + 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, 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) + } + } + } + + describe("validatePage") { + it("음수 페이지면 예외를 던진다") { + shouldThrow { + 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") { + 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 { boardRepository.findByIdAndIsDeletedFalse(1L) } returns board + every { postRepository.findAllActiveByBoardId(1L, any()) } returns postSlice + every { fileReader.findAll(FileOwnerType.POST, any>(), any()) } returns emptyList() + every { postMapper.toListResponse(any(), any(), any()) } returns response + + val result = queryService.findPosts(1L, 0, 10, Role.USER) + + result.content.size shouldBe 1 + result.content.first().id shouldBe 10L + verify(exactly = 1) { fileReader.findAll(FileOwnerType.POST, listOf(10L), any()) } + } + } + }) 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..d5fc1c17 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/converter/BoardConfigConverterTest.kt @@ -0,0 +1,30 @@ +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 + +class BoardConfigConverterTest : + StringSpec({ + val converter = BoardConfigConverter() + + "BoardConfig를 JSON 문자열로 변환하고 역직렬화한다" { + val config = + BoardConfig( + commentEnabled = false, + writePermission = Role.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..4d93eb43 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/BoardEntityTest.kt @@ -0,0 +1,121 @@ +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 + +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 = Role.ADMIN), + ) + + 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( + id = 20L, + name = "운영", + type = BoardType.NOTICE, + config = BoardConfig(isPrivate = true), + ) + + privateBoard.isAccessibleBy(Role.ADMIN) shouldBe true + 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) + + board.updateConfig(newConfig) + + 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 new file mode 100644 index 00000000..944623b2 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/domain/entity/PostEntityTest.kt @@ -0,0 +1,77 @@ +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.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() + + post.increaseLikeCount() + + post.likeCount shouldBe 1 + } + + "decreaseLikeCount는 0이면 예외를 던진다" { + val post = PostTestFixture.create() + + shouldThrow { + post.decreaseLikeCount() + } + } + + "markDeleted와 restore는 삭제 상태를 토글한다" { + val post = PostTestFixture.create() + + post.markDeleted() + post.isDeleted shouldBe true + + post.restore() + post.isDeleted shouldBe false + } + }) 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/BoardTestFixture.kt b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt new file mode 100644 index 00000000..dae5c900 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/board/fixture/BoardTestFixture.kt @@ -0,0 +1,32 @@ +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( + 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 = Role.ADMIN), + ) +} 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() -} 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(), + ) } 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..7c718db8 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/comment/application/usecase/command/CommentConcurrencyTest.kt @@ -0,0 +1,327 @@ +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.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 +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.junit.jupiter.api.Tag +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.ThreadLocalRandom +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.roundToLong + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class, CommentConcurrencyBenchmarkConfig::class) +@Tag("performance") +class CommentConcurrencyTest( + private val postCommentUsecase: PostCommentUsecase, + private val boardRepository: BoardRepository, + private val postRepository: PostRepository, + private val userRepository: UserRepository, + private val commentRepository: CommentRepository, + private val entityManager: EntityManager, + private val atomicCommentCountCommand: AtomicCommentCountCommand, +) : DescribeSpec({ + + data class ConcurrencyResult( + val successCount: Int, + val failCount: Int, + val postCommentCount: Int, + val actualCommentCount: Int, + val queryCount: Long, + val elapsedTimeMs: Double, + 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( + User + .builder() + .name("user$i") + .email("user$i@test.com") + .status(Status.ACTIVE) + .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("동시성 테스트 게시글", users.first()) + 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(), + ) + } + + 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() + boardRepository.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("동시성 해소 방식별 성능 비교") { + it("PESSIMISTIC_WRITE와 Atomic Increment를 측정하고 Atomic 우위를 검증한다") { + val threadCount = 30 + val rounds = 5 + + val pessimisticSummary = + benchmark("pessimistic", rounds, threadCount) { postId, userId, index -> + postCommentUsecase.savePostComment( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "pessimistic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + val atomicSummary = + benchmark("atomic", rounds, threadCount) { postId, userId, index -> + atomicCommentCountCommand.savePostCommentWithAtomicIncrement( + dto = + CommentSaveRequest( + parentCommentId = null, + content = "atomic-$index", + files = null, + ), + postId = postId, + userId = userId, + ) + } + + 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 entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate, +) { + fun savePostCommentWithAtomicIncrement( + dto: CommentSaveRequest, + postId: Long, + userId: Long, + ) { + val maxRetries = 20 + var lastError: Exception? = null + + repeat(maxRetries) { attempt -> + try { + transactionTemplate.executeWithoutResult { + 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") + } + + 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 + val lockWaitTimeout = e.message?.contains("Lock wait timeout exceeded", ignoreCase = true) == true + if ((!deadlock && !lockWaitTimeout) || attempt == maxRetries - 1) { + throw e + } + val backoffMs = ThreadLocalRandom.current().nextLong(10, 40) + Thread.sleep(backoffMs) + } + } + + throw IllegalStateException("Atomic increment retries exhausted", lastError) + } +} + +@TestConfiguration +class CommentConcurrencyBenchmarkConfig { + @Bean + fun atomicCommentCountCommand( + commentRepository: CommentRepository, + entityManager: EntityManager, + transactionManager: PlatformTransactionManager, + ): AtomicCommentCountCommand = + AtomicCommentCountCommand( + commentRepository = commentRepository, + entityManager = entityManager, + transactionTemplate = TransactionTemplate(transactionManager), + ) +} 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 } } }) 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, - ) } 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/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) 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 = 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