From 03425731a05a254c05d4b06c3bfcdd818c9f40a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B4=80=ED=9D=AC?= <85067003+limehee@users.noreply.github.com> Date: Thu, 25 Jul 2024 00:55:07 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../application/handler/FileHandler.java | 20 +++++ .../application/service/BoardService.java | 19 +++++ .../application/service/BoardServiceImpl.java | 64 ++++++++++++++++ .../application/service/FileService.java | 27 ++++++- .../service/RecordServiceImpl.java | 22 +++++- .../service/RhythmServiceImpl.java | 9 +++ .../application/service/UserService.java | 2 + .../application/service/UserServiceImpl.java | 14 +++- .../api/domain/domain/model/Article.java | 2 +- .../stempo/api/domain/domain/model/Board.java | 61 +++++++++++++++ .../api/domain/domain/model/UploadedFile.java | 2 +- .../stempo/api/domain/domain/model/User.java | 4 + .../domain/repository/BoardRepository.java | 15 ++++ .../domain/repository/RecordRepository.java | 2 + .../domain/repository/UserRepository.java | 2 + .../persistence/entity/BoardEntity.java | 10 ++- .../persistence/mappper/BoardMapper.java | 34 +++++++++ .../repository/BoardJpaRepository.java | 24 ++++++ .../repository/BoardRepositoryImpl.java | 43 +++++++++++ .../repository/RecordJpaRepository.java | 9 +++ .../repository/RecordRepositoryImpl.java | 6 ++ .../repository/UserRepositoryImpl.java | 8 ++ .../domain/presentation/BoardController.java | 76 +++++++++++++++++++ .../domain/presentation/FileController.java | 32 ++++++++ .../domain/presentation/RecordController.java | 5 +- .../domain/presentation/RhythmController.java | 3 +- .../dto/request/BoardRequestDto.java | 45 +++++++++++ .../dto/request/BoardUpdateRequestDto.java | 20 +++++ .../dto/response/BoardResponseDto.java | 33 ++++++++ .../dto/response/RecordResponseDto.java | 9 +++ .../exception/PermissionDeniedException.java | 14 ++++ .../handler/GlobalExceptionHandler.java | 13 +++- .../api/global/util/StringJsonConverter.java | 35 +++++++++ 34 files changed, 670 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/stempo/api/domain/application/service/BoardService.java create mode 100644 src/main/java/com/stempo/api/domain/application/service/BoardServiceImpl.java create mode 100644 src/main/java/com/stempo/api/domain/domain/model/Board.java create mode 100644 src/main/java/com/stempo/api/domain/domain/repository/BoardRepository.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/mappper/BoardMapper.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/BoardJpaRepository.java create mode 100644 src/main/java/com/stempo/api/domain/persistence/repository/BoardRepositoryImpl.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/BoardController.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/FileController.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/dto/request/BoardRequestDto.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/dto/request/BoardUpdateRequestDto.java create mode 100644 src/main/java/com/stempo/api/domain/presentation/dto/response/BoardResponseDto.java create mode 100644 src/main/java/com/stempo/api/global/exception/PermissionDeniedException.java create mode 100644 src/main/java/com/stempo/api/global/util/StringJsonConverter.java diff --git a/build.gradle b/build.gradle index d13e6e2..1cbaccf 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' // 롬복 annotationProcessor 'org.projectlombok:lombok' // 롬복 implementation 'com.google.code.gson:gson:2.10.1' // JSON 라이브러리 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' // Swagger implementation 'commons-io:commons-io:2.16.1' // Apache Commons IO implementation 'com.google.guava:guava:33.2.1-jre' // Google Core Libraries For Java diff --git a/src/main/java/com/stempo/api/domain/application/handler/FileHandler.java b/src/main/java/com/stempo/api/domain/application/handler/FileHandler.java index 9e5e383..2d6aa54 100644 --- a/src/main/java/com/stempo/api/domain/application/handler/FileHandler.java +++ b/src/main/java/com/stempo/api/domain/application/handler/FileHandler.java @@ -6,6 +6,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; @@ -21,6 +22,25 @@ public class FileHandler { @Value("${resource.file.path}") private String filePath; + public void init() { + filePath = filePath.replace("/", File.separator).replace("\\", File.separator); + } + + public String saveFile(MultipartFile multipartFile, String category) throws IOException { + init(); + String originalFilename = multipartFile.getOriginalFilename(); + String extension = FilenameUtils.getExtension(originalFilename); + + String saveFilename = makeFileName(extension); + String savePath = filePath + File.separator + category + File.separator + saveFilename; + + File file = new File(savePath); + ensureParentDirectoryExists(file); + multipartFile.transferTo(file); + setFilePermissions(file, savePath, extension); + return savePath; + } + public String saveFile(File file) throws IOException { String originalFilename = file.getName(); String extension = FilenameUtils.getExtension(originalFilename); diff --git a/src/main/java/com/stempo/api/domain/application/service/BoardService.java b/src/main/java/com/stempo/api/domain/application/service/BoardService.java new file mode 100644 index 0000000..679f794 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/BoardService.java @@ -0,0 +1,19 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.domain.presentation.dto.request.BoardRequestDto; +import com.stempo.api.domain.presentation.dto.request.BoardUpdateRequestDto; +import com.stempo.api.domain.presentation.dto.response.BoardResponseDto; + +import java.util.List; + +public interface BoardService { + + Long registerBoard(BoardRequestDto requestDto); + + List getBoardsByCategory(BoardCategory category); + + Long updateBoard(Long boardId, BoardUpdateRequestDto requestDto); + + Long deleteBoard(Long boardId); +} diff --git a/src/main/java/com/stempo/api/domain/application/service/BoardServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/BoardServiceImpl.java new file mode 100644 index 0000000..b4703b6 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/application/service/BoardServiceImpl.java @@ -0,0 +1,64 @@ +package com.stempo.api.domain.application.service; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.domain.domain.model.User; +import com.stempo.api.domain.domain.repository.BoardRepository; +import com.stempo.api.domain.presentation.dto.request.BoardRequestDto; +import com.stempo.api.domain.presentation.dto.request.BoardUpdateRequestDto; +import com.stempo.api.domain.presentation.dto.response.BoardResponseDto; +import com.stempo.api.global.exception.PermissionDeniedException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BoardServiceImpl implements BoardService { + + private final UserService userService; + private final BoardRepository repository; + + @Override + public Long registerBoard(BoardRequestDto requestDto) { + User user = userService.getCurrentUser(); + Board board = BoardRequestDto.toDomain(requestDto, user.getDeviceTag()); + board.validateAccessPermissionForNotice(user); + return repository.save(board).getId(); + } + + @Override + public List getBoardsByCategory(BoardCategory category) { + validateAccessPermissionForSuggestion(category); + List boards = repository.findByCategory(category); + return boards.stream() + .map(BoardResponseDto::toDto) + .toList(); + } + + @Override + public Long updateBoard(Long boardId, BoardUpdateRequestDto requestDto) { + User user = userService.getCurrentUser(); + Board board = repository.findByIdOrThrow(boardId); + board.validateAccessPermission(user); + board.update(requestDto); + return repository.save(board).getId(); + } + + @Override + public Long deleteBoard(Long boardId) { + User user = userService.getCurrentUser(); + Board board = repository.findByIdOrThrow(boardId); + board.validateAccessPermission(user); + board.delete(); + return repository.save(board).getId(); + } + + private void validateAccessPermissionForSuggestion(BoardCategory category) { + User user = userService.getCurrentUser(); + if (category.equals(BoardCategory.SUGGESTION) && !user.isAdmin()) { + throw new PermissionDeniedException("건의하기는 관리자만 조회할 수 있습니다."); + } + } +} diff --git a/src/main/java/com/stempo/api/domain/application/service/FileService.java b/src/main/java/com/stempo/api/domain/application/service/FileService.java index f5856f4..6514758 100644 --- a/src/main/java/com/stempo/api/domain/application/service/FileService.java +++ b/src/main/java/com/stempo/api/domain/application/service/FileService.java @@ -6,26 +6,49 @@ import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; @Service @RequiredArgsConstructor public class FileService { - private final FileHandler fileHandler; + private final BoardService boardService; private final UploadedFileService uploadedFileService; + private final FileHandler fileHandler; @Value("${resource.file.url}") private String fileURL; + public List saveFiles(List multipartFiles, String path) throws IOException { + List filePaths = new ArrayList<>(); + for (MultipartFile multipartFile : multipartFiles) { + String filePath = saveFile(multipartFile, path); + filePaths.add(filePath); + } + return filePaths; + } + + public String saveFile(MultipartFile multipartFile, String path) throws IOException { + String savedFilePath = fileHandler.saveFile(multipartFile, path); + String fileName = new File(savedFilePath).getName(); + String url = fileURL + "/" + path.replace(File.separator, "/") + "/" + fileName; + + UploadedFile uploadedFile = UploadedFile.create(multipartFile.getOriginalFilename(), fileName, savedFilePath, url, multipartFile.getSize()); + uploadedFileService.saveUploadedFile(uploadedFile); + return uploadedFile.getUrl(); + } + public String saveFile(File file) throws IOException { String savedFilePath = fileHandler.saveFile(file); String fileName = new File(savedFilePath).getName(); String url = fileURL + "/" + fileName; - UploadedFile uploadedFile = UploadedFile.create(file.getName(), fileName, savedFilePath, url, file.length(), null); + UploadedFile uploadedFile = UploadedFile.create(file.getName(), fileName, savedFilePath, url, file.length()); uploadedFileService.saveUploadedFile(uploadedFile); return url; } diff --git a/src/main/java/com/stempo/api/domain/application/service/RecordServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/RecordServiceImpl.java index 130d8df..b4d5162 100644 --- a/src/main/java/com/stempo/api/domain/application/service/RecordServiceImpl.java +++ b/src/main/java/com/stempo/api/domain/application/service/RecordServiceImpl.java @@ -9,6 +9,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Service @@ -29,9 +30,26 @@ public String record(RecordRequestDto requestDto) { public List getRecordsByDateRange(LocalDate startDate, LocalDate endDate) { LocalDateTime startDateTime = startDate.atStartOfDay(); LocalDateTime endDateTime = endDate.atStartOfDay().plusDays(1); + + // startDateTime 이전의 가장 최신 데이터 가져오기 + Record latestBeforeStartDate = recordRepository.findLatestBeforeStartDate(startDateTime); + + // startDateTime과 endDateTime 사이의 데이터 가져오기 List records = recordRepository.findByDateBetween(startDateTime, endDateTime); - return records.stream() + + // 결과 합치기 + List combinedRecords = new ArrayList<>(); + if (latestBeforeStartDate != null) { + combinedRecords.add(RecordResponseDto.toDto(latestBeforeStartDate)); + } + combinedRecords.addAll(records.stream() .map(RecordResponseDto::toDto) - .toList(); + .toList()); + + // 데이터가 없을 경우 오늘 날짜로 값을 0으로 설정하여 반환 + if (combinedRecords.isEmpty()) { + combinedRecords.add(RecordResponseDto.createDefault()); + } + return combinedRecords; } } diff --git a/src/main/java/com/stempo/api/domain/application/service/RhythmServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/RhythmServiceImpl.java index 4fa62dd..06834fc 100644 --- a/src/main/java/com/stempo/api/domain/application/service/RhythmServiceImpl.java +++ b/src/main/java/com/stempo/api/domain/application/service/RhythmServiceImpl.java @@ -29,6 +29,7 @@ public class RhythmServiceImpl implements RhythmService { @Override public String createRhythm(int bpm) { try { + validateBpmRange(bpm); String outputFilename = "rhythm_" + bpm + "_bpm.wav"; Optional uploadedFile = uploadedFileService.getUploadedFileByOriginalFileName(outputFilename); @@ -43,6 +44,14 @@ public String createRhythm(int bpm) { } } + private void validateBpmRange(int bpm) { + int minBpm = 10; + int maxBpm = 200; + if (bpm < minBpm || bpm > maxBpm) { + throw new RhythmGenerationException("BPM must be between " + minBpm + " and " + maxBpm); + } + } + private Path generateRhythmFile(int bpm, String outputFilename) throws Exception { Path projectRoot = Paths.get("").toAbsolutePath(); Path venvPythonPath = projectRoot.resolve(venvPath); diff --git a/src/main/java/com/stempo/api/domain/application/service/UserService.java b/src/main/java/com/stempo/api/domain/application/service/UserService.java index d54290e..891f312 100644 --- a/src/main/java/com/stempo/api/domain/application/service/UserService.java +++ b/src/main/java/com/stempo/api/domain/application/service/UserService.java @@ -13,4 +13,6 @@ public interface UserService { boolean existsById(String id); String getCurrentDeviceTag(); + + User getCurrentUser(); } diff --git a/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java b/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java index 6e2e46d..5ed66f7 100644 --- a/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java +++ b/src/main/java/com/stempo/api/domain/application/service/UserServiceImpl.java @@ -13,7 +13,7 @@ @RequiredArgsConstructor public class UserServiceImpl implements UserService { - private final UserRepository userRepository; + private final UserRepository repository; private final PasswordService passwordService; private final PasswordUtil passwordUtil; @@ -23,21 +23,27 @@ public User registerUser(String deviceTag, String password) { User user = User.create(deviceTag, rawPassword); String encodedPassword = passwordService.encodePassword(user.getPassword()); user.updatePassword(encodedPassword); - return userRepository.save(user); + return repository.save(user); } @Override public Optional findById(String id) { - return userRepository.findById(id); + return repository.findById(id); } @Override public boolean existsById(String id) { - return userRepository.existsById(id); + return repository.existsById(id); } @Override public String getCurrentDeviceTag() { return AuthUtil.getAuthenticationInfoDeviceTag(); } + + @Override + public User getCurrentUser() { + String deviceTag = getCurrentDeviceTag(); + return repository.findByIdOrThrow(deviceTag); + } } diff --git a/src/main/java/com/stempo/api/domain/domain/model/Article.java b/src/main/java/com/stempo/api/domain/domain/model/Article.java index 20f39bd..744227e 100644 --- a/src/main/java/com/stempo/api/domain/domain/model/Article.java +++ b/src/main/java/com/stempo/api/domain/domain/model/Article.java @@ -24,7 +24,7 @@ public class Article { private String thumbnailUrl; private String articleUrl; private LocalDateTime createdAt; - private boolean deleted = false; + private boolean deleted; public void update(ArticleUpdateRequestDto requestDto) { Optional.ofNullable(requestDto.getTitle()).ifPresent(this::setTitle); diff --git a/src/main/java/com/stempo/api/domain/domain/model/Board.java b/src/main/java/com/stempo/api/domain/domain/model/Board.java new file mode 100644 index 0000000..aea7977 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/model/Board.java @@ -0,0 +1,61 @@ +package com.stempo.api.domain.domain.model; + +import com.stempo.api.domain.presentation.dto.request.BoardUpdateRequestDto; +import com.stempo.api.global.exception.PermissionDeniedException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Getter +@Setter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Board { + + private Long id; + private String deviceTag; + private BoardCategory category; + private String title; + private String content; + private List fileUrls; + private LocalDateTime createdAt; + private boolean deleted; + + public void update(BoardUpdateRequestDto requestDto) { + Optional.ofNullable(requestDto.getCategory()).ifPresent(category -> this.category = category); + Optional.ofNullable(requestDto.getTitle()).ifPresent(title -> this.title = title); + Optional.ofNullable(requestDto.getContent()).ifPresent(content -> this.content = content); + } + + public void delete() { + setDeleted(true); + } + + public boolean isOwner(User user) { + return this.deviceTag.equals(user.getDeviceTag()); + } + + public void validateAccessPermission(User user) { + if (!(isOwner(user) || user.isAdmin())) { + throw new PermissionDeniedException("게시글을 수정/삭제할 권한이 없습니다."); + } + } + + public void validateAccessPermissionForNotice(User user) { + if (isNotice() && !user.isAdmin()) { + throw new PermissionDeniedException("공지사항 관리 권한이 없습니다."); + } + } + + public boolean isNotice() { + return category.equals(BoardCategory.NOTICE); + } +} diff --git a/src/main/java/com/stempo/api/domain/domain/model/UploadedFile.java b/src/main/java/com/stempo/api/domain/domain/model/UploadedFile.java index b357ebf..f17c1d8 100644 --- a/src/main/java/com/stempo/api/domain/domain/model/UploadedFile.java +++ b/src/main/java/com/stempo/api/domain/domain/model/UploadedFile.java @@ -24,7 +24,7 @@ public class UploadedFile { private Long fileSize; private LocalDateTime createdAt; - public static UploadedFile create(String originalFileName, String saveFileName, String savedPath, String url, Long fileSize, String contentType) { + public static UploadedFile create(String originalFileName, String saveFileName, String savedPath, String url, Long fileSize) { return UploadedFile.builder() .originalFileName(originalFileName) .saveFileName(saveFileName) diff --git a/src/main/java/com/stempo/api/domain/domain/model/User.java b/src/main/java/com/stempo/api/domain/domain/model/User.java index b728d04..c31d1f3 100644 --- a/src/main/java/com/stempo/api/domain/domain/model/User.java +++ b/src/main/java/com/stempo/api/domain/domain/model/User.java @@ -25,4 +25,8 @@ public static User create(String deviceTag, String password) { public void updatePassword(String encodedPassword) { setPassword(encodedPassword); } + + public boolean isAdmin() { + return role.equals(Role.ADMIN); + } } diff --git a/src/main/java/com/stempo/api/domain/domain/repository/BoardRepository.java b/src/main/java/com/stempo/api/domain/domain/repository/BoardRepository.java new file mode 100644 index 0000000..546e464 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/domain/repository/BoardRepository.java @@ -0,0 +1,15 @@ +package com.stempo.api.domain.domain.repository; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.domain.model.BoardCategory; + +import java.util.List; + +public interface BoardRepository { + + Board save(Board board); + + List findByCategory(BoardCategory category); + + Board findByIdOrThrow(Long boardId); +} diff --git a/src/main/java/com/stempo/api/domain/domain/repository/RecordRepository.java b/src/main/java/com/stempo/api/domain/domain/repository/RecordRepository.java index 3483b26..626a533 100644 --- a/src/main/java/com/stempo/api/domain/domain/repository/RecordRepository.java +++ b/src/main/java/com/stempo/api/domain/domain/repository/RecordRepository.java @@ -10,4 +10,6 @@ public interface RecordRepository { Record save(Record record); List findByDateBetween(LocalDateTime startDateTime, LocalDateTime endDateTime); + + Record findLatestBeforeStartDate(LocalDateTime startDateTime); } diff --git a/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java b/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java index e43821a..f809df7 100644 --- a/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java +++ b/src/main/java/com/stempo/api/domain/domain/repository/UserRepository.java @@ -10,5 +10,7 @@ public interface UserRepository { Optional findById(String id); + User findByIdOrThrow(String deviceTag); + boolean existsById(String id); } diff --git a/src/main/java/com/stempo/api/domain/persistence/entity/BoardEntity.java b/src/main/java/com/stempo/api/domain/persistence/entity/BoardEntity.java index 04a623f..f805463 100644 --- a/src/main/java/com/stempo/api/domain/persistence/entity/BoardEntity.java +++ b/src/main/java/com/stempo/api/domain/persistence/entity/BoardEntity.java @@ -1,7 +1,9 @@ package com.stempo.api.domain.persistence.entity; import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.global.util.StringJsonConverter; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -17,6 +19,8 @@ import lombok.NoArgsConstructor; import lombok.Setter; +import java.util.List; + @Entity @Getter @Setter @@ -31,7 +35,7 @@ public class BoardEntity extends BaseEntity { private Long id; @Column(nullable = false) - private String userId; + private String deviceTag; @Column(nullable = false) @Enumerated(EnumType.STRING) @@ -45,6 +49,10 @@ public class BoardEntity extends BaseEntity { @Size(min = 1, max = 10000, message = "Content must be between 1 and 10000 characters") private String content; + @Column(columnDefinition = "TEXT") + @Convert(converter = StringJsonConverter.class) + private List fileUrls; + @Column(nullable = false) private boolean deleted = false; } diff --git a/src/main/java/com/stempo/api/domain/persistence/mappper/BoardMapper.java b/src/main/java/com/stempo/api/domain/persistence/mappper/BoardMapper.java new file mode 100644 index 0000000..1c9b366 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/mappper/BoardMapper.java @@ -0,0 +1,34 @@ +package com.stempo.api.domain.persistence.mappper; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.persistence.entity.BoardEntity; +import org.springframework.stereotype.Component; + +@Component +public class BoardMapper { + + public BoardEntity toEntity(Board board) { + return BoardEntity.builder() + .id(board.getId()) + .deviceTag(board.getDeviceTag()) + .category(board.getCategory()) + .title(board.getTitle()) + .content(board.getContent()) + .fileUrls(board.getFileUrls()) + .deleted(board.isDeleted()) + .build(); + } + + public Board toDomain(BoardEntity entity) { + return Board.builder() + .id(entity.getId()) + .deviceTag(entity.getDeviceTag()) + .category(entity.getCategory()) + .title(entity.getTitle()) + .content(entity.getContent()) + .fileUrls(entity.getFileUrls()) + .createdAt(entity.getCreatedAt()) + .deleted(entity.isDeleted()) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/BoardJpaRepository.java b/src/main/java/com/stempo/api/domain/persistence/repository/BoardJpaRepository.java new file mode 100644 index 0000000..20179c8 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/BoardJpaRepository.java @@ -0,0 +1,24 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.domain.persistence.entity.BoardEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface BoardJpaRepository extends JpaRepository { + + @Query("SELECT b " + + "FROM BoardEntity b " + + "WHERE b.category = :category " + + "AND b.deleted = false") + List findByCategory(BoardCategory category); + + @Query("SELECT b " + + "FROM BoardEntity b " + + "WHERE b.id = :boardId " + + "AND b.deleted = false") + Optional findByIdAndNotDeleted(Long boardId); +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/BoardRepositoryImpl.java b/src/main/java/com/stempo/api/domain/persistence/repository/BoardRepositoryImpl.java new file mode 100644 index 0000000..e235fe7 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/persistence/repository/BoardRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.stempo.api.domain.persistence.repository; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.domain.domain.repository.BoardRepository; +import com.stempo.api.domain.persistence.entity.BoardEntity; +import com.stempo.api.domain.persistence.mappper.BoardMapper; +import com.stempo.api.global.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class BoardRepositoryImpl implements BoardRepository { + + private final BoardJpaRepository repository; + private final BoardMapper mapper; + + + @Override + public Board save(Board board) { + BoardEntity jpaEntity = mapper.toEntity(board); + BoardEntity savedEntity = repository.save(jpaEntity); + return mapper.toDomain(savedEntity); + } + + @Override + public List findByCategory(BoardCategory category) { + List boards = repository.findByCategory(category); + return boards.stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public Board findByIdOrThrow(Long boardId) { + return repository.findByIdAndNotDeleted(boardId) + .map(mapper::toDomain) + .orElseThrow(() -> new NotFoundException("[Board] id: " + boardId + " not found")); + } +} diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/RecordJpaRepository.java b/src/main/java/com/stempo/api/domain/persistence/repository/RecordJpaRepository.java index 187f4bc..f2ea028 100644 --- a/src/main/java/com/stempo/api/domain/persistence/repository/RecordJpaRepository.java +++ b/src/main/java/com/stempo/api/domain/persistence/repository/RecordJpaRepository.java @@ -18,4 +18,13 @@ List findByDateBetween( @Param("startDateTime") LocalDateTime startDateTime, @Param("endDateTime") LocalDateTime endDateTime ); + + @Query("SELECT r " + + "FROM RecordEntity r " + + "WHERE r.createdAt < :startDateTime " + + "ORDER BY r.createdAt DESC " + + "LIMIT 1") + RecordEntity findLatestBeforeStartDate( + @Param("startDateTime") LocalDateTime startDateTime + ); } diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/RecordRepositoryImpl.java b/src/main/java/com/stempo/api/domain/persistence/repository/RecordRepositoryImpl.java index 32d51ab..badde76 100644 --- a/src/main/java/com/stempo/api/domain/persistence/repository/RecordRepositoryImpl.java +++ b/src/main/java/com/stempo/api/domain/persistence/repository/RecordRepositoryImpl.java @@ -31,4 +31,10 @@ public List findByDateBetween(LocalDateTime startDateTime, LocalDateTime .map(mapper::toDomain) .toList(); } + + @Override + public Record findLatestBeforeStartDate(LocalDateTime startDateTime) { + RecordEntity jpaEntity = repository.findLatestBeforeStartDate(startDateTime); + return mapper.toDomain(jpaEntity); + } } diff --git a/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java b/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java index 5476331..f30e03e 100644 --- a/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java +++ b/src/main/java/com/stempo/api/domain/persistence/repository/UserRepositoryImpl.java @@ -4,6 +4,7 @@ import com.stempo.api.domain.domain.repository.UserRepository; import com.stempo.api.domain.persistence.entity.UserEntity; import com.stempo.api.domain.persistence.mappper.UserMapper; +import com.stempo.api.global.exception.NotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -29,6 +30,13 @@ public Optional findById(String id) { .map(mapper::toDomain); } + @Override + public User findByIdOrThrow(String deviceTag) { + return repository.findById(deviceTag) + .map(mapper::toDomain) + .orElseThrow(() -> new NotFoundException("[User] id: " + deviceTag + " not found")); + } + @Override public boolean existsById(String id) { return repository.existsById(id); diff --git a/src/main/java/com/stempo/api/domain/presentation/BoardController.java b/src/main/java/com/stempo/api/domain/presentation/BoardController.java new file mode 100644 index 0000000..265ffa0 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/BoardController.java @@ -0,0 +1,76 @@ +package com.stempo.api.domain.presentation; + +import com.stempo.api.domain.application.service.BoardService; +import com.stempo.api.domain.domain.model.BoardCategory; +import com.stempo.api.domain.presentation.dto.request.BoardRequestDto; +import com.stempo.api.domain.presentation.dto.request.BoardUpdateRequestDto; +import com.stempo.api.domain.presentation.dto.response.BoardResponseDto; +import com.stempo.api.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.annotation.Secured; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Tag(name = "Board", description = "게시판") +public class BoardController { + + private final BoardService boardService; + + @Operation(summary = "[U] 게시글 작성", description = "ROLE_USER 이상의 권한이 필요함
" + + "공지사항은 ROLE_ADMIN 이상의 권한이 필요함") + @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @PostMapping("/api/v1/boards") + public ApiResponse registerBoard( + @Valid @RequestBody BoardRequestDto requestDto + ) { + Long id = boardService.registerBoard(requestDto); + return ApiResponse.success(id); + } + + @Operation(summary = "[U] 카테고리별 게시글 조회", description = "ROLE_USER 이상의 권한이 필요함
" + + "건의하기는 ROLE_ADMIN 이상의 권한이 필요함") + @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @GetMapping("/api/v1/boards") + public ApiResponse> getBoardsByCategory( + @RequestParam(name = "category") BoardCategory category + ) { + List boards = boardService.getBoardsByCategory(category); + return ApiResponse.success(boards); + } + + @Operation(summary = "[U] 게시글 수정", description = "ROLE_USER 이상의 권한이 필요함
" + + "ADMIN은 모든 게시글 수정 가능") + @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @PatchMapping("/api/v1/boards/{boardId}") + public ApiResponse updateBoard( + @PathVariable(name = "boardId") Long boardId, + @Valid @RequestBody BoardUpdateRequestDto requestDto + ) { + Long id = boardService.updateBoard(boardId, requestDto); + return ApiResponse.success(id); + } + + @Operation(summary = "[U] 게시글 삭제", description = "ROLE_USER 이상의 권한이 필요함
" + + "ADMIN은 모든 게시글 삭제 가능") + @Secured({ "ROLE_USER", "ROLE_ADMIN" }) + @DeleteMapping("/api/v1/boards/{boardId}") + public ApiResponse deleteBoard( + @PathVariable(name = "boardId") Long boardId + ) { + Long id = boardService.deleteBoard(boardId); + return ApiResponse.success(id); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/FileController.java b/src/main/java/com/stempo/api/domain/presentation/FileController.java new file mode 100644 index 0000000..fa3ad39 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/FileController.java @@ -0,0 +1,32 @@ +package com.stempo.api.domain.presentation; + +import com.stempo.api.domain.application.service.FileService; +import com.stempo.api.global.common.dto.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@Tag(name = "File Upload", description = "파일 업로드") +public class FileController { + + private final FileService fileService; + + @Operation(summary = "[U] 게시판 파일 업로드", description = "ROLE_USER 이상의 권한이 필요함") + @PostMapping(value = "/api/v1/boards/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse> boardFileUpload( + @RequestParam(name = "multipartFile", required = false) List multipartFiles + ) throws IOException { + List filePaths = fileService.saveFiles(multipartFiles, "boards"); + return ApiResponse.success(filePaths); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/RecordController.java b/src/main/java/com/stempo/api/domain/presentation/RecordController.java index 18418d8..3a2e5c0 100644 --- a/src/main/java/com/stempo/api/domain/presentation/RecordController.java +++ b/src/main/java/com/stempo/api/domain/presentation/RecordController.java @@ -35,7 +35,10 @@ public ApiResponse record( return ApiResponse.success(deviceTag); } - @Operation(summary = "[U] 내 재활 운동 기록 조회", description = "ROLE_USER 이상의 권한이 필요함") + @Operation(summary = "[U] 내 재활 운동 기록 조회", description = "ROLE_USER 이상의 권한이 필요함
" + + "startDate와 endDate는 yyyy-MM-dd 형식으로 입력해야 함
" + + "startDate 이전의 가장 최신 데이터와 startDate부터 endDate까지의 데이터를 가져옴
" + + "데이터가 없을 경우 오늘 날짜로 값을 0으로 설정하여 반환") @Secured({ "ROLE_USER", "ROLE_ADMIN" }) @GetMapping("/api/v1/records") public ApiResponse> getRecords( diff --git a/src/main/java/com/stempo/api/domain/presentation/RhythmController.java b/src/main/java/com/stempo/api/domain/presentation/RhythmController.java index 6fcce46..37a05d4 100644 --- a/src/main/java/com/stempo/api/domain/presentation/RhythmController.java +++ b/src/main/java/com/stempo/api/domain/presentation/RhythmController.java @@ -17,7 +17,8 @@ public class RhythmController { private final RhythmService rhythmService; - @Operation(summary = "[U] 리듬 생성", description = "ROLE_USER 이상의 권한이 필요함") + @Operation(summary = "[U] 리듬 생성", description = "ROLE_USER 이상의 권한이 필요함
" + + "BPM은 10 이상 200 이하의 값이어야 함") @Secured({ "ROLE_USER", "ROLE_ADMIN" }) @PostMapping("/api/v1/rhythm/{bpm}") public ApiResponse generateRhythm( diff --git a/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardRequestDto.java b/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardRequestDto.java new file mode 100644 index 0000000..86d9b52 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardRequestDto.java @@ -0,0 +1,45 @@ +package com.stempo.api.domain.presentation.dto.request; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.domain.model.BoardCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class BoardRequestDto { + + @NotNull + @Schema(description = "카테고리", example = "NOTICE", requiredMode = Schema.RequiredMode.REQUIRED) + private BoardCategory category; + + @NotNull + @Schema(description = "제목", example = "청각 자극을 통한 뇌성마비 환자 보행 패턴 개선 서비스, Stempo.", minLength = 1, maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @NotNull + @Schema(description = "내용", example = "Stempo는 청각 자극을 통한 뇌성마비 환자 보행 패턴 개선 서비스입니다.", minLength = 1, maxLength = 10000, requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "파일 링크(JSON Array)", example = """ + [ + "/resources/files/947051880039041_19dea234-b6ec-4c4b-bc92-c53c0d921943.wav", + "/resources/files/boards/1/1030487120626166_1dec3611-c148-4139-bb16-3d2a89ac1dd7.pdf" + ] + """) + private List fileUrls; + + public static Board toDomain(BoardRequestDto requestDto, String deviceTag) { + return Board.builder() + .deviceTag(deviceTag) + .category(requestDto.getCategory()) + .title(requestDto.getTitle()) + .content(requestDto.getContent()) + .fileUrls(requestDto.getFileUrls()) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardUpdateRequestDto.java b/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardUpdateRequestDto.java new file mode 100644 index 0000000..028da14 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/dto/request/BoardUpdateRequestDto.java @@ -0,0 +1,20 @@ +package com.stempo.api.domain.presentation.dto.request; + +import com.stempo.api.domain.domain.model.BoardCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BoardUpdateRequestDto { + + @Schema(description = "카테고리", example = "NOTICE", requiredMode = Schema.RequiredMode.REQUIRED) + private BoardCategory category; + + @Schema(description = "제목", example = "청각 자극을 통한 뇌성마비 환자 보행 패턴 개선 서비스, Stempo.", minLength = 1, maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Schema(description = "내용", example = "Stempo는 청각 자극을 통한 뇌성마비 환자 보행 패턴 개선 서비스입니다.", minLength = 1, maxLength = 10000, requiredMode = Schema.RequiredMode.REQUIRED) + private String content; +} diff --git a/src/main/java/com/stempo/api/domain/presentation/dto/response/BoardResponseDto.java b/src/main/java/com/stempo/api/domain/presentation/dto/response/BoardResponseDto.java new file mode 100644 index 0000000..db3b447 --- /dev/null +++ b/src/main/java/com/stempo/api/domain/presentation/dto/response/BoardResponseDto.java @@ -0,0 +1,33 @@ +package com.stempo.api.domain.presentation.dto.response; + +import com.stempo.api.domain.domain.model.Board; +import com.stempo.api.domain.domain.model.BoardCategory; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class BoardResponseDto { + + private Long id; + private String deviceTag; + private BoardCategory category; + private String title; + private String content; + private List fileUrls; + private String createdAt; + + public static BoardResponseDto toDto(Board board) { + return BoardResponseDto.builder() + .id(board.getId()) + .deviceTag(board.getDeviceTag()) + .category(board.getCategory()) + .title(board.getTitle()) + .content(board.getContent()) + .fileUrls(board.getFileUrls()) + .createdAt(board.getCreatedAt().toString()) + .build(); + } +} diff --git a/src/main/java/com/stempo/api/domain/presentation/dto/response/RecordResponseDto.java b/src/main/java/com/stempo/api/domain/presentation/dto/response/RecordResponseDto.java index 9aff856..e0e6713 100644 --- a/src/main/java/com/stempo/api/domain/presentation/dto/response/RecordResponseDto.java +++ b/src/main/java/com/stempo/api/domain/presentation/dto/response/RecordResponseDto.java @@ -23,4 +23,13 @@ public static RecordResponseDto toDto(Record record) { .date(record.getCreatedAt().toLocalDate()) .build(); } + + public static RecordResponseDto createDefault() { + return RecordResponseDto.builder() + .accuracy(0.0) + .duration(0) + .steps(0) + .date(LocalDate.now()) + .build(); + } } diff --git a/src/main/java/com/stempo/api/global/exception/PermissionDeniedException.java b/src/main/java/com/stempo/api/global/exception/PermissionDeniedException.java new file mode 100644 index 0000000..5b53e9b --- /dev/null +++ b/src/main/java/com/stempo/api/global/exception/PermissionDeniedException.java @@ -0,0 +1,14 @@ +package com.stempo.api.global.exception; + +public class PermissionDeniedException extends RuntimeException { + + private static final String DEFAULT_MESSAGE = "권한이 부족합니다."; + + public PermissionDeniedException() { + super(DEFAULT_MESSAGE); + } + + public PermissionDeniedException(String s) { + super(s); + } +} diff --git a/src/main/java/com/stempo/api/global/handler/GlobalExceptionHandler.java b/src/main/java/com/stempo/api/global/handler/GlobalExceptionHandler.java index 2be286a..ebe7912 100644 --- a/src/main/java/com/stempo/api/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/stempo/api/global/handler/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import com.stempo.api.global.common.dto.ApiResponse; import com.stempo.api.global.common.dto.ErrorResponse; import com.stempo.api.global.exception.NotFoundException; +import com.stempo.api.global.exception.PermissionDeniedException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolationException; @@ -61,18 +62,26 @@ public ErrorResponse badRequestException(HttpServletResponse response BadCredentialsException.class, TokenValidateException.class, TokenNotFoundException.class, - TokenForgeryException.class, + TokenForgeryException.class }) public ApiResponse unAuthorizeException(HttpServletResponse response, Exception e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return ApiResponse.failure(); } + @ExceptionHandler({ + PermissionDeniedException.class + }) + public ApiResponse deniedException(HttpServletResponse response, Exception e) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return ApiResponse.failure(); + } + @ExceptionHandler({ NullPointerException.class, NotFoundException.class, NoSuchElementException.class, - FileNotFoundException.class, + FileNotFoundException.class }) public ErrorResponse notFoundException(HttpServletResponse response, Exception e) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); diff --git a/src/main/java/com/stempo/api/global/util/StringJsonConverter.java b/src/main/java/com/stempo/api/global/util/StringJsonConverter.java new file mode 100644 index 0000000..51b1500 --- /dev/null +++ b/src/main/java/com/stempo/api/global/util/StringJsonConverter.java @@ -0,0 +1,35 @@ +package com.stempo.api.global.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Slf4j +public class StringJsonConverter implements AttributeConverter, String> { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(List attribute) { + try { + return objectMapper.writeValueAsString(attribute); + } catch (Exception e) { + log.error("Could not convert list to JSON string", e); + return null; + } + } + + @Override + public List convertToEntityAttribute(String dbData) { + try { + return objectMapper.readValue(dbData, new TypeReference<>() { + }); + } catch (Exception e) { + log.error("Could not convert JSON string to list", e); + return null; + } + } +}