diff --git a/build.gradle b/build.gradle index a934e3bd..80acef83 100644 --- a/build.gradle +++ b/build.gradle @@ -51,7 +51,6 @@ project(':module-domain') { bootJar.enabled = false dependencies { - implementation project(':module-domain-s3') implementation project(':module-domain-smtp') implementation project(':module-internal') implementation project(':module-core') @@ -62,6 +61,7 @@ project(':module-domain-s3') { bootJar.enabled = false dependencies { + implementation project(':module-internal') implementation project(':module-core') } } diff --git a/module-application/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonApplicationService.java b/module-application/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonApplicationService.java new file mode 100644 index 00000000..5e1382f5 --- /dev/null +++ b/module-application/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonApplicationService.java @@ -0,0 +1,97 @@ +package com.devtoon.jtoon.webtoon.application; + +import static com.devtoon.jtoon.common.ImageType.*; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.devtoon.jtoon.application.S3Service; +import com.devtoon.jtoon.common.FileName; +import com.devtoon.jtoon.global.util.CustomPageRequest; +import com.devtoon.jtoon.member.entity.Member; +import com.devtoon.jtoon.request.UploadImageReq; +import com.devtoon.jtoon.webtoon.entity.Webtoon; +import com.devtoon.jtoon.webtoon.entity.enums.DayOfWeek; +import com.devtoon.jtoon.webtoon.request.CreateEpisodeReq; +import com.devtoon.jtoon.webtoon.request.CreateWebtoonReq; +import com.devtoon.jtoon.webtoon.request.GetWebtoonsReq; +import com.devtoon.jtoon.webtoon.response.EpisodeRes; +import com.devtoon.jtoon.webtoon.response.EpisodesRes; +import com.devtoon.jtoon.webtoon.response.WebtoonInfoRes; +import com.devtoon.jtoon.webtoon.response.WebtoonItemRes; +import com.devtoon.jtoon.webtoon.service.WebtoonDomainService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WebtoonApplicationService { + + private final WebtoonDomainService webtoonDomainService; + private final S3Service s3Service; + + @Transactional + public void createWebtoon(Member member, MultipartFile thumbnailImage, CreateWebtoonReq request) { + webtoonDomainService.validateDuplicateTitle(request.title()); + String thumbnailUrl = s3Service.upload(UploadImageReq.builder() + .imageType(WEBTOON_THUMBNAIL) + .webtoonTitle(request.title()) + .fileName(FileName.forWebtoon()) + .image(thumbnailImage) + .build() + ); + webtoonDomainService.createWebtoon(member, thumbnailUrl, request); + } + + @Transactional + public void createEpisode( + Member member, + Long webtoonId, + MultipartFile mainImage, + MultipartFile thumbnailImage, + CreateEpisodeReq request + ) { + Webtoon webtoon = webtoonDomainService.getWebtoonById(webtoonId); + webtoon.validateAuthor(member.getId()); + String mainUrl = s3Service.upload(UploadImageReq.builder() + .imageType(EPISODE_MAIN) + .webtoonTitle(webtoon.getTitle()) + .fileName(FileName.forEpisode(request.no())) + .image(mainImage) + .build() + ); + String thumbnailUrl = s3Service.upload(UploadImageReq.builder() + .imageType(EPISODE_THUMBNAIL) + .webtoonTitle(webtoon.getTitle()) + .fileName(FileName.forEpisode(request.no())) + .image(thumbnailImage) + .build() + ); + webtoonDomainService.createEpisode(webtoon, mainUrl, thumbnailUrl, request); + } + + public Map> getWebtoons(GetWebtoonsReq request) { + return webtoonDomainService.getWebtoons(request); + } + + public WebtoonInfoRes getWebtoon(Long webtoonId) { + return webtoonDomainService.getWebtoon(webtoonId); + } + + public List getEpisodes(Long webtoonId, CustomPageRequest request) { + return webtoonDomainService.getEpisodes(webtoonId, request); + } + + public EpisodeRes getEpisode(Long episodeId) { + return webtoonDomainService.getEpisode(episodeId); + } + + @Transactional + public void purchaseEpisode(Member member, Long episodeId) { + webtoonDomainService.purchaseEpisode(member, episodeId); + } +} diff --git a/module-application/src/main/java/com/devtoon/jtoon/webtoon/presentation/WebtoonController.java b/module-application/src/main/java/com/devtoon/jtoon/webtoon/presentation/WebtoonController.java index 600c1f26..8b48bfc9 100644 --- a/module-application/src/main/java/com/devtoon/jtoon/webtoon/presentation/WebtoonController.java +++ b/module-application/src/main/java/com/devtoon/jtoon/webtoon/presentation/WebtoonController.java @@ -1,5 +1,8 @@ package com.devtoon.jtoon.webtoon.presentation; +import java.util.List; +import java.util.Map; + import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -14,19 +17,17 @@ import com.devtoon.jtoon.global.util.CustomPageRequest; import com.devtoon.jtoon.member.entity.Member; import com.devtoon.jtoon.security.domain.jwt.MemberThreadLocal; -import com.devtoon.jtoon.webtoon.application.WebtoonService; +import com.devtoon.jtoon.webtoon.application.WebtoonApplicationService; import com.devtoon.jtoon.webtoon.entity.enums.DayOfWeek; import com.devtoon.jtoon.webtoon.request.CreateEpisodeReq; import com.devtoon.jtoon.webtoon.request.CreateWebtoonReq; -import com.devtoon.jtoon.webtoon.request.GetEpisodeReq; import com.devtoon.jtoon.webtoon.request.GetWebtoonsReq; import com.devtoon.jtoon.webtoon.response.EpisodeRes; import com.devtoon.jtoon.webtoon.response.EpisodesRes; import com.devtoon.jtoon.webtoon.response.WebtoonInfoRes; import com.devtoon.jtoon.webtoon.response.WebtoonItemRes; + import jakarta.validation.Valid; -import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; @RestController @@ -34,7 +35,7 @@ @RequestMapping("/webtoons") public class WebtoonController { - private final WebtoonService webtoonService; + private final WebtoonApplicationService webtoonService; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -47,12 +48,12 @@ public void createWebtoon(@RequestPart MultipartFile thumbnailImage, @RequestPar @ResponseStatus(HttpStatus.CREATED) public void createEpisode( @PathVariable Long webtoonId, - @RequestPart @Valid CreateEpisodeReq request, @RequestPart MultipartFile mainImage, - @RequestPart(required = false) MultipartFile thumbnailImage + @RequestPart(required = false) MultipartFile thumbnailImage, + @RequestPart @Valid CreateEpisodeReq request ) { Member member = MemberThreadLocal.getMember(); - webtoonService.createEpisode(member, webtoonId, request, mainImage, thumbnailImage); + webtoonService.createEpisode(member, webtoonId, mainImage, thumbnailImage, request); } @GetMapping @@ -72,9 +73,14 @@ public List getEpisodes(@RequestParam Long webtoonId, CustomPageReq return webtoonService.getEpisodes(webtoonId, request); } - @GetMapping("/detail") - @ResponseStatus(HttpStatus.OK) - public EpisodeRes detailEpisode(@RequestParam Long episodeId, @RequestParam GetEpisodeReq request) { - return webtoonService.getDetailEpisode(episodeId, request); + @GetMapping("/episodes/{episodeId}") + public EpisodeRes getEpisode(@PathVariable Long episodeId) { + return webtoonService.getEpisode(episodeId); + } + + @PostMapping("/episodes/{episodeId}/purchase") + public void purchaseEpisode(@PathVariable Long episodeId) { + Member member = MemberThreadLocal.getMember(); + webtoonService.purchaseEpisode(member, episodeId); } } diff --git a/module-domain-s3/src/main/java/com/devtoon/jtoon/application/S3Service.java b/module-domain-s3/src/main/java/com/devtoon/jtoon/application/S3Service.java index 688ade1f..40de2ac8 100644 --- a/module-domain-s3/src/main/java/com/devtoon/jtoon/application/S3Service.java +++ b/module-domain-s3/src/main/java/com/devtoon/jtoon/application/S3Service.java @@ -2,10 +2,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; -import com.devtoon.jtoon.common.FileName; -import com.devtoon.jtoon.common.ImageType; +import com.devtoon.jtoon.request.UploadImageReq; import com.devtoon.jtoon.util.S3Uploader; import lombok.RequiredArgsConstructor; @@ -19,9 +17,9 @@ public class S3Service { @Value("${spring.cloud.aws.cloud-front.url}") private String IMAGE_URL; - public String upload(ImageType imageType, String webtoonTitle, FileName fileName, MultipartFile image) { - String key = imageType.getPath(webtoonTitle, fileName.getValue()); - s3Uploader.upload(key, image); + public String upload(UploadImageReq request) { + String key = request.toKey(); + s3Uploader.upload(key, request.image()); return IMAGE_URL + key; } diff --git a/module-domain-s3/src/main/java/com/devtoon/jtoon/request/UploadImageReq.java b/module-domain-s3/src/main/java/com/devtoon/jtoon/request/UploadImageReq.java new file mode 100644 index 00000000..6ec8175e --- /dev/null +++ b/module-domain-s3/src/main/java/com/devtoon/jtoon/request/UploadImageReq.java @@ -0,0 +1,21 @@ +package com.devtoon.jtoon.request; + +import org.springframework.web.multipart.MultipartFile; + +import com.devtoon.jtoon.common.FileName; +import com.devtoon.jtoon.common.ImageType; + +import lombok.Builder; + +@Builder +public record UploadImageReq( + ImageType imageType, + String webtoonTitle, + FileName fileName, + MultipartFile image +) { + + public String toKey() { + return imageType.getPath(webtoonTitle, fileName.getValue()); + } +} diff --git a/module-domain-s3/src/main/java/com/devtoon/jtoon/util/S3Uploader.java b/module-domain-s3/src/main/java/com/devtoon/jtoon/util/S3Uploader.java index 725162ff..426424f0 100644 --- a/module-domain-s3/src/main/java/com/devtoon/jtoon/util/S3Uploader.java +++ b/module-domain-s3/src/main/java/com/devtoon/jtoon/util/S3Uploader.java @@ -1,11 +1,15 @@ package com.devtoon.jtoon.util; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; + import java.io.IOException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; +import com.devtoon.jtoon.error.exception.InvalidRequestException; + import io.awspring.cloud.s3.ObjectMetadata; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; @@ -28,7 +32,7 @@ public void upload(String key, MultipartFile file) { ObjectMetadata.builder().contentType("image/png").build() ); } catch (IOException e) { - throw new RuntimeException(e); + throw new InvalidRequestException(S3_UPLOAD_FAIL); } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/entity/MemberCookie.java b/module-domain/src/main/java/com/devtoon/jtoon/member/entity/MemberCookie.java new file mode 100644 index 00000000..87532a7b --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/entity/MemberCookie.java @@ -0,0 +1,58 @@ +package com.devtoon.jtoon.member.entity; + +import static com.devtoon.jtoon.error.model.ErrorStatus.*; +import static java.util.Objects.*; + +import com.devtoon.jtoon.error.exception.InvalidRequestException; +import com.devtoon.jtoon.global.common.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "member_cookies") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemberCookie extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_cookie_id") + private Long id; + + @Column(name = "cookie_count", nullable = false) + private int cookieCount; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Builder + private MemberCookie(int cookieCount, Member member) { + if (cookieCount < 0) { + throw new InvalidRequestException(COOKIE_COUNT_NOT_NEGATIVE); + } + + this.cookieCount = cookieCount; + this.member = requireNonNull(member, MEMBER_IS_NULL.getMessage()); + } + + public void decreaseCookieCount(int cookieCount) { + if (this.cookieCount < cookieCount) { + throw new InvalidRequestException(EPISODE_NOT_ENOUGH_COOKIES); + } + + this.cookieCount -= cookieCount; + } +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberCookieRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberCookieRepository.java new file mode 100644 index 00000000..33727905 --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/member/repository/MemberCookieRepository.java @@ -0,0 +1,8 @@ +package com.devtoon.jtoon.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.devtoon.jtoon.member.entity.MemberCookie; + +public interface MemberCookieRepository extends JpaRepository { +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/DayOfWeekWebtoon.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/DayOfWeekWebtoon.java index 68a29328..91c367c7 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/DayOfWeekWebtoon.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/DayOfWeekWebtoon.java @@ -1,5 +1,6 @@ package com.devtoon.jtoon.webtoon.entity; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; import static java.util.Objects.*; import com.devtoon.jtoon.global.common.BaseTimeEntity; @@ -42,7 +43,7 @@ public class DayOfWeekWebtoon extends BaseTimeEntity { @Builder private DayOfWeekWebtoon(DayOfWeek dayOfWeek, Webtoon webtoon) { - this.dayOfWeek = requireNonNull(dayOfWeek, "dayOfWeek is null"); - this.webtoon = requireNonNull(webtoon, "webtoon is null"); + this.dayOfWeek = requireNonNull(dayOfWeek, WEBTOON_DAY_OF_WEEK_IS_NULL.getMessage()); + this.webtoon = requireNonNull(webtoon, WEBTOON_IS_NULL.getMessage()); } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Episode.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Episode.java index a7685dfd..6bad889c 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Episode.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Episode.java @@ -1,11 +1,13 @@ package com.devtoon.jtoon.webtoon.entity; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; import static java.util.Objects.*; import java.time.LocalDateTime; import org.hibernate.annotations.ColumnDefault; +import com.devtoon.jtoon.error.exception.InvalidRequestException; import com.devtoon.jtoon.global.common.BaseTimeEntity; import jakarta.persistence.Column; @@ -69,15 +71,19 @@ private Episode( Webtoon webtoon ) { if (no <= 0) { - throw new RuntimeException("number is zero or negative number"); + throw new InvalidRequestException(EPISODE_NUMBER_POSITIVE); } this.no = no; - this.title = requireNonNull(title, "title is null"); - this.mainUrl = requireNonNull(mainUrl, "mainUrl is null"); + this.title = requireNonNull(title, EPISODE_TITLE_IS_NULL.getMessage()); + this.mainUrl = requireNonNull(mainUrl, EPISODE_MAIN_URL_IS_NULL.getMessage()); this.thumbnailUrl = thumbnailUrl; this.hasComment = hasComment; - this.openedAt = requireNonNull(openedAt, "openedAt is null"); - this.webtoon = requireNonNull(webtoon, "webtoon is null"); + this.openedAt = requireNonNull(openedAt, EPISODE_OPENED_AT_IS_NULL.getMessage()); + this.webtoon = requireNonNull(webtoon, WEBTOON_IS_NULL.getMessage()); + } + + public int getCookieCount() { + return webtoon.getCookieCount(); } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/GenreWebtoon.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/GenreWebtoon.java index 88c84279..c34c3e87 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/GenreWebtoon.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/GenreWebtoon.java @@ -1,5 +1,6 @@ package com.devtoon.jtoon.webtoon.entity; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; import static java.util.Objects.*; import com.devtoon.jtoon.global.common.BaseTimeEntity; @@ -42,7 +43,7 @@ public class GenreWebtoon extends BaseTimeEntity { @Builder private GenreWebtoon(Genre genre, Webtoon webtoon) { - this.genre = requireNonNull(genre, "genre is null"); - this.webtoon = requireNonNull(webtoon, "webtoon is null"); + this.genre = requireNonNull(genre, WEBTOON_GENRE_IS_NULL.getMessage()); + this.webtoon = requireNonNull(webtoon, WEBTOON_IS_NULL.getMessage()); } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/PurchasedEpisode.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/PurchasedEpisode.java new file mode 100644 index 00000000..0a67e124 --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/PurchasedEpisode.java @@ -0,0 +1,47 @@ +package com.devtoon.jtoon.webtoon.entity; + +import static com.devtoon.jtoon.error.model.ErrorStatus.*; +import static java.util.Objects.*; + +import com.devtoon.jtoon.global.common.BaseTimeEntity; +import com.devtoon.jtoon.member.entity.Member; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "purchased_episodes") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PurchasedEpisode extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purchased_episode_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "episode_id", nullable = false) + private Episode episode; + + @Builder + private PurchasedEpisode(Member member, Episode episode) { + this.member = requireNonNull(member, MEMBER_IS_NULL.getMessage()); + this.episode = requireNonNull(episode, EPISODE_IS_NULL.getMessage()); + } +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Webtoon.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Webtoon.java index ca52df0e..5d9bb54d 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Webtoon.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/Webtoon.java @@ -1,9 +1,13 @@ package com.devtoon.jtoon.webtoon.entity; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; import static java.util.Objects.*; +import java.util.Objects; + import org.hibernate.annotations.ColumnDefault; +import com.devtoon.jtoon.error.exception.InvalidRequestException; import com.devtoon.jtoon.global.common.BaseTimeEntity; import com.devtoon.jtoon.member.entity.Member; import com.devtoon.jtoon.webtoon.entity.enums.AgeLimit; @@ -66,18 +70,24 @@ private Webtoon( Member author ) { if (cookieCount < 0) { - throw new RuntimeException("cookieCount is negative number"); + throw new InvalidRequestException(COOKIE_COUNT_NOT_NEGATIVE); } - this.title = requireNonNull(title, "title is null"); - this.description = requireNonNull(description, "description is null"); - this.ageLimit = requireNonNull(ageLimit, "ageLimit is null"); + this.title = requireNonNull(title, WEBTOON_TITLE_IS_NULL.getMessage()); + this.description = requireNonNull(description, WEBTOON_DESCRIPTION_IS_NULL.getMessage()); + this.ageLimit = requireNonNull(ageLimit, WEBTOON_AGE_LIMIT_IS_NULL.getMessage()); this.thumbnailUrl = thumbnailUrl; this.cookieCount = cookieCount; - this.author = requireNonNull(author, "author is null"); + this.author = requireNonNull(author, WEBTOON_AUTHOR_IS_NULL.getMessage()); + } + + public void validateAuthor(Long memberId) { + if (!isAuthor(memberId)) { + throw new InvalidRequestException(WEBTOON_NOT_AUTHOR); + } } - public boolean isAuthor(Long memberId) { - return memberId.equals(author.getId()); + private boolean isAuthor(Long memberId) { + return Objects.equals(author.getId(), memberId); } } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/enums/Genre.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/enums/Genre.java index f6d18ccd..a7a71c11 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/enums/Genre.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/entity/enums/Genre.java @@ -17,7 +17,8 @@ public enum Genre { HISTORICAL("무협/사극"), DRAMA("드라마"), SENSIBILITY("감성"), - SPORTS("스포츠"); + SPORTS("스포츠"), + ; private final String text; } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/EpisodeRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/EpisodeRepository.java index f73de940..1da04916 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/EpisodeRepository.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/EpisodeRepository.java @@ -5,6 +5,4 @@ import com.devtoon.jtoon.webtoon.entity.Episode; public interface EpisodeRepository extends JpaRepository { - - Episode findByIdAndNo(Long id, int no); } diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/PurchasedEpisodeRepository.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/PurchasedEpisodeRepository.java new file mode 100644 index 00000000..f53c7eb5 --- /dev/null +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/repository/PurchasedEpisodeRepository.java @@ -0,0 +1,8 @@ +package com.devtoon.jtoon.webtoon.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.devtoon.jtoon.webtoon.entity.PurchasedEpisode; + +public interface PurchasedEpisodeRepository extends JpaRepository { +} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/request/GetEpisodeReq.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/request/GetEpisodeReq.java deleted file mode 100644 index 749c0d98..00000000 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/request/GetEpisodeReq.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.devtoon.jtoon.webtoon.request; - -import jakarta.validation.constraints.Min; - -public record GetEpisodeReq( - @Min(1) int no -) { -} diff --git a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonService.java b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/service/WebtoonDomainService.java similarity index 66% rename from module-domain/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonService.java rename to module-domain/src/main/java/com/devtoon/jtoon/webtoon/service/WebtoonDomainService.java index c780830e..26c874d4 100644 --- a/module-domain/src/main/java/com/devtoon/jtoon/webtoon/application/WebtoonService.java +++ b/module-domain/src/main/java/com/devtoon/jtoon/webtoon/service/WebtoonDomainService.java @@ -1,6 +1,6 @@ -package com.devtoon.jtoon.webtoon.application; +package com.devtoon.jtoon.webtoon.service; -import static com.devtoon.jtoon.common.ImageType.*; +import static com.devtoon.jtoon.error.model.ErrorStatus.*; import static java.util.stream.Collectors.*; import java.util.List; @@ -8,27 +8,28 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; -import com.devtoon.jtoon.application.S3Service; -import com.devtoon.jtoon.common.FileName; +import com.devtoon.jtoon.error.exception.DuplicatedException; +import com.devtoon.jtoon.error.exception.NotFoundException; import com.devtoon.jtoon.global.util.CustomPageRequest; import com.devtoon.jtoon.member.entity.Member; -import com.devtoon.jtoon.member.repository.MemberRepository; +import com.devtoon.jtoon.member.entity.MemberCookie; +import com.devtoon.jtoon.member.repository.MemberCookieRepository; import com.devtoon.jtoon.webtoon.entity.DayOfWeekWebtoon; import com.devtoon.jtoon.webtoon.entity.Episode; import com.devtoon.jtoon.webtoon.entity.GenreWebtoon; +import com.devtoon.jtoon.webtoon.entity.PurchasedEpisode; import com.devtoon.jtoon.webtoon.entity.Webtoon; import com.devtoon.jtoon.webtoon.entity.enums.DayOfWeek; import com.devtoon.jtoon.webtoon.repository.DayOfWeekWebtoonRepository; import com.devtoon.jtoon.webtoon.repository.EpisodeRepository; import com.devtoon.jtoon.webtoon.repository.EpisodeSearchRepository; import com.devtoon.jtoon.webtoon.repository.GenreWebtoonRepository; +import com.devtoon.jtoon.webtoon.repository.PurchasedEpisodeRepository; import com.devtoon.jtoon.webtoon.repository.WebtoonRepository; import com.devtoon.jtoon.webtoon.repository.WebtoonSearchRepository; import com.devtoon.jtoon.webtoon.request.CreateEpisodeReq; import com.devtoon.jtoon.webtoon.request.CreateWebtoonReq; -import com.devtoon.jtoon.webtoon.request.GetEpisodeReq; import com.devtoon.jtoon.webtoon.request.GetWebtoonsReq; import com.devtoon.jtoon.webtoon.response.EpisodeRes; import com.devtoon.jtoon.webtoon.response.EpisodesRes; @@ -41,7 +42,7 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class WebtoonService { +public class WebtoonDomainService { private final WebtoonRepository webtoonRepository; private final WebtoonSearchRepository webtoonSearchRepository; @@ -49,18 +50,11 @@ public class WebtoonService { private final GenreWebtoonRepository genreWebtoonRepository; private final EpisodeRepository episodeRepository; private final EpisodeSearchRepository episodeSearchRepository; - private final MemberRepository memberRepository; - private final S3Service s3Service; - + private final PurchasedEpisodeRepository purchasedEpisodeRepository; + private final MemberCookieRepository memberCookieRepository; + @Transactional - public void createWebtoon(Member member, MultipartFile thumbnailImage, CreateWebtoonReq request) { - validateDuplicateTitle(request.title()); - String thumbnailUrl = s3Service.upload( - WEBTOON_THUMBNAIL, - request.title(), - FileName.forWebtoon(), - thumbnailImage - ); + public void createWebtoon(Member member, String thumbnailUrl, CreateWebtoonReq request) { Webtoon webtoon = request.toWebtoonEntity(member, thumbnailUrl); List dayOfWeekWebtoons = request.toDayOfWeekWebtoonEntity(webtoon); List genreWebtoons = request.toGenreWebtoonEntity(webtoon); @@ -70,27 +64,7 @@ public void createWebtoon(Member member, MultipartFile thumbnailImage, CreateWeb } @Transactional - public void createEpisode( - Member member, - Long webtoonId, - CreateEpisodeReq request, - MultipartFile mainImage, - MultipartFile thumbnailImage - ) { - Webtoon webtoon = getWebtoonById(webtoonId); - validateAuthorOfWebtoon(member, webtoon); - String mainUrl = s3Service.upload( - EPISODE_MAIN, - webtoon.getTitle(), - FileName.forEpisode(request.no()), - mainImage - ); - String thumbnailUrl = s3Service.upload( - EPISODE_THUMBNAIL, - webtoon.getTitle(), - FileName.forEpisode(request.no()), - thumbnailImage - ); + public void createEpisode(Webtoon webtoon, String mainUrl, String thumbnailUrl, CreateEpisodeReq request) { Episode episode = request.toEntity(webtoon, mainUrl, thumbnailUrl); episodeRepository.save(episode); } @@ -119,14 +93,42 @@ public List getEpisodes(Long webtoonId, CustomPageRequest request) .toList(); } - public EpisodeRes getDetailEpisode(Long episodeId, GetEpisodeReq request) { - Episode episode = episodeRepository.findByIdAndNo(episodeId, request.no()); + public EpisodeRes getEpisode(Long episodeId) { + Episode episode = getEpisodeById(episodeId); return EpisodeRes.from(episode); } - private Webtoon getWebtoonById(Long webtoonId) { + @Transactional + public void purchaseEpisode(Member member, Long episodeId) { + Episode episode = getEpisodeById(episodeId); + MemberCookie memberCookie = getMemberCookieById(member.getId()); + memberCookie.decreaseCookieCount(episode.getCookieCount()); + purchasedEpisodeRepository.save(PurchasedEpisode.builder() + .member(member) + .episode(episode) + .build() + ); + } + + public void validateDuplicateTitle(String title) { + if (webtoonRepository.existsByTitle(title)) { + throw new DuplicatedException(WEBTOON_TITLE_DUPLICATED); + } + } + + public Webtoon getWebtoonById(Long webtoonId) { return webtoonRepository.findById(webtoonId) - .orElseThrow(() -> new RuntimeException("존재하는 웹툰이 아닙니다.")); + .orElseThrow(() -> new NotFoundException(WEBTOON_NOT_FOUND)); + } + + private Episode getEpisodeById(Long episodeId) { + return episodeRepository.findById(episodeId) + .orElseThrow(() -> new NotFoundException(EPISODE_NOT_FOUND)); + } + + private MemberCookie getMemberCookieById(Long memberId) { + return memberCookieRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException(MEMBER_COOKIE_NOT_FOUND)); } private List getDayOfWeeks(Long webtoonId) { @@ -142,16 +144,4 @@ private List getGenres(Long webtoonId) { .map(GenreRes::from) .toList(); } - - private void validateDuplicateTitle(String title) { - if (webtoonRepository.existsByTitle(title)) { - throw new RuntimeException("이미 존재하는 웹툰 제목입니다."); - } - } - - private void validateAuthorOfWebtoon(Member member, Webtoon webtoon) { - if (!webtoon.isAuthor(member.getId())) { - throw new RuntimeException("해당 웹툰의 작가가 아닙니다."); - } - } } diff --git a/module-internal/src/main/java/com/devtoon/jtoon/error/handler/GlobalExceptionHandler.java b/module-internal/src/main/java/com/devtoon/jtoon/error/handler/GlobalExceptionHandler.java index ed25966a..3a2407aa 100644 --- a/module-internal/src/main/java/com/devtoon/jtoon/error/handler/GlobalExceptionHandler.java +++ b/module-internal/src/main/java/com/devtoon/jtoon/error/handler/GlobalExceptionHandler.java @@ -1,71 +1,85 @@ package com.devtoon.jtoon.error.handler; -import com.devtoon.jtoon.error.exception.*; -import com.devtoon.jtoon.error.model.ErrorResponse; -import com.devtoon.jtoon.error.model.ErrorStatus; +import java.util.List; + +import org.springframework.http.HttpStatus; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.List; +import com.devtoon.jtoon.error.exception.DuplicatedException; +import com.devtoon.jtoon.error.exception.ForbiddenException; +import com.devtoon.jtoon.error.exception.InvalidRequestException; +import com.devtoon.jtoon.error.exception.NotFoundException; +import com.devtoon.jtoon.error.exception.UnauthorizedException; +import com.devtoon.jtoon.error.model.ErrorResponse; +import com.devtoon.jtoon.error.model.ErrorStatus; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException.class) - public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - final ErrorStatus error = ErrorStatus.COMMON_DTO_FIELD_INVALID_FORMAT; - - return makeResponseErrorFormat(error, e); - } - - @ExceptionHandler(NullPointerException.class) - public ErrorResponse handleNullPointerException(NullPointerException e) { - return makeResponseErrorFormat(e.getMessage()); - } - - @ExceptionHandler(NotFoundException.class) - protected ErrorResponse handleNotFoundException(NotFoundException e) { - return makeResponseErrorFormat(e.getErrorStatus()); - } - - @ExceptionHandler(InvalidRequestException.class) - protected ErrorResponse handleInvalidRequestException(InvalidRequestException e) { - return makeResponseErrorFormat(e.getErrorStatus()); - } - - @ExceptionHandler(DuplicatedException.class) - protected ErrorResponse handleDuplicatedException(DuplicatedException e) { - return makeResponseErrorFormat(e.getErrorStatus()); - } - - @ExceptionHandler(UnauthorizedException.class) - protected ErrorResponse handleUnauthorizedException(UnauthorizedException e) { - return makeResponseErrorFormat(e.getErrorStatus()); - } - - @ExceptionHandler(ForbiddenException.class) - protected ErrorResponse handleForbiddenException(ForbiddenException e) { - return makeResponseErrorFormat(e.getErrorStatus()); - } - - private ErrorResponse makeResponseErrorFormat(String message) { - return new ErrorResponse(message, null); - } - - private ErrorResponse makeResponseErrorFormat(ErrorStatus error) { - return new ErrorResponse(error.getMessage(), null); - } - - private ErrorResponse makeResponseErrorFormat(ErrorStatus error, BindException e) { - List fieldErrors = e.getBindingResult() - .getFieldErrors(); - List errors = fieldErrors.stream() - .map(ErrorResponse.FieldErrorStatus::of) - .toList(); - - return new ErrorResponse(error.getMessage(), errors); - } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + final ErrorStatus error = ErrorStatus.COMMON_DTO_FIELD_INVALID_FORMAT; + + return makeResponseErrorFormat(error, e); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(NullPointerException.class) + public ErrorResponse handleNullPointerException(NullPointerException e) { + return makeResponseErrorFormat(e.getMessage()); + } + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NotFoundException.class) + protected ErrorResponse handleNotFoundException(NotFoundException e) { + return makeResponseErrorFormat(e.getErrorStatus()); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(InvalidRequestException.class) + protected ErrorResponse handleInvalidRequestException(InvalidRequestException e) { + return makeResponseErrorFormat(e.getErrorStatus()); + } + + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler(DuplicatedException.class) + protected ErrorResponse handleDuplicatedException(DuplicatedException e) { + return makeResponseErrorFormat(e.getErrorStatus()); + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + protected ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + return makeResponseErrorFormat(e.getErrorStatus()); + } + + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(ForbiddenException.class) + protected ErrorResponse handleForbiddenException(ForbiddenException e) { + return makeResponseErrorFormat(e.getErrorStatus()); + } + + private ErrorResponse makeResponseErrorFormat(String message) { + return new ErrorResponse(message, null); + } + + private ErrorResponse makeResponseErrorFormat(ErrorStatus error) { + return new ErrorResponse(error.getMessage(), null); + } + + private ErrorResponse makeResponseErrorFormat(ErrorStatus error, BindException e) { + List fieldErrors = e.getBindingResult() + .getFieldErrors(); + List errors = fieldErrors.stream() + .map(ErrorResponse.FieldErrorStatus::of) + .toList(); + + return new ErrorResponse(error.getMessage(), errors); + } } diff --git a/module-internal/src/main/java/com/devtoon/jtoon/error/model/ErrorStatus.java b/module-internal/src/main/java/com/devtoon/jtoon/error/model/ErrorStatus.java index 15901d2b..3a9f6dfa 100644 --- a/module-internal/src/main/java/com/devtoon/jtoon/error/model/ErrorStatus.java +++ b/module-internal/src/main/java/com/devtoon/jtoon/error/model/ErrorStatus.java @@ -8,30 +8,53 @@ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public enum ErrorStatus { - COMMON_DTO_FIELD_INVALID_FORMAT("올바른 요청 정보가 아닙니다."), - - MEMBER_EMAIL_INVALID_FORMAT("올바른 이메일 형식이 아닙니다."), - MEMBER_PASSWORD_INVALID_FORMAT("올바른 비밀번호형식이 아닙니다."), - MEMBER_NAME_INVALID_FORMAT("올바른 이름이 아닙니다."), - MEMBER_NICKNAME_INVALID_FORMAT("올바른 닉네임이 아닙니다."), - MEMBER_GENDER_INVALID_FORMAT("올바른 성이 아닙니다."), - MEMBER_PHONE_INVALID_FORMAT("올바른 전화번호 형식이 아닙니다."), - MEMBER_ROLE_INVALID_FORMAT("올바른 회원 역할이 아닙니다"), - MEMBER_LOGIN_TYPE_INVALID_FORMAT("올바른 로그인 타입이 아닙니다."), - MEMBER_MESSAGE_SEND_FAILED("이메일 인증 메세지 전송 실패"), - MEMBER_EMAIL_CONFLICT("Email 중복"), - - PAYMENT_IMP_UID_IS_NULL("결제 고유번호 값이 NULL 입니다."), - PAYMENT_MERCHANT_UID_IS_NULL("주문번호 값이 NULL 입니다."), - PAYMENT_PAY_METHOD_IS_NULL("결제 방법 값이 NULL 입니다."), - PAYMENT_COOKIE_ITEM_IS_NULL("쿠키 아이템 값이 NULL 입니다."), - PAYMENT_AMOUNT_IS_NULL("결제 금액 값이 NULL 입니다."), - PAYMENT_MEMBER_IS_NULL("결제 사용자 값이 NULL 입니다."), - PAYMENT_IMP_UID_DUPLICATED("결제 고유번호가 중복되었습니다."), - PAYMENT_MERCHANT_UID_DUPLICATED("주문 번호가 중복되었습니다."), - PAYMENT_AMOUNT_INVALID("잘못된 결제 금액입니다."), - PAYMENT_COOKIE_NOT_FOUND("등록되지 않은 쿠키 상품입니다."), - ; - - private final String message; + COMMON_DTO_FIELD_INVALID_FORMAT("올바른 요청 정보가 아닙니다."), + + MEMBER_IS_NULL("회원 값이 NULL 입니다."), + MEMBER_EMAIL_INVALID_FORMAT("올바른 이메일 형식이 아닙니다."), + MEMBER_PASSWORD_INVALID_FORMAT("올바른 비밀번호형식이 아닙니다."), + MEMBER_NAME_INVALID_FORMAT("올바른 이름이 아닙니다."), + MEMBER_NICKNAME_INVALID_FORMAT("올바른 닉네임이 아닙니다."), + MEMBER_GENDER_INVALID_FORMAT("올바른 성이 아닙니다."), + MEMBER_PHONE_INVALID_FORMAT("올바른 전화번호 형식이 아닙니다."), + MEMBER_ROLE_INVALID_FORMAT("올바른 회원 역할이 아닙니다"), + MEMBER_LOGIN_TYPE_INVALID_FORMAT("올바른 로그인 타입이 아닙니다."), + MEMBER_MESSAGE_SEND_FAILED("이메일 인증 메세지 전송 실패"), + MEMBER_EMAIL_CONFLICT("Email 중복"), + MEMBER_COOKIE_NOT_FOUND("회원의 쿠키 정보가 존재하지 않습니다."), + + PAYMENT_IMP_UID_IS_NULL("결제 고유번호 값이 NULL 입니다."), + PAYMENT_MERCHANT_UID_IS_NULL("주문번호 값이 NULL 입니다."), + PAYMENT_PAY_METHOD_IS_NULL("결제 방법 값이 NULL 입니다."), + PAYMENT_COOKIE_ITEM_IS_NULL("쿠키 아이템 값이 NULL 입니다."), + PAYMENT_AMOUNT_IS_NULL("결제 금액 값이 NULL 입니다."), + PAYMENT_MEMBER_IS_NULL("결제 사용자 값이 NULL 입니다."), + PAYMENT_IMP_UID_DUPLICATED("결제 고유번호가 중복되었습니다."), + PAYMENT_MERCHANT_UID_DUPLICATED("주문 번호가 중복되었습니다."), + PAYMENT_AMOUNT_INVALID("잘못된 결제 금액입니다."), + PAYMENT_COOKIE_NOT_FOUND("등록되지 않은 쿠키 상품입니다."), + + WEBTOON_TITLE_DUPLICATED("이미 존재하는 웹툰 제목입니다."), + WEBTOON_NOT_FOUND("존재하지 않는 웹툰입니다."), + WEBTOON_NOT_AUTHOR("해당 웹툰의 작가가 아닙니다."), + WEBTOON_IS_NULL("웹툰 값이 NULL 입니다."), + WEBTOON_TITLE_IS_NULL("웹툰 제목 값이 NULL 입니다."), + WEBTOON_DESCRIPTION_IS_NULL("웹툰 설명 값이 NULL 입니다."), + WEBTOON_AGE_LIMIT_IS_NULL("웹툰 연령 값이 NULL 입니다."), + WEBTOON_AUTHOR_IS_NULL("웹툰 작가 값이 NULL 입니다."), + WEBTOON_DAY_OF_WEEK_IS_NULL("웹툰 요일 값이 NULL 입니다."), + WEBTOON_GENRE_IS_NULL("웹툰 장르 값이 NULL 입니다."), + COOKIE_COUNT_NOT_NEGATIVE("쿠키 개수는 음수일 수 없습니다."), + EPISODE_IS_NULL("회차 값이 NULL 입니다."), + EPISODE_NOT_FOUND("존재하지 않는 회차입니다."), + EPISODE_NUMBER_POSITIVE("회차 번호는 양수여야 합니다."), + EPISODE_NOT_ENOUGH_COOKIES("쿠키 개수가 부족합니다."), + EPISODE_TITLE_IS_NULL("회차 제목 값이 NULL 입니다."), + EPISODE_MAIN_URL_IS_NULL("회차 메인 이미지 URL 값이 NULL 입니다."), + EPISODE_OPENED_AT_IS_NULL("회차 공개일자 값이 NULL 입니다."), + + S3_UPLOAD_FAIL("S3 이미지 업로드에 실패했습니다."), + ; + + private final String message; }