From a1a5234af019274e0100c6a7b49f813a367bfff8 Mon Sep 17 00:00:00 2001 From: Chanhyeok Seo Date: Wed, 17 Apr 2024 14:12:56 +0900 Subject: [PATCH] Feature/dashboard advertisements (#34) * feature: add advertisement entity and register api * feature: add upload,delete,get api for advertisement * refactor: change media type for update advertisement --- .../api/AdvertisementController.java | 66 +++++++++++++++ .../dto/AdvertisementResponse.java | 31 +++++++ .../advertisement/dto/PatchAdvertisement.java | 15 ++++ .../advertisement/dto/PostAdvertisement.java | 18 ++++ .../advertisement/entity/Advertisement.java | 35 ++++++++ .../repository/AdvertisementRepository.java | 13 +++ .../service/AdvertisementService.java | 82 +++++++++++++++++++ .../global/error/exception/ErrorCode.java | 5 +- 8 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/repick/domain/advertisement/api/AdvertisementController.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/dto/AdvertisementResponse.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/dto/PatchAdvertisement.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/dto/PostAdvertisement.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/entity/Advertisement.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/repository/AdvertisementRepository.java create mode 100644 src/main/java/com/example/repick/domain/advertisement/service/AdvertisementService.java diff --git a/src/main/java/com/example/repick/domain/advertisement/api/AdvertisementController.java b/src/main/java/com/example/repick/domain/advertisement/api/AdvertisementController.java new file mode 100644 index 00000000..3bf91851 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/api/AdvertisementController.java @@ -0,0 +1,66 @@ +package com.example.repick.domain.advertisement.api; + +import com.example.repick.domain.advertisement.dto.AdvertisementResponse; +import com.example.repick.domain.advertisement.dto.PatchAdvertisement; +import com.example.repick.domain.advertisement.dto.PostAdvertisement; +import com.example.repick.domain.advertisement.service.AdvertisementService; +import com.example.repick.global.response.SuccessResponse; +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.*; + +import java.util.List; + +@Tag(name = "Advertisement", description = "광고 관련 API") +@RestController @RequestMapping("/advertisements") +@RequiredArgsConstructor +public class AdvertisementController { + + private final AdvertisementService advertisementService; + + @Operation(summary = "광고 등록", description = """ + 광고를 등록합니다. + + sequence 값은 광고를 노출할 우선 순위로, 낮을수록 먼저 노출됩니다. + sequence 값이 중복일 경우 에러가 발생합니다. (400: ADVERTISEMENT_SEQUENCE_DUPLICATED) + + """) + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public SuccessResponse postAdvertisement(@ModelAttribute PostAdvertisement postAdvertisement) { + return SuccessResponse.createSuccess(advertisementService.postAdvertisement(postAdvertisement)); + } + + @Operation(summary = "광고 순서 수정", description = """ + 광고 순서를 수정합니다. + + sequence 값은 광고를 노출할 우선 순위로, 낮을수록 먼저 노출됩니다. + sequence 값이 중복일 경우 에러가 발생합니다. (400: ADVERTISEMENT_SEQUENCE_DUPLICATED) + + - advertisementId: 광고 ID + - sequence: 광고 노출 순서 + + """) + @PatchMapping + public SuccessResponse patchAdvertisement(@RequestBody PatchAdvertisement patchAdvertisement) { + return SuccessResponse.success(advertisementService.patchAdvertisement(patchAdvertisement)); + } + + @Operation(summary = "광고 삭제", description = """ + 광고를 삭제합니다. (하드 삭제) + """) + @DeleteMapping("/{advertisementId}") + public SuccessResponse deleteAdvertisement(@PathVariable Long advertisementId) { + return SuccessResponse.success(advertisementService.deleteAdvertisement(advertisementId)); + } + + @Operation(summary = "광고 조회", description = """ + 광고를 조회합니다. sequence가 낮은 순서대로 조회됩니다. + """) + @GetMapping + public SuccessResponse> getAdvertisement() { + return SuccessResponse.success(advertisementService.getAdvertisementList()); + } + +} diff --git a/src/main/java/com/example/repick/domain/advertisement/dto/AdvertisementResponse.java b/src/main/java/com/example/repick/domain/advertisement/dto/AdvertisementResponse.java new file mode 100644 index 00000000..46d81c00 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/dto/AdvertisementResponse.java @@ -0,0 +1,31 @@ +package com.example.repick.domain.advertisement.dto; + +import com.example.repick.domain.advertisement.entity.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +public record AdvertisementResponse( + @Schema(description = "광고 ID", example = "1") Long id, + @Schema(description = "광고 이미지 URL") String imageUrl, + @Schema(description = "링크 URL, null일 시 클릭 불가능한 광고입니다.") String linkUrl, + @Schema(description = "광고 노출 순서", example = "1") Integer order + +) { + + + public static AdvertisementResponse of(Advertisement advertisement) { + return new AdvertisementResponse( + advertisement.getId(), + advertisement.getImageUrl(), + advertisement.getLinkUrl(), + advertisement.getSequence() + ); + } + + public static List ofList(List allByOrderBySequenceAsc) { + return allByOrderBySequenceAsc.stream() + .map(AdvertisementResponse::of) + .toList(); + } +} diff --git a/src/main/java/com/example/repick/domain/advertisement/dto/PatchAdvertisement.java b/src/main/java/com/example/repick/domain/advertisement/dto/PatchAdvertisement.java new file mode 100644 index 00000000..3f69c1c2 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/dto/PatchAdvertisement.java @@ -0,0 +1,15 @@ +package com.example.repick.domain.advertisement.dto; + +import com.example.repick.domain.advertisement.entity.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; + +public record PatchAdvertisement( + @Schema(description = "광고 ID", example = "1") Long advertisementId, + @Schema(description = "광고 노출 순서", example = "1") Integer sequence +) { + public Advertisement toAdvertisement() { + return Advertisement.builder() + .sequence(this.sequence()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/repick/domain/advertisement/dto/PostAdvertisement.java b/src/main/java/com/example/repick/domain/advertisement/dto/PostAdvertisement.java new file mode 100644 index 00000000..1b7b1a85 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/dto/PostAdvertisement.java @@ -0,0 +1,18 @@ +package com.example.repick.domain.advertisement.dto; + +import com.example.repick.domain.advertisement.entity.Advertisement; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.web.multipart.MultipartFile; + +public record PostAdvertisement( + @Schema(description = "광고 이미지 파일") MultipartFile image, + @Schema(description = "링크 URL, null일 시 클릭 불가능한 광고입니다.") String linkUrl, + @Schema(description = "광고 노출 순서", example = "1") Integer sequence +) { + public Advertisement toAdvertisement() { + return Advertisement.builder() + .linkUrl(this.linkUrl()) + .sequence(this.sequence()) + .build(); + } +} diff --git a/src/main/java/com/example/repick/domain/advertisement/entity/Advertisement.java b/src/main/java/com/example/repick/domain/advertisement/entity/Advertisement.java new file mode 100644 index 00000000..097df503 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/entity/Advertisement.java @@ -0,0 +1,35 @@ +package com.example.repick.domain.advertisement.entity; + +import com.example.repick.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter +public class Advertisement extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private String imageUrl; + private String linkUrl; // null일 시 클릭 불가 광고 + @Column(nullable = false, unique = true) + private Integer sequence; + + @Builder + public Advertisement(String imageUrl, String linkUrl, Integer sequence) { + this.imageUrl = imageUrl; + this.linkUrl = linkUrl; + this.sequence = sequence; + } + + public void updateImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public void updateSequence(Integer sequence) { + this.sequence = sequence == null ? this.sequence : sequence; + } +} diff --git a/src/main/java/com/example/repick/domain/advertisement/repository/AdvertisementRepository.java b/src/main/java/com/example/repick/domain/advertisement/repository/AdvertisementRepository.java new file mode 100644 index 00000000..b7f31985 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/repository/AdvertisementRepository.java @@ -0,0 +1,13 @@ +package com.example.repick.domain.advertisement.repository; + +import com.example.repick.domain.advertisement.entity.Advertisement; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AdvertisementRepository extends JpaRepository { + Optional findBySequence(Integer sequence); + + List findAllByOrderBySequenceAsc(); +} diff --git a/src/main/java/com/example/repick/domain/advertisement/service/AdvertisementService.java b/src/main/java/com/example/repick/domain/advertisement/service/AdvertisementService.java new file mode 100644 index 00000000..c1990117 --- /dev/null +++ b/src/main/java/com/example/repick/domain/advertisement/service/AdvertisementService.java @@ -0,0 +1,82 @@ +package com.example.repick.domain.advertisement.service; + +import com.example.repick.domain.advertisement.dto.AdvertisementResponse; +import com.example.repick.domain.advertisement.dto.PatchAdvertisement; +import com.example.repick.domain.advertisement.dto.PostAdvertisement; +import com.example.repick.domain.advertisement.entity.Advertisement; +import com.example.repick.domain.advertisement.repository.AdvertisementRepository; +import com.example.repick.global.aws.S3UploadService; +import com.example.repick.global.error.exception.CustomException; +import com.example.repick.global.error.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Service @RequiredArgsConstructor +public class AdvertisementService { + + private final AdvertisementRepository advertisementRepository; + private final S3UploadService s3UploadService; + + private void uploadAndUpdateImage(MultipartFile image, Advertisement advertisement) { + try { + String imageUrl = s3UploadService.saveFile(image, "advertisement/" + advertisement.getId()); + advertisement.updateImageUrl(imageUrl); + advertisementRepository.save(advertisement); + } + catch (IOException e) { + e.printStackTrace(); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + } + + public AdvertisementResponse postAdvertisement(PostAdvertisement postAdvertisement) { + Advertisement advertisement = postAdvertisement.toAdvertisement(); + + advertisementRepository.findBySequence(advertisement.getSequence()) + .ifPresent(ad -> { + throw new CustomException(ErrorCode.ADVERTISEMENT_SEQUENCE_DUPLICATED); + }); + + uploadAndUpdateImage(postAdvertisement.image(), advertisement); + + return AdvertisementResponse.of(advertisement); + + } + + public AdvertisementResponse patchAdvertisement(PatchAdvertisement patchAdvertisement) { + Advertisement advertisement = advertisementRepository.findById(patchAdvertisement.advertisementId()) + .orElseThrow(() -> new CustomException(ErrorCode.ADVERTISEMENT_NOT_FOUND)); + + + if (patchAdvertisement.sequence() != null) { + advertisementRepository.findBySequence(patchAdvertisement.sequence()) + .ifPresent(ad -> { + throw new CustomException(ErrorCode.ADVERTISEMENT_SEQUENCE_DUPLICATED); + }); + } + + advertisement.updateSequence(patchAdvertisement.sequence()); + + advertisementRepository.save(advertisement); + + return AdvertisementResponse.of(advertisement); + + } + + public Boolean deleteAdvertisement(Long advertisementId) { + Advertisement advertisement = advertisementRepository.findById(advertisementId) + .orElseThrow(() -> new CustomException(ErrorCode.ADVERTISEMENT_NOT_FOUND)); + + advertisementRepository.delete(advertisement); + + return true; + } + + public List getAdvertisementList() { + return AdvertisementResponse.ofList(advertisementRepository.findAllByOrderBySequenceAsc()); + } +} diff --git a/src/main/java/com/example/repick/global/error/exception/ErrorCode.java b/src/main/java/com/example/repick/global/error/exception/ErrorCode.java index 47fe0b40..f793b5bf 100644 --- a/src/main/java/com/example/repick/global/error/exception/ErrorCode.java +++ b/src/main/java/com/example/repick/global/error/exception/ErrorCode.java @@ -38,7 +38,10 @@ public enum ErrorCode { // Apple OAuth APPLE_LOGIN_FAILED(400, "C001", "인증 코드가 올바르지 않거나 만료되었습니다."), //FCM - USER_FCM_TOKEN_NOT_FOUND(404, "C006", "존재하지 않는 FCM 토큰입니다."); + USER_FCM_TOKEN_NOT_FOUND(404, "C006", "존재하지 않는 FCM 토큰입니다."), + // Advertisement + ADVERTISEMENT_SEQUENCE_DUPLICATED(400, "C001", "중복된 순서의 광고가 존재합니다."), + ADVERTISEMENT_NOT_FOUND(404, "C002", "존재하지 않는 광고입니다."); private final int status; private final String code;