From 32a358cbb662fe8a2296dd608c3cfb73b6c2b94e Mon Sep 17 00:00:00 2001 From: NamDoHyeon2 Date: Tue, 1 Jul 2025 16:39:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Feat]=20=EB=B0=B0=EB=84=88=20CRUD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../banner/controller/BannerController.java | 83 +++++++++++++++++ .../domain/banner/dto/BannerRequest.java | 31 +++++++ .../domain/banner/dto/BannerResponse.java | 33 +++++++ .../domain/banner/entity/BannerEntity.java | 53 +++++++++++ .../banner/repository/BannerRepository.java | 10 +++ .../domain/banner/service/BannerService.java | 89 +++++++++++++++++++ .../global/exception/ErrorException.java | 1 + 7 files changed, 300 insertions(+) create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/controller/BannerController.java create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/service/BannerService.java diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/controller/BannerController.java b/src/main/java/hs/kr/backend/devpals/domain/banner/controller/BannerController.java new file mode 100644 index 0000000..babdcb3 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/controller/BannerController.java @@ -0,0 +1,83 @@ +package hs.kr.backend.devpals.domain.banner.controller; + +import hs.kr.backend.devpals.domain.banner.dto.BannerRequest; +import hs.kr.backend.devpals.domain.banner.dto.BannerResponse; +import hs.kr.backend.devpals.domain.banner.service.BannerService; +import hs.kr.backend.devpals.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/banner") +@RequiredArgsConstructor +@Tag(name = "Banner API", description = "배너 관련 API") +public class BannerController { + + private final BannerService bannerService; + + @PostMapping(consumes = "multipart/form-data") + @Operation( + summary = "배너 생성", + description = "이미지, 노출 여부, 노출 방식(상시/기간) 및 기간(선택)을 포함하여 배너를 생성합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배너 생성 성공") + public ResponseEntity> createBanner( + @RequestPart BannerRequest request, + @RequestPart MultipartFile image + ) { + return bannerService.createBanner(request, image); + } + + @PatchMapping(value = "/{bannerId}", consumes = "multipart/form-data") + @Operation( + summary = "배너 수정", + description = "배너 ID를 기반으로 기존 배너 정보를 이미지 포함 수정합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배너 수정 성공") + public ResponseEntity> updateBanner( + @Parameter(description = "수정할 배너 ID") @PathVariable Long bannerId, + @RequestPart BannerRequest request, + @RequestPart(required = false) MultipartFile image + ) { + return bannerService.updateBanner(bannerId, request, image); + } + + @DeleteMapping("/{bannerId}") + @Operation( + summary = "배너 삭제", + description = "배너 ID를 기반으로 배너를 삭제합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "배너 삭제 성공") + public ResponseEntity> deleteBanner( + @Parameter(description = "삭제할 배너 ID") @PathVariable Long bannerId + ) { + return bannerService.deleteBanner(bannerId); + } + + @GetMapping + @Operation( + summary = "전체 배너 조회", + description = "관리자 페이지 또는 시스템용 전체 배너 목록을 조회합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "전체 배너 조회 성공") + public ResponseEntity>> getAllBanners() { + return bannerService.getAllBanners(); + } + + @GetMapping("/visible") + @Operation( + summary = "노출 중 배너 조회", + description = "isVisible=true인 노출 중인 배너만 필터링하여 조회합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "노출 중 배너 조회 성공") + public ResponseEntity>> getVisibleBanners() { + return bannerService.getVisibleBanners(); + } +} diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java new file mode 100644 index 0000000..95ac168 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java @@ -0,0 +1,31 @@ +package hs.kr.backend.devpals.domain.banner.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BannerRequest { + + private String imageUrl; + private boolean isVisible; + private boolean isAlways; + private LocalDateTime startDate; + private LocalDateTime endDate; + + public BannerEntity toEntity() { + return BannerEntity.builder() + .imageUrl(imageUrl) + .isVisible(isVisible) + .isAlways(isAlways) + .startDate(isAlways ? null : startDate) + .endDate(isAlways ? null : endDate) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java new file mode 100644 index 0000000..72f0b00 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java @@ -0,0 +1,33 @@ +package hs.kr.backend.devpals.domain.banner.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BannerResponse { + + private Long id; + private String imageUrl; + private boolean isVisible; + private boolean isAlways; + private LocalDateTime startDate; + private LocalDateTime endDate; + + public static BannerResponse from(BannerEntity banner) { + return BannerResponse.builder() + .id(banner.getId()) + .imageUrl(banner.getImageUrl()) + .isVisible(banner.isVisible()) + .isAlways(banner.isAlways()) + .startDate(banner.getStartDate()) + .endDate(banner.getEndDate()) + .build(); + } +} diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java b/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java new file mode 100644 index 0000000..df3459c --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java @@ -0,0 +1,53 @@ +package hs.kr.backend.devpals.domain.banner.entity; + +import hs.kr.backend.devpals.domain.banner.dto.BannerRequest; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "banner") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BannerEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private boolean isVisible; + + @Column(nullable = false) + private boolean isAlways; + + private LocalDateTime startDate; + + private LocalDateTime endDate; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(nullable = false) + private LocalDateTime updatedAt = LocalDateTime.now(); + + public void update(BannerRequest request, String newImageUrl) { + if (newImageUrl != null) { + this.imageUrl = newImageUrl; + } + this.isVisible = request.isVisible(); + this.isAlways = request.isAlways(); + this.startDate = request.isAlways() ? null : request.getStartDate(); + this.endDate = request.isAlways() ? null : request.getEndDate(); + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java b/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java new file mode 100644 index 0000000..d5c21d9 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java @@ -0,0 +1,10 @@ +package hs.kr.backend.devpals.domain.banner.repository; + +import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BannerRepository extends JpaRepository { + List findAllByIsVisibleTrue(); +} diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/service/BannerService.java b/src/main/java/hs/kr/backend/devpals/domain/banner/service/BannerService.java new file mode 100644 index 0000000..9829210 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/service/BannerService.java @@ -0,0 +1,89 @@ +package hs.kr.backend.devpals.domain.banner.service; + +import hs.kr.backend.devpals.domain.banner.dto.BannerRequest; +import hs.kr.backend.devpals.domain.banner.dto.BannerResponse; +import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; +import hs.kr.backend.devpals.domain.banner.repository.BannerRepository; +import hs.kr.backend.devpals.global.common.ApiResponse; +import hs.kr.backend.devpals.global.exception.CustomException; +import hs.kr.backend.devpals.global.exception.ErrorException; +import hs.kr.backend.devpals.infra.aws.AwsS3Client; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BannerService { + + private final BannerRepository bannerRepository; + private final AwsS3Client awsS3Client; + + @Transactional + public ResponseEntity> createBanner(BannerRequest request, MultipartFile image) { + if (image == null || image.isEmpty()) { + throw new CustomException(ErrorException.FILE_EMPTY); + } + + BannerEntity banner = bannerRepository.save(request.toEntity()); + + String fileName = "devpals_banner_" + banner.getId(); + String imageUrl = awsS3Client.upload(image, fileName); + + banner.update(request, imageUrl); + + return ResponseEntity.ok(new ApiResponse<>(200, true, "배너 생성 성공", BannerResponse.from(banner))); + } + + @Transactional + public ResponseEntity> updateBanner(Long id, BannerRequest request, MultipartFile image) { + BannerEntity banner = bannerRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorException.BANNER_NOT_FOUND)); + + String imageUrl = null; + + if (image != null && !image.isEmpty()) { + String fileName = "devpals_banner_" + banner.getId(); + awsS3Client.delete(fileName); + imageUrl = awsS3Client.upload(image, fileName); + } + + banner.update(request, imageUrl); + + return ResponseEntity.ok(new ApiResponse<>(200, true, "배너 수정 성공", BannerResponse.from(banner))); + } + + @Transactional + public ResponseEntity> deleteBanner(Long id) { + BannerEntity banner = bannerRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorException.BANNER_NOT_FOUND)); + + String fileName = "devpals_banner_" + banner.getId(); + awsS3Client.delete(fileName); + bannerRepository.delete(banner); + + return ResponseEntity.ok(new ApiResponse<>(200, true, "배너 삭제 성공", null)); + } + + @Transactional(readOnly = true) + public ResponseEntity>> getAllBanners() { + List list = bannerRepository.findAll().stream() + .map(BannerResponse::from) + .toList(); + + return ResponseEntity.ok(new ApiResponse<>(200, true, "전체 배너 조회 성공", list)); + } + + @Transactional(readOnly = true) + public ResponseEntity>> getVisibleBanners() { + List list = bannerRepository.findAllByIsVisibleTrue().stream() + .map(BannerResponse::from) + .toList(); + + return ResponseEntity.ok(new ApiResponse<>(200, true, "노출 중인 배너 조회 성공", list)); + } +} \ No newline at end of file diff --git a/src/main/java/hs/kr/backend/devpals/global/exception/ErrorException.java b/src/main/java/hs/kr/backend/devpals/global/exception/ErrorException.java index aff9e04..824c554 100644 --- a/src/main/java/hs/kr/backend/devpals/global/exception/ErrorException.java +++ b/src/main/java/hs/kr/backend/devpals/global/exception/ErrorException.java @@ -55,6 +55,7 @@ public enum ErrorException { NOT_FOUND_NOTICE(404, "공지사항을 찾을 수 없습니다."), ANSWER_NOT_FOUND(404, "해당 문의에 대한 답변이 존재하지 않습니다."), REPORT_NOT_FOUND(404, "신고를 찾을 수 없습니다."), + BANNER_NOT_FOUND(404, "배너를 찾을 수 없습니다."), DUPLICATE_EMAIL(409, "중복된 이메일 입니다."), DUPLICATE_NICKNAME(409, "중복된 닉네임 입니다."), From 33e08cafa45c4fd0314f239dff5ceda28ba0cead Mon Sep 17 00:00:00 2001 From: NamDoHyeon2 Date: Tue, 1 Jul 2025 17:09:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Fix]=20=EB=B0=B0=EB=84=88=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EB=A7=8C=EB=A3=8C=EC=8B=9C=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EC=A7=80=EC=95=8A=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/banner/dto/BannerRequest.java | 4 +++ .../domain/banner/dto/BannerResponse.java | 3 ++ .../domain/banner/entity/BannerEntity.java | 5 ++++ .../banner/repository/BannerRepository.java | 3 ++ .../banner/scheduler/BannerScheduler.java | 30 +++++++++++++++++++ 5 files changed, 45 insertions(+) create mode 100644 src/main/java/hs/kr/backend/devpals/domain/banner/scheduler/BannerScheduler.java diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java index 95ac168..54745a2 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java @@ -1,5 +1,6 @@ package hs.kr.backend.devpals.domain.banner.dto; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -16,7 +17,10 @@ public class BannerRequest { private String imageUrl; private boolean isVisible; private boolean isAlways; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime endDate; public BannerEntity toEntity() { diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java index 72f0b00..7092c53 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java @@ -1,5 +1,6 @@ package hs.kr.backend.devpals.domain.banner.dto; +import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -17,7 +18,9 @@ public class BannerResponse { private String imageUrl; private boolean isVisible; private boolean isAlways; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime startDate; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime endDate; public static BannerResponse from(BannerEntity banner) { diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java b/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java index df3459c..ce99083 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java @@ -50,4 +50,9 @@ public void update(BannerRequest request, String newImageUrl) { this.endDate = request.isAlways() ? null : request.getEndDate(); this.updatedAt = LocalDateTime.now(); } + + public void setInvisible() { + this.isVisible = false; + this.updatedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java b/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java index d5c21d9..07e7f50 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java @@ -3,8 +3,11 @@ import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; public interface BannerRepository extends JpaRepository { List findAllByIsVisibleTrue(); + + List findByEndDateBeforeAndIsVisibleTrue(LocalDateTime now); } diff --git a/src/main/java/hs/kr/backend/devpals/domain/banner/scheduler/BannerScheduler.java b/src/main/java/hs/kr/backend/devpals/domain/banner/scheduler/BannerScheduler.java new file mode 100644 index 0000000..fa843bb --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/scheduler/BannerScheduler.java @@ -0,0 +1,30 @@ +package hs.kr.backend.devpals.domain.banner.scheduler; + +import hs.kr.backend.devpals.domain.banner.entity.BannerEntity; +import hs.kr.backend.devpals.domain.banner.repository.BannerRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BannerScheduler { + private final BannerRepository bannerRepository; + + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") + @Transactional + public void updateExpiredBanners() { + LocalDateTime now = LocalDateTime.now(); + List expiredBanners = bannerRepository.findByEndDateBeforeAndIsVisibleTrue(now); + + for (BannerEntity banner : expiredBanners) { + banner.setInvisible(); + } + } +}