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..54745a2 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerRequest.java @@ -0,0 +1,35 @@ +package hs.kr.backend.devpals.domain.banner.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +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; + + @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() { + 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..7092c53 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/dto/BannerResponse.java @@ -0,0 +1,36 @@ +package hs.kr.backend.devpals.domain.banner.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +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; + @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) { + 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..ce99083 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/entity/BannerEntity.java @@ -0,0 +1,58 @@ +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(); + } + + 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 new file mode 100644 index 0000000..07e7f50 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/domain/banner/repository/BannerRepository.java @@ -0,0 +1,13 @@ +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.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(); + } + } +} 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, "중복된 닉네임 입니다."),