Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,9 @@ private String generateRecommendationMessage(String userQuestion, List<CocktailS

} catch (Exception e) {
log.error("추천 메시지 생성 중 오류: ", e);
return "🍹 요청하신 칵테일을 찾아봤어요! 쑤리가 엄선한 칵테일들을 추천해드릴게요.";
return "🍹 요청하신 칵테일을 찾아봤어요! 쑤리가 엄선한 칵테일들을 추천해드릴게요." +
"\n\n칵테일의 자세한 정보는 '상세보기'를 클릭해서 확인할 수 있어요.\n" +
"마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!";
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,15 @@ public RsData<Void> unkeep(
myBarService.unkeep(userId, cocktailId);
return RsData.of(200, "deleted");
}

@DeleteMapping
@Operation(summary = "내 바 전체 삭제", description = "내 바에 담긴 모든 칵테일을 소프트 삭제합니다")
public RsData<Void> clearAll(
@AuthenticationPrincipal SecurityUser principal
) {
Long userId = principal.getId();
myBarService.clearAll(userId);
return RsData.of(200, "cleared");
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ public interface MyBarRepository extends JpaRepository<MyBar, Long> {
/** 복원/재킵을 위해 status 무시하고 한 건 찾기 (없으면 Optional.empty) */
Optional<MyBar> findByUser_IdAndCocktail_Id(Long userId, Long cocktailId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update MyBar m
set m.status = 'DELETED', m.deletedAt = CURRENT_TIMESTAMP
where m.user.id = :userId
and m.status = 'ACTIVE'
""")
int softDeleteAllByUser(Long userId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
update MyBar m
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/back/domain/mybar/service/MyBarService.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,5 +123,13 @@ public void unkeep(Long userId, Long cocktailId) {
abvScoreService.revokeForKeep(userId);
}
}

@Transactional
public void clearAll(Long userId) {
int changed = myBarRepository.softDeleteAllByUser(userId);
if (changed > 0) {
abvScoreService.revokeForKeep(userId, changed);
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.back.domain.notification.entity.Notification;
import com.back.domain.notification.enums.NotificationType;
import com.back.domain.post.post.entity.PostImage;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -13,18 +14,29 @@ public class NotificationItemDto {
private NotificationType type;
private Long postId;
private String postTitle;
private String postCategoryName;
private String postThumbnailUrl;
private String message;
private boolean read;
private LocalDateTime createdAt;

public static NotificationItemDto from(Notification n) {
String categoryName = n.getPost().getCategory() != null ? n.getPost().getCategory().getName() : null;
String thumbnailUrl = n.getPost().getImages().stream()
.map(PostImage::getUrl)
.findFirst()
.orElse(null);

return NotificationItemDto.builder()
.id(n.getId())
.type(n.getType())
.postId(n.getPost().getId())
.postTitle(n.getPost().getTitle())
.postCategoryName(categoryName)
.postThumbnailUrl(thumbnailUrl)
.message(n.getMessage())
.read(n.isRead())
.createdAt(n.getCreatedAt())
.build();
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,12 @@ public CommentResponseDto createComment(Long postId, CommentCreateRequestDto req
.build();

// 게시글 작성자에게 알림 전송
String commentMessage = String.format("%s 님이 '%s' 게시글에 댓글을 남겼습니다.", user.getNickname(), post.getTitle());
notificationService.sendNotification(
post.getUser(),
post,
NotificationType.COMMENT,
user.getNickname() + " 님이 댓글을 남겼습니다."
commentMessage
);

Comment saved = commentRepository.save(comment);
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/com/back/domain/post/post/entity/Post.java
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ public void updateContent(String content) {
this.content = content;
}

public void addImage(PostImage image) {
if (this.images == null) {
this.images = new ArrayList<>();
}
this.images.add(image);
image.updatePost(this); // 양방향 관계 유지
}


public void updateImages(List<PostImage> images) {
this.images.clear();
for (PostImage i : images) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.back.domain.post.post.repository;

import com.back.domain.post.post.entity.Post;
import com.back.domain.post.post.enums.PostStatus;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
Expand All @@ -9,28 +10,31 @@
public interface PostRepository extends JpaRepository<Post, Long> {

// 최신순 (카테고리 없음)
List<Post> findTop10ByOrderByIdDesc();
List<Post> findTop10ByIdLessThanOrderByIdDesc(Long lastId);
List<Post> findTop10ByStatusNotOrderByIdDesc(PostStatus status);
List<Post> findTop10ByStatusNotAndIdLessThanOrderByIdDesc(PostStatus status, Long lastId);

// 추천순 (카테고리 없음)
List<Post> findTop10ByOrderByLikeCountDescIdDesc();
List<Post> findTop10ByLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(
Integer likeCount, Integer likeCount2, Long id);
List<Post> findTop10ByStatusNotOrderByLikeCountDescIdDesc(PostStatus status);
List<Post> findTop10ByStatusNotAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(
PostStatus status, Integer likeCount, Integer likeCount2, Long id);

// 댓글순 (카테고리 없음)
List<Post> findTop10ByOrderByCommentCountDescIdDesc();
List<Post> findTop10ByCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(
Integer commentCount, Integer commentCount2, Long id);
List<Post> findTop10ByStatusNotOrderByCommentCountDescIdDesc(PostStatus status);
List<Post> findTop10ByStatusNotAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(
PostStatus status, Integer commentCount, Integer commentCount2, Long id);

// 최신순 (카테고리)
List<Post> findTop10ByCategoryIdOrderByIdDesc(Long categoryId);
List<Post> findTop10ByCategoryIdAndIdLessThanOrderByIdDesc(Long categoryId, Long id);
List<Post> findTop10ByCategoryIdAndStatusNotOrderByIdDesc(Long categoryId, PostStatus status);
List<Post> findTop10ByCategoryIdAndStatusNotAndIdLessThanOrderByIdDesc(Long categoryId, PostStatus status, Long id);

// 추천순 (카테고리)
List<Post> findTop10ByCategoryIdOrderByLikeCountDescIdDesc(Long categoryId);
List<Post> findTop10ByCategoryIdAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(Long categoryId, Integer likeCount, Integer likeCountEquals, Long id);
List<Post> findTop10ByCategoryIdAndStatusNotOrderByLikeCountDescIdDesc(Long categoryId, PostStatus status);
List<Post> findTop10ByCategoryIdAndStatusNotAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(
Long categoryId, PostStatus status, Integer likeCount, Integer likeCountEquals, Long id);

// 댓글순 (카테고리)
List<Post> findTop10ByCategoryIdOrderByCommentCountDescIdDesc(Long categoryId);
List<Post> findTop10ByCategoryIdAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(Long categoryId, Integer commentCount, Integer commentCountEquals, Long id);
List<Post> findTop10ByCategoryIdAndStatusNotOrderByCommentCountDescIdDesc(Long categoryId, PostStatus status);
List<Post> findTop10ByCategoryIdAndStatusNotAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(
Long categoryId, PostStatus status, Integer commentCount, Integer commentCountEquals, Long id);
}

49 changes: 23 additions & 26 deletions src/main/java/com/back/domain/post/post/service/PostService.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,17 @@
import com.back.global.file.dto.UploadedFileDto;
import com.back.global.file.service.FileService;
import com.back.global.rq.Rq;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class PostService {
Expand Down Expand Up @@ -82,13 +79,12 @@ public PostResponseDto createPost(PostCreateRequestDto reqBody, List<MultipartFi
String url = fileService.uploadFile(image);

PostImage postImage = PostImage.builder()
.post(post)
.fileName(image.getOriginalFilename())
.url(url)
.sortOrder(order++)
.build();

postImageRepository.save(postImage);
post.addImage(postImage);
}
}

Expand Down Expand Up @@ -264,11 +260,12 @@ public PostLikeResponseDto toggleLike(Long postId) {
abvScoreService.awardForLike(user.getId());

// 게시글 작성자에게 알림 전송
String likeMessage = String.format("%s 님이 '%s' 게시글에 추천을 남겼습니다.", user.getNickname(), post.getTitle());
notificationService.sendNotification(
post.getUser(),
post,
NotificationType.LIKE,
user.getNickname() + " 님이 추천을 남겼습니다."
likeMessage
);

return new PostLikeResponseDto(postLike.getStatus());
Expand All @@ -294,23 +291,23 @@ private List<Post> findAllPosts(PostSortScrollRequestDto reqBody) {
return switch (reqBody.postSortStatus()) {
case POPULAR -> {
if (reqBody.lastId() == null || reqBody.lastLikeCount() == null) {
yield postRepository.findTop10ByOrderByLikeCountDescIdDesc();
yield postRepository.findTop10ByStatusNotOrderByLikeCountDescIdDesc(PostStatus.DELETED);
} else {
yield postRepository.findTop10ByLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(reqBody.lastLikeCount(), reqBody.lastLikeCount(), reqBody.lastId());
yield postRepository.findTop10ByStatusNotAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(PostStatus.DELETED, reqBody.lastLikeCount(), reqBody.lastLikeCount(), reqBody.lastId());
}
}
case COMMENTS -> {
if (reqBody.lastId() == null || reqBody.lastCommentCount() == null) {
yield postRepository.findTop10ByOrderByCommentCountDescIdDesc();
yield postRepository.findTop10ByStatusNotOrderByCommentCountDescIdDesc(PostStatus.DELETED);
} else {
yield postRepository.findTop10ByCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(reqBody.lastCommentCount(), reqBody.lastCommentCount(), reqBody.lastId());
yield postRepository.findTop10ByStatusNotAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(PostStatus.DELETED, reqBody.lastCommentCount(), reqBody.lastCommentCount(), reqBody.lastId());
}
}
case LATEST -> {
if (reqBody.lastId() == null) {
yield postRepository.findTop10ByOrderByIdDesc();
yield postRepository.findTop10ByStatusNotOrderByIdDesc(PostStatus.DELETED);
} else {
yield postRepository.findTop10ByIdLessThanOrderByIdDesc(reqBody.lastId());
yield postRepository.findTop10ByStatusNotAndIdLessThanOrderByIdDesc(PostStatus.DELETED, reqBody.lastId());
}
}
default -> throw new IllegalArgumentException("지원하지 않는 정렬 기준: " + reqBody.postSortStatus());
Expand All @@ -322,29 +319,29 @@ private List<Post> findPostsByCategory(PostSortScrollRequestDto reqBody) {
return switch (reqBody.postSortStatus()) {
case POPULAR -> {
if (reqBody.lastId() == null || reqBody.lastLikeCount() == null) {
yield postRepository.findTop10ByCategoryIdOrderByLikeCountDescIdDesc(
reqBody.categoryId());
yield postRepository.findTop10ByCategoryIdAndStatusNotOrderByLikeCountDescIdDesc(
reqBody.categoryId(), PostStatus.DELETED);
} else {
yield postRepository.findTop10ByCategoryIdAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(
reqBody.categoryId(), reqBody.lastLikeCount(), reqBody.lastLikeCount(),
yield postRepository.findTop10ByCategoryIdAndStatusNotAndLikeCountLessThanOrLikeCountEqualsAndIdLessThanOrderByLikeCountDescIdDesc(
reqBody.categoryId(), PostStatus.DELETED, reqBody.lastLikeCount(), reqBody.lastLikeCount(),
reqBody.lastId());
}
}
case COMMENTS -> {
if (reqBody.lastId() == null || reqBody.lastCommentCount() == null) {
yield postRepository.findTop10ByCategoryIdOrderByCommentCountDescIdDesc(
reqBody.categoryId());
yield postRepository.findTop10ByCategoryIdAndStatusNotOrderByCommentCountDescIdDesc(
reqBody.categoryId(), PostStatus.DELETED);
} else {
yield postRepository.findTop10ByCategoryIdAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(
reqBody.categoryId(), reqBody.lastCommentCount(), reqBody.lastCommentCount(),
yield postRepository.findTop10ByCategoryIdAndStatusNotAndCommentCountLessThanOrCommentCountEqualsAndIdLessThanOrderByCommentCountDescIdDesc(
reqBody.categoryId(), PostStatus.DELETED, reqBody.lastCommentCount(), reqBody.lastCommentCount(),
reqBody.lastId());
}
}
case LATEST -> {
if (reqBody.lastId() == null) {
yield postRepository.findTop10ByCategoryIdOrderByIdDesc(reqBody.categoryId());
yield postRepository.findTop10ByCategoryIdAndStatusNotOrderByIdDesc(reqBody.categoryId(), PostStatus.DELETED);
} else {
yield postRepository.findTop10ByCategoryIdAndIdLessThanOrderByIdDesc(reqBody.categoryId(),
yield postRepository.findTop10ByCategoryIdAndStatusNotAndIdLessThanOrderByIdDesc(reqBody.categoryId(), PostStatus.DELETED,
reqBody.lastId());
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ public void revokeForKeep(Long userId) {
addScore(userId, -KEEP_SCORE);
}

@Transactional
public void revokeForKeep(Long userId, int count) {
if (count <= 0) {
return;
}
addScore(userId, -KEEP_SCORE * count);
}

private void addScore(Long userId, double delta) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ServiceException(404, "사용자를 찾을 수 없습니다."));
Expand Down
5 changes: 2 additions & 3 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,10 @@ custom:
jwt:
secretKey: ${JWT_SECRET_KEY}
accessToken:
expirationSeconds: "#{60}" # 15분 곱하기
expirationSeconds: "#{60*15}"
refreshToken:
expirationSeconds: "#{60*60*24*30}"
idleTimeoutHours: "#{1}"
# "#{60*6*4}"
idleTimeoutHours: "#{60*6*4}"


management:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,22 @@ void unkeepCocktail() throws Exception {

verify(myBarService).unkeep(principal.getId(), cocktailId);
}

@Test
@DisplayName("Clear entire my bar")
void clearAllMyBar() throws Exception {
SecurityUser principal = createPrincipal(21L);

willDoNothing().given(myBarService).clearAll(principal.getId());

mockMvc.perform(delete("/me/bar")
.with(withPrincipal(principal))
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("cleared"))
.andExpect(jsonPath("$.data").doesNotExist());

verify(myBarService).clearAll(principal.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ void getNotifications_noCursor() throws Exception {
.type(NotificationType.COMMENT)
.postId(55L)
.postTitle("새 댓글")
.postCategoryName("공지")
.postThumbnailUrl("https://example.com/thumb.png")
.message("새 댓글이 달렸습니다.")
.read(false)
.createdAt(LocalDateTime.of(2025, 1, 2, 12, 0))
.build();
Expand All @@ -154,6 +157,9 @@ void getNotifications_noCursor() throws Exception {
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("success"))
.andExpect(jsonPath("$.data.items[0].id").value(101))
.andExpect(jsonPath("$.data.items[0].postCategoryName").value("공지"))
.andExpect(jsonPath("$.data.items[0].postThumbnailUrl").value("https://example.com/thumb.png"))
.andExpect(jsonPath("$.data.items[0].message").value("새 댓글이 달렸습니다."))
.andExpect(jsonPath("$.data.hasNext").value(false))
.andExpect(jsonPath("$.data.nextCreatedAt").doesNotExist());

Expand Down