From 6a33d881fc38bc77a797d1a3665f98c6d5df7b2f Mon Sep 17 00:00:00 2001 From: sanghoon Date: Sun, 31 Aug 2025 18:52:15 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(#281):=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/exception/BannerErrorCode.java | 34 +++++++++++++++++++ .../messages/messages-error.properties | 6 +++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mos/backend/banners/entity/exception/BannerErrorCode.java diff --git a/src/main/java/com/mos/backend/banners/entity/exception/BannerErrorCode.java b/src/main/java/com/mos/backend/banners/entity/exception/BannerErrorCode.java new file mode 100644 index 00000000..972308e6 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/entity/exception/BannerErrorCode.java @@ -0,0 +1,34 @@ +package com.mos.backend.banners.entity.exception; + +import com.mos.backend.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; + +import java.util.Locale; + +@RequiredArgsConstructor +public enum BannerErrorCode implements ErrorCode { + NOT_FOUND(HttpStatus.NOT_FOUND, "banner.not-found"), + SORT_ORDER_NOT_PROPER(HttpStatus.BAD_REQUEST, "banner.sort-order.not-proper") + ; + + + private final HttpStatus httpStatus; + private final String messageKey; + + @Override + public HttpStatus getStatus() { + return httpStatus; + } + + @Override + public String getErrorName() { + return this.name(); + } + + @Override + public String getMessage(MessageSource messageSource) { + return messageSource.getMessage(messageKey, null, Locale.getDefault()); + } +} diff --git a/src/main/resources/messages/messages-error.properties b/src/main/resources/messages/messages-error.properties index feed40bd..36cc0ba9 100644 --- a/src/main/resources/messages/messages-error.properties +++ b/src/main/resources/messages/messages-error.properties @@ -127,4 +127,8 @@ stomp.invalid-destination=\uC62C\uBC14\uB974\uC9C0 \uC54A\uC740 destination\uC78 #UserStudySetting -user-study-setting.not-found=스터디에 등록된 유저를 찾을 수 없습니다. \ No newline at end of file +user-study-setting.not-found=스터디에 등록된 유저를 찾을 수 없습니다. + +# banner +banner.not-found = 배너를 찾을 수 없습니다. +banner.sort-order.not-proper = 올바르지 않은 배너 순서입니다. 순서는 전체 배너의 수와 같거나 작아야 하며, 1보다 같거나 커야 합니다. \ No newline at end of file From 7502bd260b0fbda15f112e26dffb6ca1a2649dbc Mon Sep 17 00:00:00 2001 From: sanghoon Date: Sun, 31 Aug 2025 18:52:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(#281):=20security=EC=97=90=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mos/backend/common/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/mos/backend/common/config/SecurityConfig.java b/src/main/java/com/mos/backend/common/config/SecurityConfig.java index b17279ff..2ef5e0d8 100644 --- a/src/main/java/com/mos/backend/common/config/SecurityConfig.java +++ b/src/main/java/com/mos/backend/common/config/SecurityConfig.java @@ -72,6 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.GET, "/users/tokens").permitAll() .requestMatchers(HttpMethod.GET, "/likes").permitAll() .requestMatchers(HttpMethod.GET, "/tokens").permitAll() + .requestMatchers(HttpMethod.GET, "/banners/**").permitAll() .anyRequest().authenticated() ) From 24ca2031e32640674234fac0454138c65fcbf4c8 Mon Sep 17 00:00:00 2001 From: sanghoon Date: Sun, 31 Aug 2025 18:53:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(#281):=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=9C=A0=ED=98=95=EC=97=90=20BAN?= =?UTF-8?q?NER=20=EC=9C=A0=ED=98=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mos/backend/studymaterials/application/UploadType.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/mos/backend/studymaterials/application/UploadType.java b/src/main/java/com/mos/backend/studymaterials/application/UploadType.java index 211a772a..210a3623 100644 --- a/src/main/java/com/mos/backend/studymaterials/application/UploadType.java +++ b/src/main/java/com/mos/backend/studymaterials/application/UploadType.java @@ -9,6 +9,7 @@ public enum UploadType { USER("user"), STUDY("study"), TEMP("temp"), + BANNER("banner") ; private final String folderPath; From e121db9171b0a07d89a3f53f9e8c3829bfefeeeb Mon Sep 17 00:00:00 2001 From: sanghoon Date: Sun, 31 Aug 2025 18:53:55 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(#281):=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20api=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단 건 조회 - 다 건 조회 - 생성 - 수정 - 삭제 - 생성, 수정, 삭제 시 배너의 순서를 그에 맞춰 조정하도록 함 --- .../banners/application/BannerService.java | 141 ++++++++++++++++++ .../responsedto/BannerResponseDto.java | 23 +++ .../mos/backend/banners/entity/Banner.java | 43 ++++++ .../infrastructure/BannerJpaRepository.java | 25 ++++ .../infrastructure/BannerRepository.java | 22 +++ .../infrastructure/BannerRepositoryImpl.java | 49 ++++++ .../presentation/BannerController.java | 72 +++++++++ .../requestdto/BannerCreateRequestDto.java | 19 +++ .../requestdto/BannerUpdateRequestDto.java | 19 +++ 9 files changed, 413 insertions(+) create mode 100644 src/main/java/com/mos/backend/banners/application/BannerService.java create mode 100644 src/main/java/com/mos/backend/banners/application/responsedto/BannerResponseDto.java create mode 100644 src/main/java/com/mos/backend/banners/entity/Banner.java create mode 100644 src/main/java/com/mos/backend/banners/infrastructure/BannerJpaRepository.java create mode 100644 src/main/java/com/mos/backend/banners/infrastructure/BannerRepository.java create mode 100644 src/main/java/com/mos/backend/banners/infrastructure/BannerRepositoryImpl.java create mode 100644 src/main/java/com/mos/backend/banners/presentation/BannerController.java create mode 100644 src/main/java/com/mos/backend/banners/presentation/requestdto/BannerCreateRequestDto.java create mode 100644 src/main/java/com/mos/backend/banners/presentation/requestdto/BannerUpdateRequestDto.java diff --git a/src/main/java/com/mos/backend/banners/application/BannerService.java b/src/main/java/com/mos/backend/banners/application/BannerService.java new file mode 100644 index 00000000..88a44f2e --- /dev/null +++ b/src/main/java/com/mos/backend/banners/application/BannerService.java @@ -0,0 +1,141 @@ +package com.mos.backend.banners.application; + +import com.mos.backend.banners.application.responsedto.BannerResponseDto; +import com.mos.backend.banners.entity.Banner; +import com.mos.backend.banners.entity.exception.BannerErrorCode; +import com.mos.backend.banners.infrastructure.BannerRepository; +import com.mos.backend.banners.presentation.requestdto.BannerCreateRequestDto; +import com.mos.backend.banners.presentation.requestdto.BannerUpdateRequestDto; +import com.mos.backend.common.exception.MosException; +import com.mos.backend.studymaterials.application.UploadType; +import com.mos.backend.studymaterials.application.fileuploader.Uploader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BannerService { + private static final Long FOLDER_NAME = 1L; + private final BannerRepository bannerRepository; + private final Uploader uploader; + + /** + * 배너 생성 + */ + @Transactional + public BannerResponseDto createBanner(BannerCreateRequestDto requestDto, MultipartFile imageFile) { + long totalBanners = bannerRepository.count(); + int requestedOrder = requestDto.getSortOrder(); + + // 배너 순서 검증 + if (requestedOrder < 1 || requestedOrder > totalBanners + 1) { + throw new MosException(BannerErrorCode.SORT_ORDER_NOT_PROPER); + } + + // 새로운 배너의 순서 뒤쪽 배너는 모두 순서 + 1 + bannerRepository.incrementSortOrderGreaterThanOrEqual(requestedOrder); + + // 배너 이미지 업로드 + String uuidFileName = uploader.generateUUIDFileName(imageFile); + String newFilePath = uploader.uploadFileSync(uuidFileName, FOLDER_NAME, UploadType.BANNER, imageFile); + + Banner banner = Banner.builder() + .title(requestDto.getTitle()) + .content(requestDto.getContent()) + .imageUrl(newFilePath) + .linkUrl(requestDto.getLinkUrl()) + .sortOrder(requestedOrder) + .build(); + + Banner savedBanner = bannerRepository.save(banner); + return new BannerResponseDto(savedBanner); + } + + /** + * Banner 수정 + */ + @Transactional + public BannerResponseDto updateBanner(Long bannerId, BannerUpdateRequestDto requestDto, MultipartFile imageFile) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new MosException(BannerErrorCode.NOT_FOUND)); + + int originalOrder = banner.getSortOrder(); + int newOrder = requestDto.getSortOrder(); + + long totalBanners = bannerRepository.count(); + + // 배너 순서 검증 + if (newOrder < 1 || newOrder > totalBanners) { + throw new MosException(BannerErrorCode.SORT_ORDER_NOT_PROPER); + } + + String imageUrl = banner.getImageUrl(); + + // 배너의 수정할 이미지가 존재하면 기존 이미지 삭제하고 새로운 이미지 업로드 + if (imageFile != null && !imageFile.isEmpty()) { + uploader.deleteFile(imageUrl); + String generateUUIDFileName = uploader.generateUUIDFileName(imageFile); + imageUrl = uploader.uploadFileSync(generateUUIDFileName, FOLDER_NAME, UploadType.BANNER, imageFile); + } + + // 배너의 순서를 변경했다면 + if (originalOrder != newOrder) { + // 기존 순서 뒤쪽은 순서를 -1 + bannerRepository.decrementSortOrderGreaterThan(originalOrder); + // 새로운 순서부터 뒤 순서는 +1 + bannerRepository.incrementSortOrderGreaterThanOrEqual(newOrder); + } + + banner.update( + requestDto.getTitle(), + requestDto.getContent(), + imageUrl, + requestDto.getLinkUrl(), + newOrder + ); + + return new BannerResponseDto(banner); + } + + /** + * Banner 다 건 조회 + */ + public List findAllBanners() { + return bannerRepository.findAllByOrderBySortOrderAsc().stream() + .map(BannerResponseDto::new) + .toList(); + } + + /** + * 단 건 조회 + */ + public BannerResponseDto findBanner(Long bannerId) { + Banner banner = bannerRepository.findById(bannerId) + .orElseThrow(() -> new MosException(BannerErrorCode.NOT_FOUND)); + return new BannerResponseDto(banner); + } + + /** + * 배너 삭제 + */ + @Transactional + public void deleteBanner(Long bannerId) { + Banner bannerToDelete = bannerRepository.findById(bannerId) + .orElseThrow(() -> new MosException(BannerErrorCode.NOT_FOUND)); + + int deletedOrder = bannerToDelete.getSortOrder(); + + // S3에 업로드된 이미지 파일 삭제 + uploader.deleteFile(bannerToDelete.getImageUrl()); + + bannerRepository.delete(bannerToDelete); + + // 빈자리를 채우기 위해 뒷순서 배너들을 1씩 앞으로 당김 + bannerRepository.decrementSortOrderGreaterThan(deletedOrder); + } +} diff --git a/src/main/java/com/mos/backend/banners/application/responsedto/BannerResponseDto.java b/src/main/java/com/mos/backend/banners/application/responsedto/BannerResponseDto.java new file mode 100644 index 00000000..955e9525 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/application/responsedto/BannerResponseDto.java @@ -0,0 +1,23 @@ +package com.mos.backend.banners.application.responsedto; + +import com.mos.backend.banners.entity.Banner; +import lombok.Getter; + +@Getter +public class BannerResponseDto { + private Long id; + private String title; + private String content; + private String imageUrl; + private String linkUrl; + private int sortOrder; + + public BannerResponseDto(Banner banner) { + this.id = banner.getId(); + this.title = banner.getTitle(); + this.content = banner.getContent(); + this.imageUrl = banner.getImageUrl(); + this.linkUrl = banner.getLinkUrl(); + this.sortOrder = banner.getSortOrder(); + } +} diff --git a/src/main/java/com/mos/backend/banners/entity/Banner.java b/src/main/java/com/mos/backend/banners/entity/Banner.java new file mode 100644 index 00000000..bdce7f4a --- /dev/null +++ b/src/main/java/com/mos/backend/banners/entity/Banner.java @@ -0,0 +1,43 @@ +package com.mos.backend.banners.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "banners") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Banner { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String content; + private String imageUrl; + private String linkUrl; + + @Column(nullable = false) + private int sortOrder; + + @Builder + public Banner(String title, String content, String imageUrl, String linkUrl, int sortOrder) { + this.title = title; + this.content = content; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.sortOrder = sortOrder; + } + + public void update(String title, String content, String imageUrl, String linkUrl, int sortOrder) { + this.title = title; + this.content = content; + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.sortOrder = sortOrder; + } +} diff --git a/src/main/java/com/mos/backend/banners/infrastructure/BannerJpaRepository.java b/src/main/java/com/mos/backend/banners/infrastructure/BannerJpaRepository.java new file mode 100644 index 00000000..d97586cd --- /dev/null +++ b/src/main/java/com/mos/backend/banners/infrastructure/BannerJpaRepository.java @@ -0,0 +1,25 @@ +package com.mos.backend.banners.infrastructure; + +import com.mos.backend.banners.entity.Banner; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface BannerJpaRepository extends JpaRepository { + + List findAllByOrderBySortOrderAsc(); + + // 생성 시 순서 조정을 위한 쿼리 + @Modifying + @Query("UPDATE Banner b SET b.sortOrder = b.sortOrder + 1 WHERE b.sortOrder >= :sortOrder") + void incrementSortOrderGreaterThanOrEqual(@Param("sortOrder") int sortOrder); + + @Modifying + @Query("UPDATE Banner b SET b.sortOrder = b.sortOrder - 1 WHERE b.sortOrder > :sortOrder") + void decrementSortOrderGreaterThan(@Param("sortOrder") int sortOrder); + + long count(); +} diff --git a/src/main/java/com/mos/backend/banners/infrastructure/BannerRepository.java b/src/main/java/com/mos/backend/banners/infrastructure/BannerRepository.java new file mode 100644 index 00000000..a4cf6a91 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/infrastructure/BannerRepository.java @@ -0,0 +1,22 @@ +package com.mos.backend.banners.infrastructure; + +import com.mos.backend.banners.entity.Banner; + +import java.util.List; +import java.util.Optional; + +public interface BannerRepository { + long count(); + + void incrementSortOrderGreaterThanOrEqual(int sortOrder); + + void decrementSortOrderGreaterThan(int sortOrder); + + List findAllByOrderBySortOrderAsc(); + + Banner save(Banner banner); + + Optional findById(Long bannerId); + + void delete(Banner banner); +} diff --git a/src/main/java/com/mos/backend/banners/infrastructure/BannerRepositoryImpl.java b/src/main/java/com/mos/backend/banners/infrastructure/BannerRepositoryImpl.java new file mode 100644 index 00000000..4e779af8 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/infrastructure/BannerRepositoryImpl.java @@ -0,0 +1,49 @@ +package com.mos.backend.banners.infrastructure; + +import com.mos.backend.banners.entity.Banner; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class BannerRepositoryImpl implements BannerRepository{ + private final BannerJpaRepository bannerJpaRepository; + + @Override + public long count() { + return bannerJpaRepository.count(); + } + + @Override + public void incrementSortOrderGreaterThanOrEqual(int sortOrder) { + bannerJpaRepository.incrementSortOrderGreaterThanOrEqual(sortOrder); + } + + @Override + public void decrementSortOrderGreaterThan(int sortOrder) { + bannerJpaRepository.decrementSortOrderGreaterThan(sortOrder); + } + + @Override + public List findAllByOrderBySortOrderAsc() { + return bannerJpaRepository.findAllByOrderBySortOrderAsc(); + } + + @Override + public Banner save(Banner banner) { + return bannerJpaRepository.save(banner); + } + + @Override + public Optional findById(Long bannerId) { + return bannerJpaRepository.findById(bannerId); + } + + @Override + public void delete(Banner banner) { + bannerJpaRepository.delete(banner); + } +} diff --git a/src/main/java/com/mos/backend/banners/presentation/BannerController.java b/src/main/java/com/mos/backend/banners/presentation/BannerController.java new file mode 100644 index 00000000..b511aa7c --- /dev/null +++ b/src/main/java/com/mos/backend/banners/presentation/BannerController.java @@ -0,0 +1,72 @@ +package com.mos.backend.banners.presentation; + +import com.mos.backend.banners.application.BannerService; +import com.mos.backend.banners.application.responsedto.BannerResponseDto; +import com.mos.backend.banners.presentation.requestdto.BannerCreateRequestDto; +import com.mos.backend.banners.presentation.requestdto.BannerUpdateRequestDto; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/banners") +@RequiredArgsConstructor +public class BannerController { + private final BannerService bannerService; + + /** + * 새로운 배너를 생성 + */ + @PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) + @ResponseStatus(HttpStatus.CREATED) + public BannerResponseDto createBanner( + @RequestPart("bannerData") @Valid BannerCreateRequestDto requestDto, + @RequestPart("imageFile") MultipartFile imageFile) { + return bannerService.createBanner(requestDto, imageFile); + } + + /** + * 모든 배너 목록을 순서대로 조회 + */ + @GetMapping + @ResponseStatus(HttpStatus.OK) + public List findAllBanners() { + return bannerService.findAllBanners(); + } + + /** + * 특정 배너의 상세 정보를 조회 + */ + @GetMapping("/{id}") + @ResponseStatus(HttpStatus.OK) + public BannerResponseDto findBanner(@PathVariable Long id) { + return bannerService.findBanner(id); + } + + /** + * 기존 배너의 정보를 수정 + */ + @PutMapping(value = "/{id}") + @ResponseStatus(HttpStatus.OK) + public BannerResponseDto updateBanner( + @PathVariable Long id, + @RequestPart("bannerData") @Valid BannerUpdateRequestDto requestDto, + @RequestPart(value = "imageFile", required = false) MultipartFile imageFile) { + + return bannerService.updateBanner(id, requestDto, imageFile); + } + + /** + * 특정 배너를 삭제 + */ + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteBanner(@PathVariable Long id) { + bannerService.deleteBanner(id); + } +} diff --git a/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerCreateRequestDto.java b/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerCreateRequestDto.java new file mode 100644 index 00000000..4fcc1a82 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerCreateRequestDto.java @@ -0,0 +1,19 @@ +package com.mos.backend.banners.presentation.requestdto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BannerCreateRequestDto { + @NotBlank(message = "배너 제목은 필수입니다.") + private String title; + private String content; + private String linkUrl; + @NotNull(message = "배너 순서는 필수입니다.") + @Min(value = 1, message = "배너 순서는 1 이상이어야 합니다.") + private Integer sortOrder; +} diff --git a/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerUpdateRequestDto.java b/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerUpdateRequestDto.java new file mode 100644 index 00000000..c72e7dc0 --- /dev/null +++ b/src/main/java/com/mos/backend/banners/presentation/requestdto/BannerUpdateRequestDto.java @@ -0,0 +1,19 @@ +package com.mos.backend.banners.presentation.requestdto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BannerUpdateRequestDto { + @NotBlank(message = "배너 제목은 필수입니다.") + private String title; + private String content; + private String linkUrl; + @NotNull(message = "배너 순서는 필수입니다.") + @Min(value = 1, message = "배너 순서는 1 이상이어야 합니다.") + private Integer sortOrder; +}