Skip to content

Commit a886729

Browse files
authored
Merge pull request #94 from Money-Touch/feat/#89
[#89]✨ Feat: 피드 관련 기능 구현
2 parents abd3afa + ee19b68 commit a886729

File tree

10 files changed

+456
-20
lines changed

10 files changed

+456
-20
lines changed

src/main/java/com/server/money_touch/domain/consumptionRecord/controller/FeedController.java

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.server.money_touch.domain.consumptionRecord.dto.FeedRequest;
44
import com.server.money_touch.domain.consumptionRecord.dto.FeedResponse;
5+
import com.server.money_touch.domain.consumptionRecord.enums.FeedSortType;
6+
import com.server.money_touch.domain.consumptionRecord.enums.MyFeedViewType;
57
import com.server.money_touch.domain.consumptionRecord.service.comment.CommentLikeService;
68
import com.server.money_touch.domain.consumptionRecord.service.comment.CommentService;
79
import com.server.money_touch.domain.consumptionRecord.service.feed.FeedService;
@@ -41,17 +43,35 @@ public class FeedController {
4143
// 피드 홈 (피드 리스트) 조회
4244
@Operation(
4345
summary = "피드 홈(피드 리스트) API",
44-
description = "공개된 소비 기록(가계부에만 등록하지 않은 소비기록) 피드를 조회하는 API입니다"
46+
description = """
47+
공개된 소비기록(가계부에만 등록하지 않은 소비기록) 피드를 커서 기반 무한스크롤 방식으로 조회합니다.
48+
49+
정렬 방식:
50+
- RECENT (기본값): 가장 최근에 생성된 게시물부터 정렬됩니다. (ID 내림차순)
51+
- POPULAR: 조회수가 높은 게시물부터 정렬됩니다. 조회수가 같을 경우 ID가 더 큰 게시물이 우선입니다.
52+
53+
커서 방식:
54+
- 최신순(RECENT)일 경우 → cursorId 사용
55+
- 조회수순(POPULAR)일 경우 → cursorViewCount + cursorId 함께 사용
56+
"""
4557
)
46-
// @ApiSuccessCodeExample(resultClass = NotificationResponse.NotificationListDTO.class)
4758
@ApiErrorCodeExamples({
4859
@ApiErrorCodeExample(value = ErrorStatus.class, name = "USER_NOT_FOUND"),
4960
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"),
5061
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_INTERNAL_SERVER_ERROR"),
5162
})
5263
@GetMapping("/home")
53-
public ApiResponse<FeedResponse.FeedListResultDTO> getFeedList() {
54-
FeedResponse.FeedListResultDTO response = FeedResponse.FeedListResultDTO.builder().build();
64+
public ApiResponse<FeedResponse.FeedListResultDTO> getFeedList(
65+
HttpServletRequest request,
66+
@RequestParam(name = "sortType", defaultValue = "RECENT") FeedSortType sortType,
67+
@RequestParam(name = "cursorId", required = false) Long cursorId,
68+
@RequestParam(name = "cursorViewCount", required = false) Integer cursorViewCount
69+
) {
70+
Long userId = authUtil.getUserIdFromRequest(request);
71+
72+
FeedResponse.FeedListResultDTO response =
73+
feedService.getFeedsByCursor(userId, sortType, cursorId, cursorViewCount);
74+
5575
return ApiResponse.onSuccess(response);
5676
}
5777

@@ -82,7 +102,11 @@ public ApiResponse<FeedResponse.FeedDetailResultDTO> getFeedDetail(
82102
// 마이페이지 - 내 피드 모아보기
83103
@Operation(
84104
summary = "내 피드 조회 API",
85-
description = "마이페이지에 있는 My 피드를 눌러 현재 사용자의 소비 기록 피드를 조회하는 API 입니다."
105+
description = "마이페이지에 있는 My 피드를 눌러 현재 사용자의 소비 기록 피드를 조회하는 API 입니다." +
106+
"- viewMode에 따라 카드형(CARD) 또는 리스트형(LIST)으로 데이터를 반환합니다. \n" +
107+
" - 카드형(CARD): pagesize : 20" +
108+
" - 리스트형(LIST): pagesize : 5" +
109+
"- 커서 기반 무한 스크롤 방식으로 페이징되며, cursorId가 없는 경우 첫 페이지로 간주됩니다."
86110
)
87111
// @ApiSuccessCodeExample(resultClass = FeedResponse.FeedListResultDTO.class)
88112
@ApiErrorCodeExamples({
@@ -91,10 +115,44 @@ public ApiResponse<FeedResponse.FeedDetailResultDTO> getFeedDetail(
91115
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_INTERNAL_SERVER_ERROR")
92116
})
93117
@GetMapping("/my")
94-
public ApiResponse<FeedResponse.FeedListResultDTO> getMyFeed(HttpServletRequest servletrequest){
118+
public ApiResponse<FeedResponse.MyFeedListResultDTO> getMyFeed(
119+
HttpServletRequest servletrequest,
120+
@RequestParam(name = "viewMode") MyFeedViewType viewMode,
121+
@RequestParam(name = "cursorId", required = false) Long cursorId
122+
){
123+
Long userId = authUtil.getUserIdFromRequest(servletrequest);
124+
FeedResponse.MyFeedListResultDTO response =feedService.getMyFeedsByCursor(userId, viewMode, cursorId);
125+
return ApiResponse.onSuccess(response);
126+
}
95127

128+
// 마이페이지 - 내 피드 모아보기
129+
@Operation(
130+
summary = "유저 닉네임 기반 게시글 검색 API",
131+
description = "유저 닉네임에 키워드가 포함된 게시글을 검색하는 API입니다.\n"
132+
+ "- 검색 대상: 공개된 소비 기록만 조회됩니다.\n"
133+
+ "- 검색 조건: 사용자 닉네임에 키워드가 포함될 경우 해당 사용자의 게시글을 반환합니다.\n"
134+
+ "- 정렬: 최신순으로 정렬됩니다.\n"
135+
+ "- 커서 기반 무한스크롤 방식 지원 (cursorId 파라미터 사용)\n\n"
136+
+ "✅ 예시\n"
137+
+ "- keyword = \"\" → 닉네임이 \"유진\", \"김유라\", \"유리\" 등인 유저의 게시글 반환\n"
138+
+ "- cursorId = null → 첫 페이지 요청\n"
139+
+ "- cursorId = 18 → ID가 18보다 작은 게시글부터 다음 페이지 조회"
140+
)
141+
142+
// @ApiSuccessCodeExample(resultClass = FeedResponse.FeedListResultDTO.class)
143+
@ApiErrorCodeExamples({
144+
@ApiErrorCodeExample(value = ErrorStatus.class, name = "USER_NOT_FOUND"),
145+
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"),
146+
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_INTERNAL_SERVER_ERROR")
147+
})
148+
@GetMapping("/search")
149+
public ApiResponse<FeedResponse.FeedListResultDTO> searchFeeds(
150+
HttpServletRequest servletrequest,
151+
@RequestParam String keyword,
152+
@RequestParam(required = false) Long cursorId
153+
){
96154
Long userId = authUtil.getUserIdFromRequest(servletrequest);
97-
FeedResponse.FeedListResultDTO response = FeedResponse.FeedListResultDTO.builder().build();
155+
FeedResponse.FeedListResultDTO response = feedService.searchFeedsByUserNickname(keyword, cursorId, userId);
98156
return ApiResponse.onSuccess(response);
99157
}
100158

src/main/java/com/server/money_touch/domain/consumptionRecord/converter/feed/FeedConverter.java

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
import com.server.money_touch.domain.consumptionRecord.entity.ConsumptionRecordImage;
77
import com.server.money_touch.domain.consumptionRecord.enums.ReactionType;
88
import com.server.money_touch.domain.user.entity.User;
9+
import org.springframework.data.domain.Slice;
910

11+
import java.util.List;
12+
import java.util.Map;
1013
import java.util.stream.Collectors;
1114

1215
public class FeedConverter {
@@ -37,6 +40,107 @@ public static FeedResponse.FeedDetailResultDTO toFeedDetailDTO(ConsumptionRecord
3740
.build();
3841
}
3942

43+
/**
44+
* 피드리스트 전용 게시글 하나
45+
*/
46+
public static FeedResponse.FeedListItemDTO toFeedListItemDTO(ConsumptionRecord record, ReactionType myReaction) {
47+
48+
return FeedResponse.FeedListItemDTO.builder()
49+
.consumptionRecordId(record.getId())
50+
.user(toUserInfo(record.getUser()))
51+
.imageUrls(record.getImages().stream()
52+
.map(ConsumptionRecordImage::getFilePath)
53+
.collect(Collectors.toList()))
54+
.createdAt(record.getCreatedAt())
55+
.wiseCount(record.getWiseCount())
56+
.wasteCount(record.getWasteCount())
57+
.viewCount(record.getViewCount())
58+
.myReaction(myReaction)
59+
.build();
60+
}
61+
62+
/**
63+
* 커서 기반 무한스크롤 Slice<ConsumptionRecord> → FeedListResultDTO 변환
64+
*/
65+
public static FeedResponse.FeedListResultDTO toFeedListDTO(
66+
Slice<ConsumptionRecord> slice,
67+
Map<Long, ReactionType> myReactions // 각 게시물에 대한 내 리액션 정보
68+
) {
69+
if (slice == null || slice.isEmpty()) {
70+
return FeedResponse.FeedListResultDTO.builder()
71+
.feedList(List.of())
72+
.FeedListSize(0)
73+
.isFirst(true)
74+
.hasNext(false)
75+
.nextCursorId(null)
76+
.nextCursorViewCount(null)
77+
.build();
78+
}
79+
80+
List<FeedResponse.FeedListItemDTO> feedList = slice.getContent().stream()
81+
.map(record -> toFeedListItemDTO(record, myReactions.get(record.getId())))
82+
.toList();
83+
84+
Long nextCursorId = null;
85+
Integer nextCursorViewCount = null;
86+
87+
if (slice.hasNext() && !feedList.isEmpty()) {
88+
ConsumptionRecord lastRecord = slice.getContent().get(slice.getContent().size() - 1);
89+
nextCursorId = lastRecord.getId();
90+
nextCursorViewCount = lastRecord.getViewCount();
91+
}
92+
93+
return FeedResponse.FeedListResultDTO.builder()
94+
.feedList(feedList)
95+
.FeedListSize(feedList.size())
96+
.isFirst(slice.isFirst())
97+
.hasNext(slice.hasNext())
98+
.nextCursorId(nextCursorId)
99+
.nextCursorViewCount(nextCursorViewCount)
100+
.build();
101+
}
102+
103+
public static FeedResponse.MyFeedListResultDTO toMyFeedListDTO(Slice<ConsumptionRecord> slice) {
104+
if (slice == null || slice.isEmpty()) {
105+
return FeedResponse.MyFeedListResultDTO.builder()
106+
.feedList(List.of())
107+
.FeedListSize(0)
108+
.isFirst(true)
109+
.hasNext(false)
110+
.nextCursorId(null)
111+
.build();
112+
}
113+
114+
// 각 ConsumptionRecord를 MyFeedItemDTO로 변환
115+
List<FeedResponse.MyFeedItemDTO> items = slice.getContent().stream()
116+
.map(record -> FeedResponse.MyFeedItemDTO.builder()
117+
.consumptionRecordId(record.getId())
118+
.userId(record.getUser().getId())
119+
.imageUrls(record.getImages().stream()
120+
.map(ConsumptionRecordImage::getFilePath)
121+
.collect(Collectors.toList()))
122+
.content(record.getContent())
123+
.amount(record.getAmount())
124+
.build()
125+
).toList();
126+
127+
// 다음 커서 ID 설정
128+
Long nextCursorId = null;
129+
if (slice.hasNext() && !items.isEmpty()) {
130+
ConsumptionRecord lastRecord = slice.getContent().get(slice.getContent().size() - 1);
131+
nextCursorId = lastRecord.getId();
132+
}
133+
134+
return FeedResponse.MyFeedListResultDTO.builder()
135+
.feedList(items)
136+
.FeedListSize(items.size())
137+
.isFirst(slice.isFirst())
138+
.hasNext(slice.hasNext())
139+
.nextCursorId(nextCursorId)
140+
.build();
141+
}
142+
143+
40144
// 사용자 정보 반환
41145
public static FeedResponse.UserInfo toUserInfo(User user) {
42146
return FeedResponse.UserInfo.builder()

src/main/java/com/server/money_touch/domain/consumptionRecord/dto/FeedResponse.java

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,105 @@ public class FeedResponse {
2020
public static class FeedListResultDTO {
2121

2222
@Schema(description = "게시글 목록")
23-
List<FeedDetailResultDTO> feedList;
23+
private List<FeedListItemDTO> feedList;
2424

25-
@Schema(description = "현재 페이지의 알림 개수", example = "10")
26-
Integer FeedListSize;
25+
@Schema(description = "현재 페이지의 게시글 개수", example = "10")
26+
private Integer FeedListSize;
2727

2828
@Schema(description = "페이지 처음 여부", example = "true")
29-
Boolean isFirst;
29+
private Boolean isFirst;
3030

31-
@Schema(description = "페이지 마지막 여부", example = "false")
32-
Boolean isLast;
31+
@Schema(description = "다음 페이지가 있는지 여부", example = "true")
32+
private Boolean hasNext;
33+
34+
@Schema(description = "다음 커서 ID (무한스크롤용)", example = "1")
35+
private Long nextCursorId;
36+
37+
@Schema(description = "다음 커서 조회수", example = "20")
38+
private Integer nextCursorViewCount;
39+
40+
}
41+
42+
@Getter
43+
@Builder
44+
@NoArgsConstructor
45+
@AllArgsConstructor
46+
@Schema(description = "피드 리스트 아이템 (리스트 전용)")
47+
public static class FeedListItemDTO {
48+
49+
@Schema(description = "소비기록 ID", example = "1")
50+
private Long consumptionRecordId;
51+
52+
@Schema(description = "사용자 정보")
53+
private UserInfo user;
54+
55+
@Schema(description = "이미지 URL 리스트", example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]")
56+
private List<String> imageUrls;
57+
58+
@Schema(description = "생성일시", example = "2024-03-15T14:30:00")
59+
private LocalDateTime createdAt;
60+
61+
@Schema(description = "현명해요 수", example = "5")
62+
private Integer wiseCount;
63+
64+
@Schema(description = "낭비에요 수", example = "1")
65+
private Integer wasteCount;
66+
67+
@Schema(description = "조회 수", example = "21")
68+
private Integer viewCount;
69+
70+
@Schema(description = "현재 내가 누른 리액션 타입 (없으면 null)", example = "WISE")
71+
private ReactionType myReaction;
72+
}
73+
74+
@Builder
75+
@Getter
76+
@NoArgsConstructor
77+
@AllArgsConstructor
78+
@Schema(description = "나의 피드 리스트 응답")
79+
public static class MyFeedListResultDTO {
80+
81+
@Schema(description = "게시글 목록")
82+
private List<MyFeedItemDTO> feedList;
83+
84+
@Schema(description = "현재 페이지의 게시글 개수", example = "10")
85+
private Integer FeedListSize;
86+
87+
@Schema(description = "페이지 처음 여부", example = "true")
88+
private Boolean isFirst;
3389

3490
@Schema(description = "다음 페이지가 있는지 여부", example = "true")
35-
Boolean hasNext;
91+
private Boolean hasNext;
92+
93+
@Schema(description = "다음 커서 ID (무한스크롤용)", example = "1")
94+
private Long nextCursorId;
95+
96+
@Schema(description = "다음 커서 조회수", example = "20")
97+
private Integer nextCursorViewCount;
98+
99+
}
36100

101+
@Getter
102+
@Builder
103+
@NoArgsConstructor
104+
@AllArgsConstructor
105+
@Schema(description = "나의 피드 아이템")
106+
public static class MyFeedItemDTO {
107+
108+
@Schema(description = "소비기록 ID", example = "1")
109+
private Long consumptionRecordId;
110+
111+
@Schema(description = "사용자 ID", example = "1")
112+
private Long userId;
113+
114+
@Schema(description = "이미지 URL 리스트", example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]")
115+
private List<String> imageUrls;
116+
117+
@Schema(description = "소비 금액", example = "12000")
118+
private int amount;
119+
120+
@Schema(description = "소비 내용", example = "신라방 마라탕")
121+
private String content;
37122
}
38123

39124
@Builder
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.server.money_touch.domain.consumptionRecord.enums;
2+
3+
public enum FeedSortType {
4+
POPULAR, RECENT
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.server.money_touch.domain.consumptionRecord.enums;
2+
3+
public enum MyFeedViewType {
4+
CARD, // 이미지 카드형
5+
LIST // 리스트형 (날짜, 금액, 메모 등 포함)
6+
}

0 commit comments

Comments
 (0)