diff --git a/AGENTS.md b/AGENTS.md index 0b76ab66..7c97f6f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,11 @@ - `./gradlew build` - If full tests fail because local infrastructure such as PostgreSQL or Redis is unavailable, report that clearly and distinguish it from failures caused by the code change. +## Git +- Commit messages must use the format `type: 한글 메시지`. +- Keep the type in English, such as `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, and write the description in Korean. +- Split database migration changes into a separate commit from application code changes. + ## Editing Rules - Never revert or overwrite user changes unless explicitly requested. - Keep changes narrowly scoped to the requested task. diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index a55c23fa..6040440f 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -75,6 +75,7 @@ public enum SuccessStatus implements BaseStatus { SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."), SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."), SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."), + SEMOFEED_EMOJI_TOGGLE_SUCCESS(HttpStatus.OK, "SF_200_5", "세모피드 이모지 처리에 성공했습니다."), SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."), /** diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java index 0155fb89..ea9b0d2a 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java @@ -4,8 +4,12 @@ import com.semosan.api.common.response.PageResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.semofeed.controller.docs.SemoFeedControllerDocs; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; +import com.semosan.api.domain.semofeed.service.SemoFeedEmojiService; import com.semosan.api.domain.semofeed.service.SemoFeedService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -22,6 +26,7 @@ public class SemoFeedController implements SemoFeedControllerDocs { private final SemoFeedService semoFeedService; + private final SemoFeedEmojiService semoFeedEmojiService; @PostMapping @Override @@ -35,16 +40,28 @@ public ResponseEntity> create( @GetMapping @Override + // 공개 세모피드 목록을 작성자 정보와 이모지 상태까지 포함해 조회합니다. public ResponseEntity>> listPublic( + @AuthenticationPrincipal Long userId, @PageableDefault(size = 100) Pageable pageable ) { if (pageable.getPageSize() > 100) { pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort()); } - return ApiResponse.success( - SuccessStatus.SEMOFEED_LIST_SUCCESS, - PageResponse.from(semoFeedService.listPublic(pageable)) - ); + PageResponse response = PageResponse.from(semoFeedService.listPublic(pageable, userId)); + return ApiResponse.success(SuccessStatus.SEMOFEED_LIST_SUCCESS, response); + } + + @PostMapping("/{semoFeedId}/emojis") + @Override + // 세모피드 이모지를 타입별로 등록하거나 취소합니다. + public ResponseEntity> toggleEmoji( + @AuthenticationPrincipal Long userId, + @PathVariable Long semoFeedId, + @Valid @RequestBody SemoFeedEmojiRequest request + ) { + SemoFeedEmojiToggleResponse response = semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType()); + return ApiResponse.success(SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, response); } @GetMapping("/me") @@ -52,7 +69,8 @@ public ResponseEntity>> listPublic( public ResponseEntity>> listMine( @AuthenticationPrincipal Long userId ) { - return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, semoFeedService.listMine(userId)); + List response = semoFeedService.listMine(userId); + return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, response); } @PatchMapping("/{semoFeedId}/public") diff --git a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java index b010feb2..60fb9ece 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java @@ -2,6 +2,8 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.response.PageResponse; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -9,6 +11,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -53,9 +56,33 @@ ResponseEntity> create( ) }) ResponseEntity>> listPublic( + @AuthenticationPrincipal Long userId, @PageableDefault(size = 100) Pageable pageable ); + @Operation( + summary = "세모피드 이모지 토글", + description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "세모피드 이모지 처리 성공", + content = @Content(schema = @Schema(implementation = SemoFeedEmojiToggleResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "세모피드를 찾을 수 없음 (SF_404_1)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> toggleEmoji( + @AuthenticationPrincipal Long userId, + @Parameter(description = "세모피드 ID", required = true) + @PathVariable Long semoFeedId, + @Valid @RequestBody SemoFeedEmojiRequest request + ); + @Operation( summary = "내 세모피드 목록 조회", description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다." diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java new file mode 100644 index 00000000..bb236449 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiRequest.java @@ -0,0 +1,10 @@ +package com.semosan.api.domain.semofeed.dto; + +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import jakarta.validation.constraints.NotNull; + +public record SemoFeedEmojiRequest( + @NotNull(message = "emojiType 은 필수입니다.") + SemoFeedEmojiType emojiType +) { +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java new file mode 100644 index 00000000..f2aafaa6 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedEmojiToggleResponse.java @@ -0,0 +1,10 @@ +package com.semosan.api.domain.semofeed.dto; + +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; + +public record SemoFeedEmojiToggleResponse( + SemoFeedEmojiType emojiType, + boolean reacted, + long count +) { +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java index 51b50310..39be861d 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java +++ b/src/main/java/com/semosan/api/domain/semofeed/dto/SemoFeedResponse.java @@ -1,17 +1,65 @@ package com.semosan.api.domain.semofeed.dto; import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; + +import java.util.EnumMap; +import java.util.Map; public record SemoFeedResponse( Long id, + Long userId, + String profileUrl, + String nickname, String imageUrl, - boolean isPublic + boolean isPublic, + Map emojiCounts, + Map reactedByMe, + boolean mine ) { public static SemoFeedResponse from(SemoFeed semoFeed) { + return of( + semoFeed, + defaultEmojiCounts(), + defaultReactedByMe(), + true + ); + } + + public static SemoFeedResponse of( + SemoFeed semoFeed, + Map emojiCounts, + Map reactedByMe, + boolean mine + ) { return new SemoFeedResponse( semoFeed.getId(), + semoFeed.getUser().getId(), + semoFeed.getUser().getProfileUrl(), + semoFeed.getUser().displayName(), semoFeed.getImageUrl(), - semoFeed.isPublic() + semoFeed.isPublic(), + emojiCounts, + reactedByMe, + mine ); } + + // 새로 생성된 세모피드 응답에 기본 이모지 개수를 채웁니다. + private static Map defaultEmojiCounts() { + Map emojiCounts = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + emojiCounts.put(emojiType, 0L); + } + return emojiCounts; + } + + // 새로 생성된 세모피드 응답에 기본 내 반응 상태를 채웁니다. + private static Map defaultReactedByMe() { + Map reactedByMe = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + reactedByMe.put(emojiType, false); + } + return reactedByMe; + } } diff --git a/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java b/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java new file mode 100644 index 00000000..cb91f969 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeedEmoji.java @@ -0,0 +1,56 @@ +package com.semosan.api.domain.semofeed.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table( + name = "semo_feed_emojis", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_semo_feed_emoji_feed_user_type", + columnNames = {"semo_feed_id", "user_id", "emoji_type"} + ) + }, + indexes = { + @Index(name = "idx_semo_feed_emojis_feed", columnList = "semo_feed_id"), + @Index(name = "idx_semo_feed_emojis_user", columnList = "user_id") + } +) +@Getter +@Entity +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SemoFeedEmoji extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "semo_feed_id", nullable = false) + private SemoFeed semoFeed; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "emoji_type", nullable = false, length = 20) + private SemoFeedEmojiType emojiType; + + public static SemoFeedEmoji create(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) { + return SemoFeedEmoji.builder() + .semoFeed(semoFeed) + .user(user) + .emojiType(emojiType) + .build(); + } +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java b/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java new file mode 100644 index 00000000..8b457dad --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/enums/SemoFeedEmojiType.java @@ -0,0 +1,8 @@ +package com.semosan.api.domain.semofeed.enums; + +public enum SemoFeedEmojiType { + FIRE, + HEART, + CONGRATS, + LAUGH +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java new file mode 100644 index 00000000..eb8da0b6 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedEmojiRepository.java @@ -0,0 +1,42 @@ +package com.semosan.api.domain.semofeed.repository; + +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface SemoFeedEmojiRepository extends JpaRepository { + + Optional findBySemoFeedAndUserAndEmojiType( + SemoFeed semoFeed, + User user, + SemoFeedEmojiType emojiType + ); + + long countBySemoFeedAndEmojiType(SemoFeed semoFeed, SemoFeedEmojiType emojiType); + + @Query(""" + SELECT e.semoFeed.id, e.emojiType, COUNT(e) + FROM SemoFeedEmoji e + WHERE e.semoFeed.id IN :semoFeedIds + GROUP BY e.semoFeed.id, e.emojiType + """) + List countBySemoFeedIdsGrouped(@Param("semoFeedIds") List semoFeedIds); + + @Query(""" + SELECT e.semoFeed.id, e.emojiType + FROM SemoFeedEmoji e + WHERE e.semoFeed.id IN :semoFeedIds + AND e.user.id = :userId + """) + List findReactedTypesBySemoFeedIdsAndUserId( + @Param("semoFeedIds") List semoFeedIds, + @Param("userId") Long userId + ); +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java index 25015fea..7d42b926 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java +++ b/src/main/java/com/semosan/api/domain/semofeed/repository/SemoFeedRepository.java @@ -10,9 +10,12 @@ public interface SemoFeedRepository extends JpaRepository { - @Query("SELECT s FROM SemoFeed s WHERE s.isPublic = true ORDER BY s.createdAt DESC") + @Query( + value = "SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.isPublic = true ORDER BY s.createdAt DESC", + countQuery = "SELECT COUNT(s) FROM SemoFeed s WHERE s.isPublic = true" + ) Page findPublic(Pageable pageable); - @Query("SELECT s FROM SemoFeed s WHERE s.user.id = :userId ORDER BY s.createdAt DESC") + @Query("SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.user.id = :userId ORDER BY s.createdAt DESC") List findByUserId(Long userId); } diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java new file mode 100644 index 00000000..00f1cb26 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiService.java @@ -0,0 +1,67 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserReader; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SemoFeedEmojiService { + + private final SemoFeedEmojiRepository semoFeedEmojiRepository; + private final SemoFeedRepository semoFeedRepository; + private final UserReader userReader; + + @Transactional + // 이모지 반응을 토글하고 변경 후 해당 타입의 개수를 반환합니다. + public SemoFeedEmojiToggleResponse toggleWithCount( + Long userId, + Long semoFeedId, + SemoFeedEmojiType emojiType + ) { + SemoFeed semoFeed = findSemoFeedOrThrow(semoFeedId); + User user = userReader.findActiveUserById(userId); + + boolean reacted = toggle(semoFeed, user, emojiType); + long count = semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, emojiType); + + return new SemoFeedEmojiToggleResponse(emojiType, reacted, count); + } + + // 기존 반응이 있으면 취소하고, 없으면 새 반응을 저장합니다. + private boolean toggle(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) { + Optional existing = semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + user, + emojiType + ); + + if (existing.isPresent()) { + semoFeedEmojiRepository.delete(existing.get()); + return false; + } + + semoFeedEmojiRepository.save(SemoFeedEmoji.create(semoFeed, user, emojiType)); + return true; + } + + // 세모피드가 없으면 도메인 예외를 던집니다. + private SemoFeed findSemoFeedOrThrow(Long semoFeedId) { + return semoFeedRepository.findById(semoFeedId) + .orElseThrow(() -> new GeneralException(ErrorStatus.SEMOFEED_NOT_FOUND)); + } + +} diff --git a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java index 8239cbbe..3910ec42 100644 --- a/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java +++ b/src/main/java/com/semosan/api/domain/semofeed/service/SemoFeedService.java @@ -4,6 +4,8 @@ import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.repository.UserRepository; @@ -14,6 +16,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.EnumMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -21,6 +27,7 @@ public class SemoFeedService { private final SemoFeedRepository semoFeedRepository; + private final SemoFeedEmojiRepository semoFeedEmojiRepository; private final UserRepository userRepository; @Transactional @@ -31,17 +38,20 @@ public SemoFeedResponse create(Long userId, String imageUrl) { return SemoFeedResponse.from(semoFeedRepository.save(semoFeed)); } - public Page listPublic(Pageable pageable) { - return semoFeedRepository.findPublic(pageable) - .map(SemoFeedResponse::from); - } - public List listMine(Long userId) { - return semoFeedRepository.findByUserId(userId).stream() - .map(SemoFeedResponse::from) + List semoFeeds = semoFeedRepository.findByUserId(userId); + SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds, userId); + return semoFeeds.stream() + .map(assembler::toResponse) .toList(); } + // 공개 세모피드 목록을 N+1 없이 응답 DTO로 조립합니다. + public Page listPublic(Pageable pageable, Long userId) { + Page semoFeeds = semoFeedRepository.findPublic(pageable); + return toResponsePage(semoFeeds, userId); + } + @Transactional public boolean togglePublic(Long userId, Long semoFeedId) { SemoFeed semoFeed = findOwned(userId, semoFeedId); @@ -63,4 +73,86 @@ private SemoFeed findOwned(Long userId, Long semoFeedId) { } return semoFeed; } + + // 페이지 메타데이터를 유지하면서 각 세모피드를 응답 DTO로 변환합니다. + private Page toResponsePage(Page semoFeeds, Long userId) { + SemoFeedResponseAssembler assembler = createResponseAssembler(semoFeeds.getContent(), userId); + return semoFeeds.map(assembler::toResponse); + } + + // 피드별 이모지 집계와 내 반응 정보를 한 번씩 조회해 조립기를 만듭니다. + private SemoFeedResponseAssembler createResponseAssembler(List semoFeeds, Long userId) { + List semoFeedIds = semoFeeds.stream() + .map(SemoFeed::getId) + .toList(); + + Map> emojiCounts = findEmojiCounts(semoFeedIds); + Map> reactedTypes = findReactedTypes(semoFeedIds, userId); + + return new SemoFeedResponseAssembler(userId, emojiCounts, reactedTypes); + } + + // 피드 ID 목록 기준으로 이모지 타입별 개수를 한 번에 조회합니다. + private Map> findEmojiCounts(List semoFeedIds) { + if (semoFeedIds.isEmpty()) { + return Map.of(); + } + + return semoFeedEmojiRepository.countBySemoFeedIdsGrouped(semoFeedIds).stream() + .collect(Collectors.groupingBy( + row -> (Long) row[0], + Collectors.toMap( + row -> (SemoFeedEmojiType) row[1], + row -> (Long) row[2] + ) + )); + } + + // 피드 ID 목록 기준으로 로그인 사용자가 누른 이모지 타입을 한 번에 조회합니다. + private Map> findReactedTypes(List semoFeedIds, Long userId) { + if (semoFeedIds.isEmpty() || userId == null) { + return Map.of(); + } + + return semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(semoFeedIds, userId).stream() + .collect(Collectors.groupingBy( + row -> (Long) row[0], + Collectors.mapping(row -> (SemoFeedEmojiType) row[1], Collectors.toSet()) + )); + } + + private record SemoFeedResponseAssembler( + Long userId, + Map> emojiCounts, + Map> reactedTypes + ) { + + // 세모피드 엔티티와 집계 결과를 최종 응답 DTO로 변환합니다. + private SemoFeedResponse toResponse(SemoFeed semoFeed) { + return SemoFeedResponse.of( + semoFeed, + completeEmojiCounts(emojiCounts.get(semoFeed.getId())), + completeReactedByMe(reactedTypes.get(semoFeed.getId())), + userId != null && semoFeed.isOwnedBy(userId) + ); + } + + // 누락된 이모지 타입도 0으로 채워 응답 계약을 일정하게 유지합니다. + private Map completeEmojiCounts(Map counts) { + Map completeCounts = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + completeCounts.put(emojiType, counts == null ? 0L : counts.getOrDefault(emojiType, 0L)); + } + return completeCounts; + } + + // 누락된 이모지 타입도 false로 채워 응답 계약을 일정하게 유지합니다. + private Map completeReactedByMe(Set reactedTypes) { + Map completeReactedTypes = new EnumMap<>(SemoFeedEmojiType.class); + for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) { + completeReactedTypes.put(emojiType, reactedTypes != null && reactedTypes.contains(emojiType)); + } + return completeReactedTypes; + } + } } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserReader.java b/src/main/java/com/semosan/api/domain/user/service/UserReader.java index 48e0a379..626123d8 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserReader.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserReader.java @@ -20,6 +20,9 @@ public class UserReader { @Transactional(readOnly = true) public User findActiveUserById(Long userId) { + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_NOT_FOUND); + } return userRepository.findByIdAndDeletedFalse(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); } diff --git a/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql b/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql new file mode 100644 index 00000000..7d692ad6 --- /dev/null +++ b/src/main/resources/db/migration/V24__create_semo_feed_emojis.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS semo_feed_emojis +( + id BIGSERIAL PRIMARY KEY, + semo_feed_id BIGINT NOT NULL REFERENCES semo_feeds (id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users (id), + emoji_type VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT uk_semo_feed_emoji_feed_user_type UNIQUE (semo_feed_id, user_id, emoji_type), + CONSTRAINT ck_semo_feed_emojis_type CHECK (emoji_type IN ('FIRE', 'HEART', 'CONGRATS', 'LAUGH')) +); + +CREATE INDEX IF NOT EXISTS idx_semo_feed_emojis_feed + ON semo_feed_emojis (semo_feed_id); + +CREATE INDEX IF NOT EXISTS idx_semo_feed_emojis_user + ON semo_feed_emojis (user_id); diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java new file mode 100644 index 00000000..fda920b5 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedEmojiServiceTest.java @@ -0,0 +1,107 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.service.UserReader; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SemoFeedEmojiServiceTest { + + @Mock + private SemoFeedEmojiRepository semoFeedEmojiRepository; + + @Mock + private SemoFeedRepository semoFeedRepository; + + @Mock + private UserReader userReader; + + @InjectMocks + private SemoFeedEmojiService semoFeedEmojiService; + + @Test + void toggleWithCountCreatesEmojiReaction() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + + when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); + when(userReader.findActiveUserById(2L)).thenReturn(reactor); + when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + reactor, + SemoFeedEmojiType.FIRE + )).thenReturn(Optional.empty()); + when(semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, SemoFeedEmojiType.FIRE)).thenReturn(1L); + + SemoFeedEmojiToggleResponse result = semoFeedEmojiService.toggleWithCount( + 2L, + 10L, + SemoFeedEmojiType.FIRE + ); + + assertThat(result.emojiType()).isEqualTo(SemoFeedEmojiType.FIRE); + assertThat(result.reacted()).isTrue(); + assertThat(result.count()).isEqualTo(1L); + verify(semoFeedEmojiRepository).save(any(SemoFeedEmoji.class)); + } + + @Test + void toggleWithCountDeletesExistingEmojiReaction() { + User author = user(1L, "author"); + User reactor = user(2L, "reactor"); + SemoFeed semoFeed = semoFeed(10L, author); + SemoFeedEmoji existing = SemoFeedEmoji.create(semoFeed, reactor, SemoFeedEmojiType.LAUGH); + + when(semoFeedRepository.findById(10L)).thenReturn(Optional.of(semoFeed)); + when(userReader.findActiveUserById(2L)).thenReturn(reactor); + when(semoFeedEmojiRepository.findBySemoFeedAndUserAndEmojiType( + semoFeed, + reactor, + SemoFeedEmojiType.LAUGH + )).thenReturn(Optional.of(existing)); + when(semoFeedEmojiRepository.countBySemoFeedAndEmojiType(semoFeed, SemoFeedEmojiType.LAUGH)).thenReturn(0L); + + SemoFeedEmojiToggleResponse result = semoFeedEmojiService.toggleWithCount( + 2L, + 10L, + SemoFeedEmojiType.LAUGH + ); + + assertThat(result.reacted()).isFalse(); + assertThat(result.count()).isZero(); + verify(semoFeedEmojiRepository).delete(existing); + } + + private User user(Long id, String nickname) { + User user = User.createTestUser(nickname, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "nickname", nickname); + return user; + } + + private SemoFeed semoFeed(Long id, User user) { + SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png"); + ReflectionTestUtils.setField(semoFeed, "id", id); + return semoFeed; + } +} diff --git a/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java new file mode 100644 index 00000000..fc624d55 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/semofeed/service/SemoFeedServiceTest.java @@ -0,0 +1,137 @@ +package com.semosan.api.domain.semofeed.service; + +import com.semosan.api.domain.semofeed.dto.SemoFeedResponse; +import com.semosan.api.domain.semofeed.entity.SemoFeed; +import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType; +import com.semosan.api.domain.semofeed.repository.SemoFeedEmojiRepository; +import com.semosan.api.domain.semofeed.repository.SemoFeedRepository; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.user.DeviceType; +import com.semosan.api.domain.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SemoFeedServiceTest { + + @Mock + private SemoFeedRepository semoFeedRepository; + + @Mock + private SemoFeedEmojiRepository semoFeedEmojiRepository; + + @Mock + private UserRepository userRepository; + + @InjectMocks + private SemoFeedService semoFeedService; + + @Test + void createReturnsDefaultEmojiFields() { + User user = user(1L, "author", "https://example.com/profile.png"); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(semoFeedRepository.save(any(SemoFeed.class))).thenAnswer(invocation -> { + SemoFeed semoFeed = invocation.getArgument(0); + ReflectionTestUtils.setField(semoFeed, "id", 10L); + return semoFeed; + }); + + SemoFeedResponse result = semoFeedService.create(1L, "https://example.com/semofeed.png"); + + assertThat(result.userId()).isEqualTo(1L); + assertThat(result.mine()).isTrue(); + assertThat(result.emojiCounts()) + .containsEntry(SemoFeedEmojiType.FIRE, 0L) + .containsEntry(SemoFeedEmojiType.HEART, 0L) + .containsEntry(SemoFeedEmojiType.CONGRATS, 0L) + .containsEntry(SemoFeedEmojiType.LAUGH, 0L); + assertThat(result.reactedByMe()) + .containsEntry(SemoFeedEmojiType.FIRE, false) + .containsEntry(SemoFeedEmojiType.HEART, false) + .containsEntry(SemoFeedEmojiType.CONGRATS, false) + .containsEntry(SemoFeedEmojiType.LAUGH, false); + } + + @Test + void listPublicBuildsAuthorEmojiCountsAndReactedByMe() { + User author = user(1L, "author", "https://example.com/profile.png"); + User me = user(2L, "me", null); + SemoFeed semoFeed = semoFeed(10L, author); + PageRequest pageable = PageRequest.of(0, 100); + + when(semoFeedRepository.findPublic(pageable)).thenReturn(new PageImpl<>(List.of(semoFeed), pageable, 1)); + when(semoFeedEmojiRepository.countBySemoFeedIdsGrouped(List.of(10L))).thenReturn(List.of( + new Object[]{10L, SemoFeedEmojiType.FIRE, 14L}, + new Object[]{10L, SemoFeedEmojiType.LAUGH, 3L} + )); + when(semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId())) + .thenReturn(List.of(new Object[]{10L, SemoFeedEmojiType.FIRE})); + + Page result = semoFeedService.listPublic(pageable, me.getId()); + + SemoFeedResponse response = result.getContent().getFirst(); + assertThat(response.userId()).isEqualTo(author.getId()); + assertThat(response.profileUrl()).isEqualTo("https://example.com/profile.png"); + assertThat(response.nickname()).isEqualTo("author"); + assertThat(response.emojiCounts()) + .containsEntry(SemoFeedEmojiType.FIRE, 14L) + .containsEntry(SemoFeedEmojiType.HEART, 0L) + .containsEntry(SemoFeedEmojiType.CONGRATS, 0L) + .containsEntry(SemoFeedEmojiType.LAUGH, 3L); + assertThat(response.reactedByMe()) + .containsEntry(SemoFeedEmojiType.FIRE, true) + .containsEntry(SemoFeedEmojiType.HEART, false) + .containsEntry(SemoFeedEmojiType.CONGRATS, false) + .containsEntry(SemoFeedEmojiType.LAUGH, false); + assertThat(response.mine()).isFalse(); + + verify(semoFeedEmojiRepository).countBySemoFeedIdsGrouped(List.of(10L)); + verify(semoFeedEmojiRepository).findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId()); + } + + @Test + void listPublicMarksMineWhenFeedAuthorIsMe() { + User me = user(1L, "me", null); + SemoFeed semoFeed = semoFeed(10L, me); + PageRequest pageable = PageRequest.of(0, 100); + + when(semoFeedRepository.findPublic(pageable)).thenReturn(new PageImpl<>(List.of(semoFeed), pageable, 1)); + when(semoFeedEmojiRepository.countBySemoFeedIdsGrouped(List.of(10L))).thenReturn(List.of()); + when(semoFeedEmojiRepository.findReactedTypesBySemoFeedIdsAndUserId(List.of(10L), me.getId())) + .thenReturn(List.of()); + + Page result = semoFeedService.listPublic(pageable, me.getId()); + + assertThat(result.getContent().getFirst().mine()).isTrue(); + } + + private User user(Long id, String nickname, String profileUrl) { + User user = User.createTestUser(nickname, DeviceType.IOS); + ReflectionTestUtils.setField(user, "id", id); + ReflectionTestUtils.setField(user, "nickname", nickname); + ReflectionTestUtils.setField(user, "profileUrl", profileUrl); + return user; + } + + private SemoFeed semoFeed(Long id, User user) { + SemoFeed semoFeed = SemoFeed.create(user, "https://example.com/semofeed.png"); + ReflectionTestUtils.setField(semoFeed, "id", id); + return semoFeed; + } +} diff --git a/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java b/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java index 6ec3e41c..23a65f51 100644 --- a/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java +++ b/src/test/java/com/semosan/api/domain/user/service/UserReaderTest.java @@ -35,6 +35,15 @@ class UserReaderTest { @Mock private UserOnboardingRepository userOnboardingRepository; + @Test + void findActiveUserByIdThrowsWhenUserIdIsNull() { + assertThatThrownBy(() -> new UserReader(userRepository, userOnboardingRepository) + .findActiveUserById(null)) + .isInstanceOf(GeneralException.class) + .extracting("errorStatus") + .isEqualTo(ErrorStatus.USER_NOT_FOUND); + } + @Test void findCompletedOnboardingByUserIdReturnsOnboardingWhenUserIsCompleted() { User user = user();