diff --git a/src/main/java/com/creatorhub/constant/CreationThumbnailType.java b/src/main/java/com/creatorhub/constant/CreationThumbnailType.java index 04340f4..4d5ad35 100644 --- a/src/main/java/com/creatorhub/constant/CreationThumbnailType.java +++ b/src/main/java/com/creatorhub/constant/CreationThumbnailType.java @@ -1,8 +1,29 @@ package com.creatorhub.constant; -public enum CreationThumbnailType { - POSTER, // 포스터형 - HORIZONTAL, // 가로형 - DERIVED, // 리사이징(파생) 이미지 - EXTRA // 리사이징 후 별도로 수정한 이미지 +public enum CreationThumbnailType implements ThumbnailType { + POSTER { + @Override + public String resolveSuffix() { + return ThumbnailKeys.POSTER_SUFFIX; + } + }, + HORIZONTAL { + @Override + public String resolveSuffix() { + return ThumbnailKeys.HORIZONTAL_SUFFIX; + } + }, + DERIVED { + @Override + public String resolveSuffix() { + return null; + } + }, + EXTRA { + @Override + public String resolveSuffix() { + return null; + } + } } + diff --git a/src/main/java/com/creatorhub/constant/EpisodeThumbnailType.java b/src/main/java/com/creatorhub/constant/EpisodeThumbnailType.java new file mode 100644 index 0000000..e459aea --- /dev/null +++ b/src/main/java/com/creatorhub/constant/EpisodeThumbnailType.java @@ -0,0 +1,16 @@ +package com.creatorhub.constant; + +public enum EpisodeThumbnailType implements ThumbnailType { + EPISODE { + @Override + public String resolveSuffix() { + return ThumbnailKeys.EPISODE_SUFFIX; + } + }, + SNS { + @Override + public String resolveSuffix() { + return ThumbnailKeys.SNS_SUFFIX; + } + } +} diff --git a/src/main/java/com/creatorhub/constant/ErrorCode.java b/src/main/java/com/creatorhub/constant/ErrorCode.java index 676c8a0..f2fadeb 100644 --- a/src/main/java/com/creatorhub/constant/ErrorCode.java +++ b/src/main/java/com/creatorhub/constant/ErrorCode.java @@ -23,7 +23,7 @@ public enum ErrorCode { AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "A006", "토큰 인증에 실패했습니다."), // FileObject 관련 에러 - FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "존재하는 파일입니다."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "존재하지 않는 파일입니다."), FILE_STATUS_NOT_CORRECT(HttpStatus.CONFLICT, "F002", "파일의 상태가 일치하지 않거나 올바르지 못합니다."), // 기타 에러 diff --git a/src/main/java/com/creatorhub/constant/ThumbnailKeys.java b/src/main/java/com/creatorhub/constant/ThumbnailKeys.java index 5a8710e..92dbe31 100644 --- a/src/main/java/com/creatorhub/constant/ThumbnailKeys.java +++ b/src/main/java/com/creatorhub/constant/ThumbnailKeys.java @@ -22,6 +22,15 @@ public final class ThumbnailKeys { "_218x120.jpg" ); + // 회차 썸네일 + public static final String EPISODE_SUFFIX = "_202x120.jpg"; + + // 회차 SNS 전용 썸네일 + public static final String SNS_SUFFIX = "_600x315.jpg"; + + // 원고 전용 썸네일 + public static final String MANUSCRIPT_SUFFIX = ".jpg"; + public static List allSuffixes() { List all = new ArrayList<>(); all.add(HORIZONTAL_SUFFIX); diff --git a/src/main/java/com/creatorhub/constant/ThumbnailType.java b/src/main/java/com/creatorhub/constant/ThumbnailType.java new file mode 100644 index 0000000..e9e6b0c --- /dev/null +++ b/src/main/java/com/creatorhub/constant/ThumbnailType.java @@ -0,0 +1,6 @@ +package com.creatorhub.constant; + +public interface ThumbnailType { + String resolveSuffix(); +} + diff --git a/src/main/java/com/creatorhub/controller/CreationController.java b/src/main/java/com/creatorhub/controller/CreationController.java index 59cc263..da4a892 100644 --- a/src/main/java/com/creatorhub/controller/CreationController.java +++ b/src/main/java/com/creatorhub/controller/CreationController.java @@ -21,7 +21,7 @@ public class CreationController { /** * 작품등록 */ - @PostMapping("create") + @PostMapping("/create") public ResponseEntity> createCreation(@Valid @RequestBody CreationRequest req) { log.info("작품등록 요청 - creatorId={}, title={}, isPublic={}", req.creatorId(), diff --git a/src/main/java/com/creatorhub/controller/EpisodeController.java b/src/main/java/com/creatorhub/controller/EpisodeController.java new file mode 100644 index 0000000..ba7c74d --- /dev/null +++ b/src/main/java/com/creatorhub/controller/EpisodeController.java @@ -0,0 +1,50 @@ +package com.creatorhub.controller; + +import com.creatorhub.dto.EpisodeRequest; +import com.creatorhub.dto.EpisodeResponse; +import com.creatorhub.security.auth.CustomUserPrincipal; +import com.creatorhub.service.EpisodeService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/episodes") +@RequiredArgsConstructor +@Slf4j +public class EpisodeController { + + private final EpisodeService episodeService; + + /** + * 회차 등록 + 원고/썸네일 매핑 insert + */ + @PreAuthorize("hasRole('ROLE_CREATOR')") + @PostMapping("/create") + public ResponseEntity publishEpisode( + @Valid @RequestBody EpisodeRequest req, + @AuthenticationPrincipal CustomUserPrincipal principal + ) { + + log.info("회차 등록 요청 - memberId={}, creationId={}, episodeNum={}, manuscripts={}, episodeFileObjectId={}, snsFileObjectId={}, isPublic={}, isCommentEnabled={}", + principal.id(), + req.creationId(), + req.episodeNum(), + req.manuscripts().size(), + req.episodeFileObjectId(), + req.snsFileObjectId(), + req.isPublic(), + req.isCommentEnabled() + ); + + EpisodeResponse res = episodeService.publishEpisode(req, principal.id()); + return ResponseEntity.ok(res); + } +} diff --git a/src/main/java/com/creatorhub/controller/FileUploadController.java b/src/main/java/com/creatorhub/controller/FileUploadController.java index b7d4366..622d1cb 100644 --- a/src/main/java/com/creatorhub/controller/FileUploadController.java +++ b/src/main/java/com/creatorhub/controller/FileUploadController.java @@ -1,15 +1,17 @@ package com.creatorhub.controller; import com.creatorhub.dto.FileObjectResponse; -import com.creatorhub.dto.S3PresignedUrlRequest; -import com.creatorhub.dto.S3PresignedUrlResponse; +import com.creatorhub.dto.s3.*; import com.creatorhub.service.FileObjectService; import com.creatorhub.service.s3.S3PresignedUploadService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/files") @@ -20,24 +22,64 @@ public class FileUploadController { private final FileObjectService fileObjectService; /** - * presigned url 요청 + * 작품 썸네일 presigned url 요청 */ - @PostMapping("/presigned-url") - public S3PresignedUrlResponse createPresignedUrl(@RequestBody S3PresignedUrlRequest req) { - log.info("Presigned PUT 요청 - contentType={}, thumbnailType={}, originalFilename={}", + @PostMapping("/creation-thumbnails/presigned") + public ThumbnailPresignedUrlResponse createCreationThumbnailPresignedUrl(@RequestBody CreationThumbnailPresignedRequest req) { + log.info("작품 썸네일 Presigned PUT 요청 - contentType={}, thumbnailType={}, originalFilename={}", req.contentType(), req.thumbnailType(), req.originalFilename()); return uploadService.generatePresignedPutUrl(req); } /** - * fileObject 작품등록시 이미지 상태 변경(INIT -> READY) + * 회차 썸네일 presigned url 요청 */ - @PostMapping("/{fileObjectId}/uploaded") - public void complete(@PathVariable Long fileObjectId) { - fileObjectService.markReady(fileObjectId); + @PostMapping("/episode-thumbnails/presigned") + public ThumbnailPresignedUrlResponse createEpisodeThumbnailPresignedUrl(@RequestBody EpisodeThumbnailPresignedRequest req) { + log.info("회차 썸네일 Presigned PUT 요청 - contentType={}, thumbnailType={}, originalFilename={}", + req.contentType(), req.thumbnailType(), req.originalFilename()); + + return uploadService.generatePresignedPutUrl(req); + } + + /** + * 원고 presigned url 요청 + */ + @PostMapping("/manuscripts/presigned") + public ManuscriptPresignedResponse createManuscriptPresignedUrls( + @Valid @RequestBody ManuscriptPresignedRequest req + ) { + // contentType 요약 + Map contentTypeSummary = req.files().stream() + .collect(Collectors.groupingBy( + ManuscriptFileRequest::contentType, + Collectors.counting() + )); + + log.info("원고 Presigned PUT 요청 - creationId={}, count={}, contentTypes={}", + req.creationId(), req.files().size(), contentTypeSummary); + + return uploadService.generateManuscriptPresignedUrls(req); } + /** + * fileObject 썸네일 이미지 상태 변경(INIT -> READY) + */ + @PostMapping("/{fileObjectId}/thumbnails/ready") + public void markThumbnailReady(@PathVariable Long fileObjectId) { + fileObjectService.markThumbnailReady(fileObjectId); + } + + /** + * fileObject 원고 이미지 상태 변경(INIT -> READY) + */ + @PostMapping("/manuscripts/ready") + public void markManuscriptsReady(@RequestBody @Valid ManuscriptReadyRequest req) { + fileObjectService.markManuscriptsReady(req.fileObjectIds()); + } + + /** * fileObject 작품등록시 가로 리사이징 이미지 업로드 상태 확인(폴링용) & file_object insert */ diff --git a/src/main/java/com/creatorhub/dto/EpisodeRequest.java b/src/main/java/com/creatorhub/dto/EpisodeRequest.java new file mode 100644 index 0000000..dc96c80 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/EpisodeRequest.java @@ -0,0 +1,37 @@ +package com.creatorhub.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + +import java.util.List; + +public record EpisodeRequest( + @NotNull(message = "creationId가 존재하지 않습니다.") + Long creationId, + + @NotNull(message = "episodeNum이 존재하지 않습니다.") + @Min(value = 1, message = "episodeNum은 1 이상이어야 합니다.") + Integer episodeNum, + + @NotBlank(message = "title이 존재하지 않습니다.") + @Size(max = 35, message = "title은 35자 이하여야 합니다.") + String title, + + @NotBlank(message = "creatorNote가 존재하지 않습니다.") + @Size(max = 100, message = "creatorNote는 100자 이하여야 합니다.") + String creatorNote, + + Boolean isCommentEnabled, + Boolean isPublic, + + @NotNull(message = "회차 썸네일의 FileObjectId가 존재하지 않습니다.") + Long episodeFileObjectId, + + @NotNull(message = "sns 썸네일의 FileObjectId가 존재하지 않습니다.") + Long snsFileObjectId, + + @NotEmpty(message = "원고 파일 목록이 비어있습니다.") + @Size(max = 50, message = "원고는 50장 이하여야 합니다.") // 정책값 + List<@Valid ManuscriptRegisterItem> manuscripts + + ) { } \ No newline at end of file diff --git a/src/main/java/com/creatorhub/dto/EpisodeResponse.java b/src/main/java/com/creatorhub/dto/EpisodeResponse.java new file mode 100644 index 0000000..1930d61 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/EpisodeResponse.java @@ -0,0 +1,11 @@ +package com.creatorhub.dto; + +import java.util.List; + +public record EpisodeResponse( + Long episodeId, + Integer episodeNum, + List manuscriptImageIds, + List episodeThumbnailIds +) { } + diff --git a/src/main/java/com/creatorhub/dto/ManuscriptRegisterItem.java b/src/main/java/com/creatorhub/dto/ManuscriptRegisterItem.java new file mode 100644 index 0000000..64abf70 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/ManuscriptRegisterItem.java @@ -0,0 +1,12 @@ +package com.creatorhub.dto; + +import jakarta.validation.constraints.*; + +public record ManuscriptRegisterItem( + @NotNull(message = "fileObjectId가 존재하지 않습니다.") + Long fileObjectId, + + @NotNull(message = "displayOrder가 존재하지 않습니다.") + @Min(value = 1, message = "displayOrder는 1 이상이어야 합니다.") + Integer displayOrder +) { } diff --git a/src/main/java/com/creatorhub/dto/S3PresignedUrlRequest.java b/src/main/java/com/creatorhub/dto/S3PresignedUrlRequest.java deleted file mode 100644 index 3c9574b..0000000 --- a/src/main/java/com/creatorhub/dto/S3PresignedUrlRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.creatorhub.dto; - -import com.creatorhub.constant.CreationThumbnailType; - -public record S3PresignedUrlRequest( - String contentType, - CreationThumbnailType thumbnailType, - String originalFilename -) { } diff --git a/src/main/java/com/creatorhub/dto/s3/CreationThumbnailPresignedRequest.java b/src/main/java/com/creatorhub/dto/s3/CreationThumbnailPresignedRequest.java new file mode 100644 index 0000000..20abc89 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/CreationThumbnailPresignedRequest.java @@ -0,0 +1,23 @@ +package com.creatorhub.dto.s3; + +import com.creatorhub.constant.CreationThumbnailType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreationThumbnailPresignedRequest( + + @NotBlank(message = "콘텐츠 타입이 존재하지 않습니다.") + String contentType, + + @NotNull(message = "썸네일 타입이 존재하지 않습니다.") + CreationThumbnailType thumbnailType, + + @NotBlank(message = "원본 파일명이 존재하지 않습니다.") + String originalFilename + + ) implements PresignedPutRequest { + @Override public String resolveSuffix() { + return thumbnailType.resolveSuffix(); + } +} + diff --git a/src/main/java/com/creatorhub/dto/s3/EpisodeThumbnailPresignedRequest.java b/src/main/java/com/creatorhub/dto/s3/EpisodeThumbnailPresignedRequest.java new file mode 100644 index 0000000..dbff217 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/EpisodeThumbnailPresignedRequest.java @@ -0,0 +1,24 @@ +package com.creatorhub.dto.s3; + +import com.creatorhub.constant.CreationThumbnailType; +import com.creatorhub.constant.EpisodeThumbnailType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record EpisodeThumbnailPresignedRequest( + + @NotBlank(message = "콘텐츠 타입이 존재하지 않습니다.") + String contentType, + + @NotNull(message = "썸네일 타입이 존재하지 않습니다.") + EpisodeThumbnailType thumbnailType, + + @NotBlank(message = "원본 파일명이 존재하지 않습니다.") + String originalFilename + +) implements PresignedPutRequest { + @Override public String resolveSuffix() { + return thumbnailType.resolveSuffix(); + } +} + diff --git a/src/main/java/com/creatorhub/dto/s3/ManuscriptFileRequest.java b/src/main/java/com/creatorhub/dto/s3/ManuscriptFileRequest.java new file mode 100644 index 0000000..9b57224 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/ManuscriptFileRequest.java @@ -0,0 +1,17 @@ +package com.creatorhub.dto.s3; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ManuscriptFileRequest ( + @NotNull(message = "displayOrder가 존재하지 않습니다.") + @Min(value = 1, message = "displayOrder는 1 이상이어야 합니다.") + Integer displayOrder, + + @NotBlank(message = "콘텐츠 타입이 존재하지 않습니다.") + String contentType, + + @NotBlank(message = "원본 파일명이 존재하지 않습니다.") + String originalFilename +){ } \ No newline at end of file diff --git a/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedRequest.java b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedRequest.java new file mode 100644 index 0000000..472928d --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedRequest.java @@ -0,0 +1,17 @@ +package com.creatorhub.dto.s3; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +public record ManuscriptPresignedRequest( + + @NotNull(message = "creatorId가 존재하지 않습니다.") + Long creationId, + + @NotEmpty(message = "원고 파일 목록이 비어있습니다.") + @Size(max = 50, message = "원고는 50장 이하여야 합니다.") + List<@Valid ManuscriptFileRequest> files + +) { } + diff --git a/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedResponse.java b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedResponse.java new file mode 100644 index 0000000..b6f271f --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedResponse.java @@ -0,0 +1,8 @@ +package com.creatorhub.dto.s3; + + +import java.util.List; + +public record ManuscriptPresignedResponse( + List items +) {} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedUrlResponse.java b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedUrlResponse.java new file mode 100644 index 0000000..b41cc03 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/ManuscriptPresignedUrlResponse.java @@ -0,0 +1,8 @@ +package com.creatorhub.dto.s3; + +public record ManuscriptPresignedUrlResponse( + int order, + Long fileObjectId, + String presignedUrl, + String storageKey +) {} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/dto/s3/ManuscriptReadyRequest.java b/src/main/java/com/creatorhub/dto/s3/ManuscriptReadyRequest.java new file mode 100644 index 0000000..2a80ef7 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/ManuscriptReadyRequest.java @@ -0,0 +1,11 @@ +package com.creatorhub.dto.s3; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record ManuscriptReadyRequest( + @NotEmpty(message = "fileObjectIds가 비어있습니다.") + @Size(max = 50, message = "fileObjectIds는 50개 이하여야 합니다.") + List fileObjectIds +) {} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/dto/s3/PresignedPutRequest.java b/src/main/java/com/creatorhub/dto/s3/PresignedPutRequest.java new file mode 100644 index 0000000..41c1c97 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/s3/PresignedPutRequest.java @@ -0,0 +1,8 @@ +package com.creatorhub.dto.s3; + +public interface PresignedPutRequest { + String contentType(); + String originalFilename(); + String resolveSuffix(); // 업로드 키에 붙을 suffix +} + diff --git a/src/main/java/com/creatorhub/dto/S3PresignedUrlResponse.java b/src/main/java/com/creatorhub/dto/s3/ThumbnailPresignedUrlResponse.java similarity index 52% rename from src/main/java/com/creatorhub/dto/S3PresignedUrlResponse.java rename to src/main/java/com/creatorhub/dto/s3/ThumbnailPresignedUrlResponse.java index 9794cdf..2d52b43 100644 --- a/src/main/java/com/creatorhub/dto/S3PresignedUrlResponse.java +++ b/src/main/java/com/creatorhub/dto/s3/ThumbnailPresignedUrlResponse.java @@ -1,6 +1,6 @@ -package com.creatorhub.dto; +package com.creatorhub.dto.s3; -public record S3PresignedUrlResponse ( +public record ThumbnailPresignedUrlResponse( Long fileObjectId, String uploadUrl, String objectKey diff --git a/src/main/java/com/creatorhub/entity/CreationThumbnail.java b/src/main/java/com/creatorhub/entity/CreationThumbnail.java index 1d8caad..1dc58b6 100644 --- a/src/main/java/com/creatorhub/entity/CreationThumbnail.java +++ b/src/main/java/com/creatorhub/entity/CreationThumbnail.java @@ -29,7 +29,6 @@ public class CreationThumbnail extends BaseTimeEntity { @JoinColumn(name = "creation_id", nullable = false) private Creation creation; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "file_object_id", nullable = false) private FileObject fileObject; @@ -39,7 +38,7 @@ public class CreationThumbnail extends BaseTimeEntity { private CreationThumbnailType type; @Column(name = "display_order", nullable = false) - private short displayOrder; + private Integer displayOrder; // self FK @ManyToOne(fetch = FetchType.LAZY) @@ -50,7 +49,7 @@ public class CreationThumbnail extends BaseTimeEntity { private CreationThumbnail(Creation creation, FileObject fileObject, CreationThumbnailType type, - short displayOrder, + Integer displayOrder, CreationThumbnail sourceImage) { this.creation = creation; this.fileObject = fileObject; @@ -62,7 +61,7 @@ private CreationThumbnail(Creation creation, public static CreationThumbnail create(Creation creation, FileObject fileObject, CreationThumbnailType type, - short displayOrder, + Integer displayOrder, CreationThumbnail sourceImage) { return CreationThumbnail.builder() .creation(creation) diff --git a/src/main/java/com/creatorhub/entity/Creator.java b/src/main/java/com/creatorhub/entity/Creator.java index 66886aa..d7cf90f 100644 --- a/src/main/java/com/creatorhub/entity/Creator.java +++ b/src/main/java/com/creatorhub/entity/Creator.java @@ -45,12 +45,10 @@ private Creator(Member member, } public static Creator createCreator(Member member, -// FileObject fileObject, String creatorName, String introduction) { return Creator.builder() .member(member) -// .fileObject(fileObject) .creatorName((creatorName != null) ? creatorName : member.getName()) // 필명이 없을시 기존 회원가입 name 사용 .introduction(introduction) .build(); diff --git a/src/main/java/com/creatorhub/entity/Episode.java b/src/main/java/com/creatorhub/entity/Episode.java new file mode 100644 index 0000000..5e0d6cc --- /dev/null +++ b/src/main/java/com/creatorhub/entity/Episode.java @@ -0,0 +1,119 @@ +package com.creatorhub.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table( + name = "episode", + indexes = { + @Index(name = "idx_episode_creation_id", columnList = "creation_id"), + @Index(name = "idx_episode_creation_episode_num", columnList = "creation_id, episode_num") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE episode SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class Episode extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creation_id", nullable = false) + private Creation creation; + + @Column(nullable = false) + private Integer episodeNum; + + @Column(length = 35, nullable = false) + private String title; + + @Column(length = 100, nullable = false) + private String creatorNote; + + @Column(nullable = false) + private boolean isCommentEnabled; + + @Column(nullable = false) + private boolean isPublic; + + @Column + private Integer likeCount; + + @Column + private Integer favoriteCount; + + @Column(precision = 3, scale = 2) + private BigDecimal ratingAverage; + + @OneToMany(mappedBy = "episode", cascade = CascadeType.ALL, orphanRemoval = true) + private final List manuscriptImages = new ArrayList<>(); + + @OneToMany(mappedBy = "episode", cascade = CascadeType.ALL, orphanRemoval = true) + private final List episodeThumbnails = new ArrayList<>(); + + @Builder(access = AccessLevel.PRIVATE) + private Episode(Creation creation, + Integer episodeNum, + String title, + String creatorNote, + boolean isCommentEnabled, + boolean isPublic) { + this.creation = creation; + this.episodeNum = episodeNum; + this.title = title; + this.creatorNote = creatorNote; + this.isCommentEnabled = isCommentEnabled; + this.isPublic = isPublic; + this.likeCount = 0; + this.favoriteCount = 0; + this.ratingAverage = new BigDecimal(0); + } + + public static Episode create(Creation creation, + Integer episodeNum, + String title, + String creatorNote, + Boolean isCommentEnabled, + Boolean isPublic) { + return Episode.builder() + .creation(creation) + .episodeNum(episodeNum) + .title(title) + .creatorNote(creatorNote) + .isCommentEnabled(isCommentEnabled != null && isCommentEnabled) + .isPublic(isPublic != null && isPublic) + .build(); + } + + public void publish() { this.isPublic = true; } + public void unpublish() { this.isPublic = false; } + + public void enableComments() { this.isCommentEnabled = true; } + public void disableComments() { this.isCommentEnabled = false; } + + public void changeTitle(String title) { this.title = title; } + public void changeCreatorNote(String note) { this.creatorNote = note; } + + public void addManuscriptImage(ManuscriptImage image) { + this.manuscriptImages.add(image); + image.changeEpisode(this); + } + + public void addThumbnail(EpisodeThumbnail thumbnail) { + this.episodeThumbnails.add(thumbnail); + thumbnail.changeEpisode(this); + } +} diff --git a/src/main/java/com/creatorhub/entity/EpisodeThumbnail.java b/src/main/java/com/creatorhub/entity/EpisodeThumbnail.java new file mode 100644 index 0000000..a24959f --- /dev/null +++ b/src/main/java/com/creatorhub/entity/EpisodeThumbnail.java @@ -0,0 +1,76 @@ +package com.creatorhub.entity; + +import com.creatorhub.constant.EpisodeThumbnailType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table( + name = "episode_thumbnail", + uniqueConstraints = @UniqueConstraint( + name = "uk_episode_thumbnail_episode_type", + columnNames = {"episode_id", "type"} + ), + indexes = { + @Index(name = "idx_episode_thumbnail_episode_id", columnList = "episode_id"), + @Index(name = "idx_episode_thumbnail_file_object_id", columnList = "file_object_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE episode_thumbnail SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class EpisodeThumbnail extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_object_id", nullable = false) + private FileObject fileObject; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "episode_id", nullable = false) + private Episode episode; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30, columnDefinition = "VARCHAR(30)") + private EpisodeThumbnailType type; + + @Builder(access = AccessLevel.PRIVATE) + private EpisodeThumbnail(FileObject fileObject, + Episode episode, + EpisodeThumbnailType type) { + this.fileObject = fileObject; + this.episode = episode; + this.type = type; + } + + public static EpisodeThumbnail create(FileObject fileObject, + Episode episode, + EpisodeThumbnailType type) { + return EpisodeThumbnail.builder() + .fileObject(fileObject) + .episode(episode) + .type(type) + .build(); + } + + public void changeEpisode(Episode episode) { + this.episode = episode; + } + + public void changeFileObject(FileObject fileObject) { + this.fileObject = fileObject; + } + + public void changeType(EpisodeThumbnailType type) { + this.type = type; + } +} diff --git a/src/main/java/com/creatorhub/entity/ManuscriptImage.java b/src/main/java/com/creatorhub/entity/ManuscriptImage.java new file mode 100644 index 0000000..fb6fa06 --- /dev/null +++ b/src/main/java/com/creatorhub/entity/ManuscriptImage.java @@ -0,0 +1,74 @@ +package com.creatorhub.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table( + name = "manuscript_image", + uniqueConstraints = @UniqueConstraint( + name = "uk_manuscript_image_episode_display_order", + columnNames = {"episode_id", "display_order"} + ), + indexes = { + @Index(name = "idx_manuscript_image_episode_id", columnList = "episode_id"), + @Index(name = "idx_manuscript_image_file_object_id", columnList = "file_object_id") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE manuscript_image SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class ManuscriptImage extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_object_id", nullable = false) + private FileObject fileObject; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "episode_id", nullable = false) + private Episode episode; + + @Column(nullable = false) + private Integer displayOrder; + + @Builder(access = AccessLevel.PRIVATE) + private ManuscriptImage(FileObject fileObject, + Episode episode, + Integer displayOrder) { + this.fileObject = fileObject; + this.episode = episode; + this.displayOrder = displayOrder; + } + + public static ManuscriptImage create(FileObject fileObject, + Episode episode, + Integer displayOrder) { + return ManuscriptImage.builder() + .fileObject(fileObject) + .episode(episode) + .displayOrder(displayOrder) + .build(); + } + + public void changeEpisode(Episode episode) { + this.episode = episode; + } + + public void changeFileObject(FileObject fileObject) { + this.fileObject = fileObject; + } + + public void changeDisplayOrder(Integer displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/creatorhub/repository/EpisodeRepository.java b/src/main/java/com/creatorhub/repository/EpisodeRepository.java new file mode 100644 index 0000000..ea05c35 --- /dev/null +++ b/src/main/java/com/creatorhub/repository/EpisodeRepository.java @@ -0,0 +1,8 @@ +package com.creatorhub.repository; + +import com.creatorhub.entity.Episode; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EpisodeRepository extends JpaRepository { + boolean existsByCreationIdAndEpisodeNum(Long creationId, Integer episodeNum); +} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/repository/EpisodeThumbnailRepository.java b/src/main/java/com/creatorhub/repository/EpisodeThumbnailRepository.java new file mode 100644 index 0000000..cf8b0b7 --- /dev/null +++ b/src/main/java/com/creatorhub/repository/EpisodeThumbnailRepository.java @@ -0,0 +1,7 @@ +package com.creatorhub.repository; + +import com.creatorhub.entity.EpisodeThumbnail; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EpisodeThumbnailRepository extends JpaRepository { +} diff --git a/src/main/java/com/creatorhub/repository/ManuscriptImageRepository.java b/src/main/java/com/creatorhub/repository/ManuscriptImageRepository.java new file mode 100644 index 0000000..b907a03 --- /dev/null +++ b/src/main/java/com/creatorhub/repository/ManuscriptImageRepository.java @@ -0,0 +1,7 @@ +package com.creatorhub.repository; + +import com.creatorhub.entity.ManuscriptImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ManuscriptImageRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/service/CreationService.java b/src/main/java/com/creatorhub/service/CreationService.java index c3670df..14aaf0e 100644 --- a/src/main/java/com/creatorhub/service/CreationService.java +++ b/src/main/java/com/creatorhub/service/CreationService.java @@ -89,7 +89,7 @@ public Long createCreation(CreationRequest req) { creation, posterOriginal, CreationThumbnailType.POSTER, - (short) 0, + 0, null ); creation.addThumbnail(posterOriginalThumb); @@ -99,13 +99,13 @@ public Long createCreation(CreationRequest req) { creation, byKey.get(baseKey + ThumbnailKeys.HORIZONTAL_SUFFIX), CreationThumbnailType.HORIZONTAL, - (short) 0, + 0, null ); creation.addThumbnail(horizontalOriginalThumb); // 리사이징 이미지 6개 - displayOrder = 1..6, sourceImage = horizontalOriginalThumb - short order = 1; + int order = 1; for (String suffix : ThumbnailKeys.DERIVED_SUFFIXES) { FileObject derivedFo = byKey.get(baseKey + suffix); diff --git a/src/main/java/com/creatorhub/service/EpisodeService.java b/src/main/java/com/creatorhub/service/EpisodeService.java new file mode 100644 index 0000000..a388349 --- /dev/null +++ b/src/main/java/com/creatorhub/service/EpisodeService.java @@ -0,0 +1,93 @@ +package com.creatorhub.service; + +import com.creatorhub.dto.EpisodeRequest; +import com.creatorhub.dto.EpisodeResponse; +import com.creatorhub.entity.*; +import com.creatorhub.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class EpisodeService { + private final CreationRepository creationRepository; + private final EpisodeRepository episodeRepository; + private final FileObjectRepository fileObjectRepository; + private final ManuscriptImageService manuscriptImageService; + private final EpisodeThumbnailService episodeThumbnailService; + + @Transactional + public EpisodeResponse publishEpisode(EpisodeRequest req, Long memberId) { + + // 1. Creation 조회 + Creation creation = creationRepository.findById(req.creationId()) + .orElseThrow(() -> new IllegalArgumentException("해당 Creation을 찾을 수 없습니다: " + req.creationId())); + + // 2. 인가 체크(로그인한 사용자가 해당 작품의 작가인지 확인) + Long ownerMemberId = creation.getCreator().getMember().getId(); + if (!ownerMemberId.equals(memberId)) { + throw new AccessDeniedException("해당 작품에 대한 회차 등록 권한이 없습니다."); + } + + // 3. episodeNum 중복 체크(회차) + if (episodeRepository.existsByCreationIdAndEpisodeNum(req.creationId(), req.episodeNum())) { + throw new IllegalArgumentException("이미 존재하는 회차 번호입니다. creationId=" + req.creationId() + ", episodeNum=" + req.episodeNum()); + } + + // 4. 원고 displayOrder 중복 체크 + manuscriptImageService.validateDisplayOrders(req.manuscripts()); + + // 5. fileObjectId 한번에 조회 (에피소드 '썸네일 + sns 썸네일 + 원고 이미지') + Set allFileObjectIds = new HashSet<>(); // 중복제거 + req.manuscripts().forEach(m -> allFileObjectIds.add(m.fileObjectId())); + allFileObjectIds.add(req.episodeFileObjectId()); + allFileObjectIds.add(req.snsFileObjectId()); + + Map fileObjectMap = findAllFileObjectsOrThrow(allFileObjectIds); + + + // 6. episode 생성/저장 + Episode episode = Episode.create( + creation, + req.episodeNum(), + req.title(), + req.creatorNote(), + req.isCommentEnabled(), + req.isPublic() + ); + Episode savedEpisode = episodeRepository.save(episode); + + // 7. manuscript_image 생성/저장 + List savedManuscripts = manuscriptImageService.createAndSave(req.manuscripts(), episode, fileObjectMap); + + // 8. episode_thumbnail 생성/저장 + List savedThumbnails = episodeThumbnailService.createAndSave(req, episode, fileObjectMap); + + // 9. 응답 + return new EpisodeResponse( + savedEpisode.getId(), + savedEpisode.getEpisodeNum(), + savedManuscripts.stream().map(ManuscriptImage::getId).toList(), + savedThumbnails.stream().map(EpisodeThumbnail::getId).toList() + ); + } + + + private Map findAllFileObjectsOrThrow(Set ids) { + List found = fileObjectRepository.findAllById(ids); + + if (found.size() != ids.size()) { + Set foundIds = found.stream().map(FileObject::getId).collect(Collectors.toSet()); + List missing = ids.stream().filter(id -> !foundIds.contains(id)).sorted().toList(); + throw new IllegalArgumentException("존재하지 않는 fileObjectId가 있습니다: " + missing); + } + + return found.stream().collect(Collectors.toMap(FileObject::getId, fo -> fo)); + } + +} diff --git a/src/main/java/com/creatorhub/service/EpisodeThumbnailService.java b/src/main/java/com/creatorhub/service/EpisodeThumbnailService.java new file mode 100644 index 0000000..b49b2e7 --- /dev/null +++ b/src/main/java/com/creatorhub/service/EpisodeThumbnailService.java @@ -0,0 +1,33 @@ +package com.creatorhub.service; + +import com.creatorhub.constant.EpisodeThumbnailType; +import com.creatorhub.dto.EpisodeRequest; +import com.creatorhub.entity.Episode; +import com.creatorhub.entity.EpisodeThumbnail; +import com.creatorhub.entity.FileObject; +import com.creatorhub.repository.EpisodeThumbnailRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class EpisodeThumbnailService { + private final EpisodeThumbnailRepository episodeThumbnailRepository; + + public List createAndSave( + EpisodeRequest req, + Episode episode, + Map fileObjectMap + ) { + FileObject episodeThumbFo = fileObjectMap.get(req.episodeFileObjectId()); + FileObject snsThumbFo = fileObjectMap.get(req.snsFileObjectId()); + + return episodeThumbnailRepository.saveAll(List.of( + EpisodeThumbnail.create(episodeThumbFo, episode, EpisodeThumbnailType.EPISODE), + EpisodeThumbnail.create(snsThumbFo, episode, EpisodeThumbnailType.SNS) + )); + } +} diff --git a/src/main/java/com/creatorhub/service/FileObjectService.java b/src/main/java/com/creatorhub/service/FileObjectService.java index 7b7e6de..4b5d8ef 100644 --- a/src/main/java/com/creatorhub/service/FileObjectService.java +++ b/src/main/java/com/creatorhub/service/FileObjectService.java @@ -24,10 +24,10 @@ public class FileObjectService { private final ImageProcessingChecker checker; /** - * status 변경(UPLOADED) + * 썸네일 status 변경(Ready), 사이즈 insert */ @Transactional // 더티체킹 - public void markReady(Long fileObjectId) { + public void markThumbnailReady(Long fileObjectId) { FileObject fo = fileObjectRepository.findById(fileObjectId) .orElseThrow(() -> new FileObjectNotFoundException("해당 FileObject를 찾을 수 없습니다: " + fileObjectId)); @@ -37,6 +37,37 @@ public void markReady(Long fileObjectId) { fo.markSize(originalSize); } + /** + * 원고 status 변경(Ready), 사이즈 insert + */ + @Transactional + public void markManuscriptsReady(List fileObjectIds) { + + // 1. 중복 제거 + List distinctIds = fileObjectIds.stream().distinct().toList(); + + // 2. 한번에 조회 + List fileObjects = fileObjectRepository.findAllById(fileObjectIds); + + // 3. 존재하지 않는 id 체크 + if (fileObjects.size() != distinctIds.size()) { + java.util.Set found = fileObjects.stream().map(FileObject::getId).collect(java.util.stream.Collectors.toSet()); + List missing = distinctIds.stream().filter(id -> !found.contains(id)).toList(); + throw new FileObjectNotFoundException("해당 FileObject를 찾을 수 없습니다: " + missing); + } + + // 4. 상태/사이즈 처리 + // checker.fetchSize가 S3 HEAD라면 네트워크 N번 호출됨(원고 50장이면 50번) + // 지금은 최대 50이니까 허용 가능. 나중에 최적화(배치 HEAD or S3 inventory/메타) 고려. + for (FileObject fo : fileObjects) { + long size = checker.fetchSize(fo.getStorageKey()); + + fo.markReady(); + fo.markSize(size); + } + } + + @Transactional // 더티체킹 public List checkAndGetStatus(Long fileObjectId) { FileObject original = fileObjectRepository.findById(fileObjectId) @@ -58,38 +89,40 @@ public List checkAndGetStatus(Long fileObjectId) { Map existingMap = existing.stream() .collect(Collectors.toMap(FileObject::getStorageKey, fo -> fo)); - List toInsert = new ArrayList<>(); - // 3. 6개 모두 없으면 insert 있으면 update - for (String key : derivedKeys) { - long sizeBytes = sizeByKey.getOrDefault(key, 0L); + // 3. 6개 file_object 없으면 insert 있으면 update - boolean isMissing = missingSet.contains(key); - FileObjectStatus status = isMissing ? FileObjectStatus.FAILED : FileObjectStatus.READY; + // 3-1. 있으면 update + derivedKeys.stream() + .map(existingMap::get) + .filter(Objects::nonNull) + .forEach(fo -> { + String key = fo.getStorageKey(); + long sizeBytes = sizeByKey.getOrDefault(key, 0L); - FileObject fo = existingMap.get(key); + if (missingSet.contains(key)) fo.markFailed(); + else fo.markReady(); - if (fo == null) { - // 없으면 insert - toInsert.add( - FileObject.create( - key, - original.getOriginalFilename(), - status, - original.getContentType(), - sizeBytes - ) - ); - } else { - // 있으면 update - if (status == FileObjectStatus.READY) fo.markReady(); - else fo.markFailed(); + fo.markSize(sizeBytes); + }); - fo.markSize(sizeBytes); - } - } - // 4. 동시 폴링 대비: UNIQUE(storage_key) 기준으로 중복 insert는 무시 + // 3-2. 없는 것들은 toInsert 생성 + List toInsert = derivedKeys.stream() + .filter(key -> !missingSet.contains(key)) + .map(key -> { + long sizeBytes = sizeByKey.getOrDefault(key, 0L); + + return FileObject.create( + key, + original.getOriginalFilename(), + FileObjectStatus.READY, + original.getContentType(), + sizeBytes + ); + }) + .toList(); + if (!toInsert.isEmpty()) { try { fileObjectRepository.saveAll(toInsert); diff --git a/src/main/java/com/creatorhub/service/ManuscriptImageService.java b/src/main/java/com/creatorhub/service/ManuscriptImageService.java new file mode 100644 index 0000000..0f1d042 --- /dev/null +++ b/src/main/java/com/creatorhub/service/ManuscriptImageService.java @@ -0,0 +1,45 @@ +package com.creatorhub.service; + +import com.creatorhub.dto.ManuscriptRegisterItem; +import com.creatorhub.entity.Episode; +import com.creatorhub.entity.FileObject; +import com.creatorhub.entity.ManuscriptImage; +import com.creatorhub.repository.ManuscriptImageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class ManuscriptImageService { + private final ManuscriptImageRepository manuscriptImageRepository; + + public void validateDisplayOrders(List manuscripts) { + Set seen = new HashSet<>(); + for (ManuscriptRegisterItem m : manuscripts) { + if (!seen.add(m.displayOrder())) { + throw new IllegalArgumentException("중복된 displayOrder가 있습니다: " + m.displayOrder()); + } + } + } + + public List createAndSave( + List manuscripts, + Episode episode, + Map fileObjectMap + ) { + List images = manuscripts.stream() + .map(m -> { + FileObject fo = fileObjectMap.get(m.fileObjectId()); + int order = m.displayOrder(); + return ManuscriptImage.create(fo, episode, order); + }) + .toList(); + + return manuscriptImageRepository.saveAll(images); + } +} diff --git a/src/main/java/com/creatorhub/service/s3/S3PresignedUploadService.java b/src/main/java/com/creatorhub/service/s3/S3PresignedUploadService.java index 80099f9..09b19e3 100644 --- a/src/main/java/com/creatorhub/service/s3/S3PresignedUploadService.java +++ b/src/main/java/com/creatorhub/service/s3/S3PresignedUploadService.java @@ -1,12 +1,11 @@ package com.creatorhub.service.s3; -import com.creatorhub.constant.CreationThumbnailType; import com.creatorhub.constant.FileObjectStatus; import com.creatorhub.constant.ThumbnailKeys; -import com.creatorhub.dto.S3PresignedUrlRequest; -import com.creatorhub.dto.S3PresignedUrlResponse; +import com.creatorhub.dto.s3.*; import com.creatorhub.entity.FileObject; import com.creatorhub.repository.FileObjectRepository; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; @@ -16,73 +15,158 @@ import java.time.Duration; import java.time.LocalDate; import java.time.format.DateTimeFormatter; -import java.util.UUID; +import java.util.*; @Service public class S3PresignedUploadService { - private static final String BUCKET_NAME = "creatorhub-dev-bucket"; - private final S3Presigner presigner; private final FileObjectRepository fileObjectRepository; + private final String bucket; public S3PresignedUploadService(S3Presigner presigner, - FileObjectRepository fileObjectRepository) { + FileObjectRepository fileObjectRepository, + @Value("${cloud.aws.s3.bucket}") String bucket) { this.presigner = presigner; this.fileObjectRepository = fileObjectRepository; + this.bucket = bucket; } - public S3PresignedUrlResponse generatePresignedPutUrl(S3PresignedUrlRequest req) { + public ThumbnailPresignedUrlResponse generatePresignedPutUrl(PresignedPutRequest req) { - // 1. 검증 (지금은 jpeg 고정 정책) - if (!"image/jpeg".equals(req.contentType())) { - throw new IllegalArgumentException("image/jpeg 타입만 허용 가능합니다."); - } + // 1. contentType 검증 (지금은 jpeg 고정 정책) + validateContentType(req.contentType()); // 2. storageKey 생성 - String storageKey = createObjectKey(req.thumbnailType()); + String storageKey = createThumbnailObjectKey(req.resolveSuffix()); - // 3. FileObject 생성 - FileObject fileObject = FileObject.create( + // 3. FileObject 저장 + FileObject fo = FileObject.create( storageKey, req.originalFilename(), FileObjectStatus.INIT, req.contentType(), 0L ); - FileObject saved = fileObjectRepository.save(fileObject); + fileObjectRepository.save(fo); // 4. presigned 발급 - PutObjectRequest objectRequest = PutObjectRequest.builder() - .bucket(BUCKET_NAME) - .key(storageKey) - .contentType(req.contentType()) - .build(); - - PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(2)) - .putObjectRequest(objectRequest) - .build(); - - PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest); + PresignedPutObjectRequest presigned = presignPut(storageKey, req.contentType()); // 5. 응답에 fileObjectId 포함 - return new S3PresignedUrlResponse( - saved.getId(), - presignedRequest.url().toString(), + return new ThumbnailPresignedUrlResponse( + fo.getId(), + presigned.url().toString(), storageKey ); } - private String createObjectKey(CreationThumbnailType type) { + + public ManuscriptPresignedResponse generateManuscriptPresignedUrls(ManuscriptPresignedRequest req) { + + int count = req.files().size(); + + // 1. contentType 검증 + displayOrder 중복 확인 + Set seenOrders = new HashSet<>(); + for (ManuscriptFileRequest f : req.files()) { + validateContentType(f.contentType()); + + if (!seenOrders.add(f.displayOrder())) { + throw new IllegalArgumentException("중복된 displayOrder가 있습니다: " + f.displayOrder()); + } + } + + // 2. displayOrder로 원고 오름차순 정렬 (같은 순서 보장) + List sorted = new ArrayList<>(req.files()); + sorted.sort(Comparator.comparingInt(ManuscriptFileRequest::displayOrder)); + + + // 3. storageKey + FileObject 생성 (N개) + List toSave = new ArrayList<>(count); + List displayOrders = new ArrayList<>(count); + List storageKeys = new ArrayList<>(count); + List contentTypes = new ArrayList<>(count); + + for (ManuscriptFileRequest fileReq : sorted) { + int order = fileReq.displayOrder(); + String storageKey = createManuscriptObjectKey(req.creationId(), order); + + FileObject fo = FileObject.create( + storageKey, + fileReq.originalFilename(), + FileObjectStatus.INIT, + fileReq.contentType(), + 0L + ); + + toSave.add(fo); + displayOrders.add(order); + storageKeys.add(storageKey); + contentTypes.add(fileReq.contentType()); + } + + // 4. 저장 + List savedList = fileObjectRepository.saveAll(toSave); + + // 5. presigned url 발급 + List items = new ArrayList<>(count); + + for (int i = 0; i < savedList.size(); i++) { + String key = storageKeys.get(i); + String contentType = contentTypes.get(i); + + PresignedPutObjectRequest presigned = presignPut(key, contentType); + + items.add(new ManuscriptPresignedUrlResponse( + displayOrders.get(i), + savedList.get(i).getId(), + presigned.url().toString(), + key + )); + } + + return new ManuscriptPresignedResponse(items); + } + + private void validateContentType(String contentType) { + if (!"image/jpeg".equals(contentType)) { + throw new IllegalArgumentException("image/jpeg 타입만 허용 가능합니다."); + } + } + + private String createThumbnailObjectKey(String suffix) { + if (suffix == null || suffix.isBlank()) { + throw new IllegalArgumentException("suffix가 비어있습니다."); + } String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); String base = "upload/" + datePath + "/" + UUID.randomUUID(); + return base + suffix; + } + + private String createManuscriptObjectKey(Long creationId, int order) { + String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + + String base = "upload/" + datePath + + "/" + creationId + "/" + + String.format("%03d", order) + + "_" + UUID.randomUUID(); + + return base + ThumbnailKeys.MANUSCRIPT_SUFFIX; + } + + + private PresignedPutObjectRequest presignPut(String key, String contentType) { + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .putObjectRequest(objectRequest) + .build(); - return switch (type) { - case POSTER -> base + ThumbnailKeys.POSTER_SUFFIX; - case HORIZONTAL -> base + ThumbnailKeys.HORIZONTAL_SUFFIX; // Lambda 트리거 - case DERIVED -> null; - case EXTRA -> null; - }; + return presigner.presignPutObject(presignRequest); } }