Skip to content

Commit 5e9b9dc

Browse files
authored
Merge pull request #140 from SEMOSAN/feat/#139-semofeed-emoji-reactions
[Feat] 세모피드 조회 응답값 추가 및 세모피드 이모지 추가 및 감소 기능 추가
2 parents 074a8fd + 8aa39ea commit 5e9b9dc

18 files changed

Lines changed: 676 additions & 16 deletions

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
- `./gradlew build`
3636
- 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.
3737

38+
## Git
39+
- Commit messages must use the format `type: 한글 메시지`.
40+
- Keep the type in English, such as `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, and write the description in Korean.
41+
- Split database migration changes into a separate commit from application code changes.
42+
3843
## Editing Rules
3944
- Never revert or overwrite user changes unless explicitly requested.
4045
- Keep changes narrowly scoped to the requested task.

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public enum SuccessStatus implements BaseStatus {
7575
SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."),
7676
SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."),
7777
SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."),
78+
SEMOFEED_EMOJI_TOGGLE_SUCCESS(HttpStatus.OK, "SF_200_5", "세모피드 이모지 처리에 성공했습니다."),
7879
SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."),
7980

8081
/**

src/main/java/com/semosan/api/domain/semofeed/controller/SemoFeedController.java

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
import com.semosan.api.common.response.PageResponse;
55
import com.semosan.api.common.status.SuccessStatus;
66
import com.semosan.api.domain.semofeed.controller.docs.SemoFeedControllerDocs;
7+
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest;
8+
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse;
79
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
10+
import com.semosan.api.domain.semofeed.service.SemoFeedEmojiService;
811
import com.semosan.api.domain.semofeed.service.SemoFeedService;
12+
import jakarta.validation.Valid;
913
import lombok.RequiredArgsConstructor;
1014
import org.springframework.data.domain.PageRequest;
1115
import org.springframework.data.domain.Pageable;
@@ -22,6 +26,7 @@
2226
public class SemoFeedController implements SemoFeedControllerDocs {
2327

2428
private final SemoFeedService semoFeedService;
29+
private final SemoFeedEmojiService semoFeedEmojiService;
2530

2631
@PostMapping
2732
@Override
@@ -35,24 +40,37 @@ public ResponseEntity<ApiResponse<SemoFeedResponse>> create(
3540

3641
@GetMapping
3742
@Override
43+
// 공개 세모피드 목록을 작성자 정보와 이모지 상태까지 포함해 조회합니다.
3844
public ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
45+
@AuthenticationPrincipal Long userId,
3946
@PageableDefault(size = 100) Pageable pageable
4047
) {
4148
if (pageable.getPageSize() > 100) {
4249
pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort());
4350
}
44-
return ApiResponse.success(
45-
SuccessStatus.SEMOFEED_LIST_SUCCESS,
46-
PageResponse.from(semoFeedService.listPublic(pageable))
47-
);
51+
PageResponse<SemoFeedResponse> response = PageResponse.from(semoFeedService.listPublic(pageable, userId));
52+
return ApiResponse.success(SuccessStatus.SEMOFEED_LIST_SUCCESS, response);
53+
}
54+
55+
@PostMapping("/{semoFeedId}/emojis")
56+
@Override
57+
// 세모피드 이모지를 타입별로 등록하거나 취소합니다.
58+
public ResponseEntity<ApiResponse<SemoFeedEmojiToggleResponse>> toggleEmoji(
59+
@AuthenticationPrincipal Long userId,
60+
@PathVariable Long semoFeedId,
61+
@Valid @RequestBody SemoFeedEmojiRequest request
62+
) {
63+
SemoFeedEmojiToggleResponse response = semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType());
64+
return ApiResponse.success(SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, response);
4865
}
4966

5067
@GetMapping("/me")
5168
@Override
5269
public ResponseEntity<ApiResponse<List<SemoFeedResponse>>> listMine(
5370
@AuthenticationPrincipal Long userId
5471
) {
55-
return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, semoFeedService.listMine(userId));
72+
List<SemoFeedResponse> response = semoFeedService.listMine(userId);
73+
return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, response);
5674
}
5775

5876
@PatchMapping("/{semoFeedId}/public")

src/main/java/com/semosan/api/domain/semofeed/controller/docs/SemoFeedControllerDocs.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
import com.semosan.api.common.response.ApiResponse;
44
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest;
6+
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse;
57
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
68
import io.swagger.v3.oas.annotations.Operation;
79
import io.swagger.v3.oas.annotations.Parameter;
810
import io.swagger.v3.oas.annotations.media.Content;
911
import io.swagger.v3.oas.annotations.media.Schema;
1012
import io.swagger.v3.oas.annotations.responses.ApiResponses;
1113
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import jakarta.validation.Valid;
1215
import org.springframework.data.domain.Pageable;
1316
import org.springframework.data.web.PageableDefault;
1417
import org.springframework.http.ResponseEntity;
@@ -53,9 +56,33 @@ ResponseEntity<ApiResponse<SemoFeedResponse>> create(
5356
)
5457
})
5558
ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
59+
@AuthenticationPrincipal Long userId,
5660
@PageableDefault(size = 100) Pageable pageable
5761
);
5862

63+
@Operation(
64+
summary = "세모피드 이모지 토글",
65+
description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다."
66+
)
67+
@ApiResponses({
68+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
69+
responseCode = "200",
70+
description = "세모피드 이모지 처리 성공",
71+
content = @Content(schema = @Schema(implementation = SemoFeedEmojiToggleResponse.class))
72+
),
73+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
74+
responseCode = "404",
75+
description = "세모피드를 찾을 수 없음 (SF_404_1)",
76+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
77+
)
78+
})
79+
ResponseEntity<ApiResponse<SemoFeedEmojiToggleResponse>> toggleEmoji(
80+
@AuthenticationPrincipal Long userId,
81+
@Parameter(description = "세모피드 ID", required = true)
82+
@PathVariable Long semoFeedId,
83+
@Valid @RequestBody SemoFeedEmojiRequest request
84+
);
85+
5986
@Operation(
6087
summary = "내 세모피드 목록 조회",
6188
description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다."
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.semosan.api.domain.semofeed.dto;
2+
3+
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
4+
import jakarta.validation.constraints.NotNull;
5+
6+
public record SemoFeedEmojiRequest(
7+
@NotNull(message = "emojiType 은 필수입니다.")
8+
SemoFeedEmojiType emojiType
9+
) {
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.semosan.api.domain.semofeed.dto;
2+
3+
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
4+
5+
public record SemoFeedEmojiToggleResponse(
6+
SemoFeedEmojiType emojiType,
7+
boolean reacted,
8+
long count
9+
) {
10+
}
Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,65 @@
11
package com.semosan.api.domain.semofeed.dto;
22

33
import com.semosan.api.domain.semofeed.entity.SemoFeed;
4+
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
5+
6+
import java.util.EnumMap;
7+
import java.util.Map;
48

59
public record SemoFeedResponse(
610
Long id,
11+
Long userId,
12+
String profileUrl,
13+
String nickname,
714
String imageUrl,
8-
boolean isPublic
15+
boolean isPublic,
16+
Map<SemoFeedEmojiType, Long> emojiCounts,
17+
Map<SemoFeedEmojiType, Boolean> reactedByMe,
18+
boolean mine
919
) {
1020
public static SemoFeedResponse from(SemoFeed semoFeed) {
21+
return of(
22+
semoFeed,
23+
defaultEmojiCounts(),
24+
defaultReactedByMe(),
25+
true
26+
);
27+
}
28+
29+
public static SemoFeedResponse of(
30+
SemoFeed semoFeed,
31+
Map<SemoFeedEmojiType, Long> emojiCounts,
32+
Map<SemoFeedEmojiType, Boolean> reactedByMe,
33+
boolean mine
34+
) {
1135
return new SemoFeedResponse(
1236
semoFeed.getId(),
37+
semoFeed.getUser().getId(),
38+
semoFeed.getUser().getProfileUrl(),
39+
semoFeed.getUser().displayName(),
1340
semoFeed.getImageUrl(),
14-
semoFeed.isPublic()
41+
semoFeed.isPublic(),
42+
emojiCounts,
43+
reactedByMe,
44+
mine
1545
);
1646
}
47+
48+
// 새로 생성된 세모피드 응답에 기본 이모지 개수를 채웁니다.
49+
private static Map<SemoFeedEmojiType, Long> defaultEmojiCounts() {
50+
Map<SemoFeedEmojiType, Long> emojiCounts = new EnumMap<>(SemoFeedEmojiType.class);
51+
for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) {
52+
emojiCounts.put(emojiType, 0L);
53+
}
54+
return emojiCounts;
55+
}
56+
57+
// 새로 생성된 세모피드 응답에 기본 내 반응 상태를 채웁니다.
58+
private static Map<SemoFeedEmojiType, Boolean> defaultReactedByMe() {
59+
Map<SemoFeedEmojiType, Boolean> reactedByMe = new EnumMap<>(SemoFeedEmojiType.class);
60+
for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) {
61+
reactedByMe.put(emojiType, false);
62+
}
63+
return reactedByMe;
64+
}
1765
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.semosan.api.domain.semofeed.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
5+
import com.semosan.api.domain.user.entity.User;
6+
import jakarta.persistence.*;
7+
import lombok.AccessLevel;
8+
import lombok.AllArgsConstructor;
9+
import lombok.Builder;
10+
import lombok.Getter;
11+
import lombok.NoArgsConstructor;
12+
13+
@Table(
14+
name = "semo_feed_emojis",
15+
uniqueConstraints = {
16+
@UniqueConstraint(
17+
name = "uk_semo_feed_emoji_feed_user_type",
18+
columnNames = {"semo_feed_id", "user_id", "emoji_type"}
19+
)
20+
},
21+
indexes = {
22+
@Index(name = "idx_semo_feed_emojis_feed", columnList = "semo_feed_id"),
23+
@Index(name = "idx_semo_feed_emojis_user", columnList = "user_id")
24+
}
25+
)
26+
@Getter
27+
@Entity
28+
@Builder(access = AccessLevel.PROTECTED)
29+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
30+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
31+
public class SemoFeedEmoji extends BaseEntity {
32+
33+
@Id
34+
@GeneratedValue(strategy = GenerationType.IDENTITY)
35+
private Long id;
36+
37+
@ManyToOne(fetch = FetchType.LAZY)
38+
@JoinColumn(name = "semo_feed_id", nullable = false)
39+
private SemoFeed semoFeed;
40+
41+
@ManyToOne(fetch = FetchType.LAZY)
42+
@JoinColumn(name = "user_id", nullable = false)
43+
private User user;
44+
45+
@Enumerated(EnumType.STRING)
46+
@Column(name = "emoji_type", nullable = false, length = 20)
47+
private SemoFeedEmojiType emojiType;
48+
49+
public static SemoFeedEmoji create(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) {
50+
return SemoFeedEmoji.builder()
51+
.semoFeed(semoFeed)
52+
.user(user)
53+
.emojiType(emojiType)
54+
.build();
55+
}
56+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.semosan.api.domain.semofeed.enums;
2+
3+
public enum SemoFeedEmojiType {
4+
FIRE,
5+
HEART,
6+
CONGRATS,
7+
LAUGH
8+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.semosan.api.domain.semofeed.repository;
2+
3+
import com.semosan.api.domain.semofeed.entity.SemoFeed;
4+
import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji;
5+
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
6+
import com.semosan.api.domain.user.entity.User;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
10+
11+
import java.util.List;
12+
import java.util.Optional;
13+
14+
public interface SemoFeedEmojiRepository extends JpaRepository<SemoFeedEmoji, Long> {
15+
16+
Optional<SemoFeedEmoji> findBySemoFeedAndUserAndEmojiType(
17+
SemoFeed semoFeed,
18+
User user,
19+
SemoFeedEmojiType emojiType
20+
);
21+
22+
long countBySemoFeedAndEmojiType(SemoFeed semoFeed, SemoFeedEmojiType emojiType);
23+
24+
@Query("""
25+
SELECT e.semoFeed.id, e.emojiType, COUNT(e)
26+
FROM SemoFeedEmoji e
27+
WHERE e.semoFeed.id IN :semoFeedIds
28+
GROUP BY e.semoFeed.id, e.emojiType
29+
""")
30+
List<Object[]> countBySemoFeedIdsGrouped(@Param("semoFeedIds") List<Long> semoFeedIds);
31+
32+
@Query("""
33+
SELECT e.semoFeed.id, e.emojiType
34+
FROM SemoFeedEmoji e
35+
WHERE e.semoFeed.id IN :semoFeedIds
36+
AND e.user.id = :userId
37+
""")
38+
List<Object[]> findReactedTypesBySemoFeedIdsAndUserId(
39+
@Param("semoFeedIds") List<Long> semoFeedIds,
40+
@Param("userId") Long userId
41+
);
42+
}

0 commit comments

Comments
 (0)