diff --git a/src/main/java/com/jiwon/mylog/domain/image/dto/request/ImageRequest.java b/src/main/java/com/jiwon/mylog/domain/image/dto/request/ImageRequest.java new file mode 100644 index 0000000..f3f6b10 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/image/dto/request/ImageRequest.java @@ -0,0 +1,4 @@ +package com.jiwon.mylog.domain.image.dto.request; + +public record ImageRequest(String fileName) { +} diff --git a/src/main/java/com/jiwon/mylog/domain/image/dto/request/MultiImageRequest.java b/src/main/java/com/jiwon/mylog/domain/image/dto/request/MultiImageRequest.java new file mode 100644 index 0000000..9198bdb --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/image/dto/request/MultiImageRequest.java @@ -0,0 +1,6 @@ +package com.jiwon.mylog.domain.image.dto.request; + +import java.util.List; + +public record MultiImageRequest(List images) { +} diff --git a/src/main/java/com/jiwon/mylog/domain/image/dto/response/MultiPresignedUrlResponse.java b/src/main/java/com/jiwon/mylog/domain/image/dto/response/MultiPresignedUrlResponse.java new file mode 100644 index 0000000..caf5e37 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/image/dto/response/MultiPresignedUrlResponse.java @@ -0,0 +1,6 @@ +package com.jiwon.mylog.domain.image.dto.response; + +import java.util.List; + +public record MultiPresignedUrlResponse(List presignedUrls) { +} diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java index 33ec1ce..f9c25aa 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java @@ -30,7 +30,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -51,9 +50,6 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; private static final QPost POST = QPost.post; - private static final QPost PREV_POST = new QPost("prev"); - private static final QPost NEXT_POST = new QPost("next"); - private static final QPost SUB_POST = new QPost("sub"); private static final QUser USER = QUser.user; private static final QProfileImage PROFILE_IMAGE = QProfileImage.profileImage; private static final QCategory CATEGORY = QCategory.category; @@ -64,10 +60,6 @@ public class PostRepositoryImpl implements PostRepositoryCustom { @Override public Page findLikedPosts(Long userId, Pageable pageable) { - BooleanBuilder builder = new BooleanBuilder() - .and(LIKE.user.id.eq(userId)) - .and(POST.deletedAt.isNull()); - List posts = jpaQueryFactory .select(Projections.constructor(PostSummaryResponse.class, POST.id, @@ -92,7 +84,9 @@ public Page findLikedPosts(Long userId, Pageable pageable) .join(POST.user, USER) .leftJoin(POST.category, CATEGORY) .leftJoin(USER.profileImage, PROFILE_IMAGE) - .where(builder) + .where(likeUserIdEq(userId), + postDeletedAtIsNull() + ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .orderBy(LIKE.createdAt.desc()) @@ -102,7 +96,8 @@ public Page findLikedPosts(Long userId, Pageable pageable) .select(LIKE.count()) .from(LIKE) .join(LIKE.post, POST) - .where(builder) + .where(likeUserIdEq(userId), + postDeletedAtIsNull()) .fetchOne(); if (!posts.isEmpty()) { @@ -115,8 +110,47 @@ public Page findLikedPosts(Long userId, Pageable pageable) @Override public Page findFilteredPosts( Long userId, Long categoryId, List tagIds, String keyword, Pageable pageable) { - BooleanBuilder builder = buildPostFilters(userId, categoryId, tagIds, keyword); - return getPostSummaryPage(builder, pageable); + + BooleanBuilder conditions = buildPostFilters(userId, categoryId, tagIds, keyword); + List posts = createPostSummaryQuery(conditions, pageable); + Long total = createCountQuery(conditions); + + if (!posts.isEmpty()) { + setTagsToPosts(posts); + } + + return new PageImpl<>(posts, pageable, total); + } + + private List createPostSummaryQuery(BooleanBuilder builder, Pageable pageable) { + return jpaQueryFactory + .select(Projections.constructor(PostSummaryResponse.class, + POST.id, + POST.title, + POST.contentPreview, + POST.postStatus, + POST.visibility, + Projections.constructor(CategoryResponse.class, + CATEGORY.id, + CATEGORY.name + ), + Projections.constructor(UserSummaryResponse.class, + USER.id, + USER.username, + PROFILE_IMAGE.fileKey.coalesce("") + ), + POST.createdAt + ) + ) + .from(POST) + .join(POST.user, USER) + .leftJoin(POST.category, CATEGORY) + .leftJoin(USER.profileImage, PROFILE_IMAGE) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(POST.createdAt.desc()) + .fetch(); } @Override @@ -150,9 +184,9 @@ public Optional findPostDetail(Long postId) { .leftJoin(POST.user, USER) .leftJoin(POST.user.profileImage, PROFILE_IMAGE) .where( - POST.id.eq(postId), - POST.deletedAt.isNull(), - USER.deletedAt.isNull() + postIdEq(postId), + postDeletedAtIsNull(), + userDeletedAtIsNull() ) .fetchOne(); @@ -160,17 +194,16 @@ public Optional findPostDetail(Long postId) { return Optional.empty(); } - List tags = getTagsByPostId(postId); - List comments = getCommentsByPostId(postId); + loadCommentsAndTags(postId, postDetailResponse); + loadAdjacentPosts(postId, postDetailResponse); - Tuple postData = getPostData(postId); - RelatedPostResponse previousPost = findPreviousPost(postData); - RelatedPostResponse nextPost = findNextPost(postData); + return Optional.of(postDetailResponse); + } + private void loadCommentsAndTags(Long postId, PostDetailResponse postDetailResponse) { + List tags = getTagsByPostId(postId); + List comments = getCommentsByPostId(postId); postDetailResponse.setTagsAndComments(tags, comments); - postDetailResponse.setRelatedPosts(previousPost, nextPost); - - return Optional.of(postDetailResponse); } private List getTagsByPostId(Long postId) { @@ -215,6 +248,67 @@ private List getCommentsByPostId(Long postId) { .fetch(); } + private void loadAdjacentPosts(Long postId, PostDetailResponse postDetailResponse) { + Tuple postData = getPostData(postId); + if (postData == null) { + postDetailResponse.setRelatedPosts(null, null); + } else { + Long userId = postData.get(0, Long.class); + Long categoryId = postData.get(1, Long.class); + LocalDateTime createdAt = postData.get(2, LocalDateTime.class); + + RelatedPostResponse previousPost = getPreviousPost(userId, categoryId, createdAt); + RelatedPostResponse nextPost = getNextPost(userId, categoryId, createdAt); + postDetailResponse.setRelatedPosts(previousPost, nextPost); + } + } + + private RelatedPostResponse getPreviousPost(Long userId, Long categoryId, LocalDateTime createdAt) { + BooleanBuilder conditions = + buildCategoryPostConditions(userId, categoryId) + .and(createdAtLt(createdAt)); + + return jpaQueryFactory + .select(Projections.constructor(RelatedPostResponse.class, + POST.id, POST.title, POST.createdAt + ) + ) + .from(POST) + .where(conditions) + .orderBy(POST.createdAt.desc()) + .limit(1) + .fetchOne(); + } + + private RelatedPostResponse getNextPost(Long userId, Long categoryId, LocalDateTime createdAt) { + BooleanBuilder conditions = + buildCategoryPostConditions(userId, categoryId) + .and(createdAtGt(createdAt)); + + return jpaQueryFactory + .select(Projections.constructor(RelatedPostResponse.class, + POST.id, POST.title, POST.createdAt + ) + ) + .from(POST) + .where(conditions) + .orderBy(POST.createdAt.asc()) + .limit(1) + .fetchOne(); + } + + private Tuple getPostData(Long postId) { + Tuple currentPost = jpaQueryFactory + .select(POST.user.id, + POST.category.id.coalesce(-1L), + POST.createdAt + ) + .from(POST) + .where(postIdEq(postId)) + .fetchOne(); + return currentPost; + } + @Override public PostNavigationResponse findPostNavigation(Long postId) { @@ -229,9 +323,10 @@ public PostNavigationResponse findPostNavigation(Long postId) { Long categoryId = postData.get(1, Long.class); LocalDateTime createdAt = postData.get(2, LocalDateTime.class); - BooleanBuilder conditions = createCategorizedDefaultConditions(userId, categoryId); - Long total = createCountQuery(conditions); - Long offset = createCountQuery(conditions.and(POST.createdAt.gt(createdAt))); + BooleanBuilder conditions = + buildCategoryPostConditions(userId, categoryId) + .and(createdAtGt(createdAt)); + Long offset = createCountQuery(conditions); long currentPage = offset / defaultPageSize; long currentPageOffset = offset - (offset % defaultPageSize); @@ -242,34 +337,26 @@ public PostNavigationResponse findPostNavigation(Long postId) { .categoryId(categoryId) .currentOffset(currentPageOffset) .currentPage(currentPage) - .totalPosts(total) .build(); } @Override public Page findCategorizedPosts(Long categoryId, Long userId, Pageable pageable) { - BooleanBuilder conditions = createCategorizedDefaultConditions(userId, categoryId); - List categorizedPosts = - createdCategorizedPostsQuery(conditions, pageable); + BooleanBuilder conditions = buildCategoryPostConditions(userId, categoryId); + List categoryPosts = + createCategoryPostQuery(conditions, pageable); Long total = createCountQuery(conditions); - return new PageImpl<>(categorizedPosts, pageable, total); + return new PageImpl<>(categoryPosts, pageable, total); } - private BooleanBuilder createCategorizedDefaultConditions(Long userId, Long categoryId) { - BooleanBuilder conditions = new BooleanBuilder() - .and(POST.user.id.eq(userId)) - .and(POST.deletedAt.isNull()); - - if (categoryId != null && categoryId > 0L) { - conditions.and(POST.category.id.eq(categoryId)); - } else { - conditions.and(POST.category.isNull()); - } - - return conditions; + private BooleanBuilder buildCategoryPostConditions(Long userId, Long categoryId) { + return new BooleanBuilder() + .and(postUserIdEq(userId)) + .and(postDeletedAtIsNull()) + .and(categoryIdEq(categoryId)); } - private List createdCategorizedPostsQuery(BooleanBuilder conditions, Pageable pageable) { + private List createCategoryPostQuery(BooleanBuilder conditions, Pageable pageable) { return jpaQueryFactory .select(Projections.constructor(RelatedPostResponse.class, POST.id, POST.title, POST.createdAt @@ -283,70 +370,6 @@ private List createdCategorizedPostsQuery(BooleanBuilder co .fetch(); } - private RelatedPostResponse findPreviousPost(Tuple currentPost) { - - if (currentPost == null) { - return null; - } - - Long userId = currentPost.get(0, Long.class); - Long categoryId = currentPost.get(1, Long.class); - LocalDateTime createdAt = currentPost.get(2, LocalDateTime.class); - - BooleanBuilder conditions = - createCategorizedDefaultConditions(userId, categoryId) - .and(POST.createdAt.lt(createdAt)); - - return jpaQueryFactory - .select(Projections.constructor(RelatedPostResponse.class, - POST.id, POST.title, POST.createdAt - ) - ) - .from(POST) - .where(conditions) - .orderBy(POST.createdAt.desc()) - .limit(1) - .fetchOne(); - } - - private RelatedPostResponse findNextPost(Tuple currentPost) { - - if (currentPost == null) { - return null; - } - - Long userId = currentPost.get(0, Long.class); - Long categoryId = currentPost.get(1, Long.class); - LocalDateTime createdAt = currentPost.get(2, LocalDateTime.class); - - BooleanBuilder conditions = - createCategorizedDefaultConditions(userId, categoryId) - .and(POST.createdAt.gt(createdAt)); - - return jpaQueryFactory - .select(Projections.constructor(RelatedPostResponse.class, - POST.id, POST.title, POST.createdAt - ) - ) - .from(POST) - .where(conditions) - .orderBy(POST.createdAt.asc()) - .limit(1) - .fetchOne(); - } - - private Tuple getPostData(Long postId) { - Tuple currentPost = jpaQueryFactory - .select(POST.user.id, - POST.category.id.coalesce(-1L), - POST.createdAt - ) - .from(POST) - .where(POST.id.eq(postId)) - .fetchOne(); - return currentPost; - } - @Override public List findUserActivities(Long userId, LocalDate start, LocalDate end) { DateTemplate formattedDate = Expressions.dateTemplate( @@ -363,7 +386,7 @@ public List findUserActivities(Long userId, LocalDate star ) .from(POST) .join(POST.user, USER) - .where(POST.user.id.eq(userId), + .where(postUserIdEq(userId), POST.createdAt.between( LocalDateTime.of(start, LocalTime.MIN), LocalDateTime.of(end, LocalTime.MAX) @@ -376,46 +399,6 @@ public List findUserActivities(Long userId, LocalDate star return activities; } - private PageImpl getPostSummaryPage(BooleanBuilder builder, Pageable pageable) { - List posts = createPostSummaryQuery(builder, pageable); - Long total = createCountQuery(builder); - if (!posts.isEmpty()) { - setTagsToPosts(posts); - } - return new PageImpl<>(posts, pageable, total); - } - - private List createPostSummaryQuery(BooleanBuilder builder, Pageable pageable) { - return jpaQueryFactory - .select(Projections.constructor(PostSummaryResponse.class, - POST.id, - POST.title, - POST.contentPreview, - POST.postStatus, - POST.visibility, - Projections.constructor(CategoryResponse.class, - CATEGORY.id, - CATEGORY.name - ), - Projections.constructor(UserSummaryResponse.class, - USER.id, - USER.username, - PROFILE_IMAGE.fileKey.coalesce("") - ), - POST.createdAt - ) - ) - .from(POST) - .join(POST.user, USER) - .leftJoin(POST.category, CATEGORY) - .leftJoin(USER.profileImage, PROFILE_IMAGE) - .where(builder) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(POST.createdAt.desc()) - .fetch(); - } - private Long createCountQuery(BooleanBuilder builder) { return jpaQueryFactory .select(POST.count()) @@ -459,39 +442,60 @@ private Map> getTagsByPostIds(List postIds) { } private BooleanBuilder buildPostFilters(Long userId, Long categoryId, List tagIds, String keyword) { - BooleanBuilder builder = new BooleanBuilder() - .and(POST.user.id.eq(userId)) - .and(POST.deletedAt.isNull()); + return new BooleanBuilder() + .and(postUserIdEq(userId)) + .and(postDeletedAtIsNull()) + .and(categoryIdEq(categoryId)) + .and(createTagExistsCondition(tagIds)) + .and(titleContainsKeyword(keyword)); + } + private BooleanExpression postIdEq(Long postId) { + return postId != null ? POST.id.eq(postId) : null; + } - // 미분류 게시글 - if (categoryId != null && categoryId == -1L) { - builder.and(POST.category.id.isNull()); - } + private BooleanExpression postUserIdEq(Long userId) { + return userId != null ? POST.user.id.eq(userId) : null; + } - // 카테고리 필터 - if (categoryId != null && categoryId > 0) { - builder.and(POST.category.id.eq(categoryId)); - } + private BooleanExpression likeUserIdEq(Long userId) { + return userId != null ? LIKE.user.id.eq(userId) : null; + } - // 태그 필터 - if (tagIds != null && !tagIds.isEmpty()) { - builder.and(createTagExistsCondition(tagIds)); - } + private BooleanExpression categoryIdEq(Long categoryId) { + return (categoryId == null || categoryId < 0L) ? + POST.category.id.isNull() : + POST.category.id.eq(categoryId); + } - if (keyword != null && !keyword.isEmpty()) { - builder.and(POST.title.containsIgnoreCase(keyword)); - } + private BooleanExpression userDeletedAtIsNull() { + return USER.deletedAt.isNull(); + } - return builder; + private BooleanExpression postDeletedAtIsNull() { + return POST.deletedAt.isNull(); + } + + private BooleanExpression titleContainsKeyword(String keyword) { + return keyword != null ? POST.title.containsIgnoreCase(keyword) : null; } private BooleanExpression createTagExistsCondition(List tagIds) { - return JPAExpressions - .select(POST_TAG.id) - .from(POST_TAG) - .where(POST_TAG.post.id.eq(POST.id) - .and(POST_TAG.tag.id.in(tagIds)) - ) - .exists(); + return (tagIds != null && !tagIds.isEmpty()) + ? JPAExpressions + .select(POST_TAG.id) + .from(POST_TAG) + .where(POST_TAG.post.id.eq(POST.id) + .and(POST_TAG.tag.id.in(tagIds)) + ) + .exists() + : null; + } + + private BooleanExpression createdAtLt(LocalDateTime createdAt) { + return createdAt != null ? POST.createdAt.lt(createdAt) : null; + } + + private BooleanExpression createdAtGt(LocalDateTime createdAt) { + return createdAt != null ? POST.createdAt.gt(createdAt) : null; } } diff --git a/src/main/java/com/jiwon/mylog/global/aws/S3Controller.java b/src/main/java/com/jiwon/mylog/global/aws/S3Controller.java index 485ec61..1d3299e 100644 --- a/src/main/java/com/jiwon/mylog/global/aws/S3Controller.java +++ b/src/main/java/com/jiwon/mylog/global/aws/S3Controller.java @@ -1,5 +1,8 @@ package com.jiwon.mylog.global.aws; +import com.jiwon.mylog.domain.image.dto.request.ImageRequest; +import com.jiwon.mylog.domain.image.dto.request.MultiImageRequest; +import com.jiwon.mylog.domain.image.dto.response.MultiPresignedUrlResponse; import com.jiwon.mylog.domain.image.dto.response.PresignedUrlResponse; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -7,6 +10,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -27,9 +31,16 @@ public ResponseEntity uploadFile(@RequestParam("file") MultipartFile fil } @PostMapping("/presigned-url") - public ResponseEntity generatePutPresignedUrl(@RequestParam String fileName) { - PresignedUrlResponse response = s3Service.generatePutPresignedUrl(fileName); - return new ResponseEntity<>(response, HttpStatus.OK); + public ResponseEntity generatePutPresignedUrl(@RequestBody ImageRequest request) { + PresignedUrlResponse response = s3Service.generatePutPresignedUrl(request.fileName()); + return ResponseEntity.ok(response); + } + + @PostMapping("/presigned-urls") + public ResponseEntity generatePutMultiPresignedUrl( + @RequestBody MultiImageRequest request) { + MultiPresignedUrlResponse response = s3Service.generatePutPresignedUrls(request); + return ResponseEntity.ok(response); } @DeleteMapping diff --git a/src/main/java/com/jiwon/mylog/global/aws/S3Service.java b/src/main/java/com/jiwon/mylog/global/aws/S3Service.java index ebbef4b..fe6924c 100644 --- a/src/main/java/com/jiwon/mylog/global/aws/S3Service.java +++ b/src/main/java/com/jiwon/mylog/global/aws/S3Service.java @@ -1,6 +1,8 @@ package com.jiwon.mylog.global.aws; +import com.jiwon.mylog.domain.image.dto.request.MultiImageRequest; import com.jiwon.mylog.domain.image.dto.response.PresignedUrlResponse; +import com.jiwon.mylog.domain.image.dto.response.MultiPresignedUrlResponse; import com.jiwon.mylog.global.common.error.ErrorCode; import com.jiwon.mylog.global.common.error.exception.AmazonS3Exception; import lombok.RequiredArgsConstructor; @@ -17,6 +19,7 @@ import java.io.IOException; import java.time.Duration; +import java.util.List; import java.util.UUID; @RequiredArgsConstructor @@ -60,6 +63,14 @@ public PresignedUrlResponse generatePutPresignedUrl(String fileName) { return PresignedUrlResponse.create(key, url); } + @Transactional + public MultiPresignedUrlResponse generatePutPresignedUrls(MultiImageRequest multiImages) { + List presignedUrls = multiImages.images().stream() + .map(image -> generatePutPresignedUrl(image.fileName())) + .toList(); + return new MultiPresignedUrlResponse(presignedUrls); + } + @Transactional public void deleteFile(String fileName) { try { @@ -85,6 +96,6 @@ private String generateKey(String fileName) { if (extension == null || extension.isBlank()) { throw new IllegalArgumentException("잘못된 확장자입니다."); } - return UUID.randomUUID() + "." + extension; + return "temp/" + UUID.randomUUID() + "." + extension; } } diff --git a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java index 9d5817e..a97ee21 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java @@ -87,7 +87,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService) // 단순 조회 (권한X) .requestMatchers(HttpMethod.GET, "/api/users/**", "/api/posts/**", "/api/categories/**", "/api/images/**", "/api/points/**", "/api/items/**", "/api/sse/**", "/api/likes/**").permitAll() // 블로그 사용자 - .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/notifications/**", "/api/likes/**", "/api/readme/**", "/api/openai/**", "/api/guestbooks").authenticated() + .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/s3/**", "/api/notifications/**", "/api/likes/**", "/api/readme/**", "/api/openai/**", "/api/guestbooks").authenticated() // 관리자 전용 .requestMatchers("/api/admin/**").hasRole("ADMIN"));