Skip to content

Commit 9f1837e

Browse files
authored
Merge pull request #137 from Money-Touch/fix/#136
[#136] 🐛Fix: 특정 월의 일일 소비 내역 조회 시 고정비 오류 수정
2 parents da681f7 + b267ff0 commit 9f1837e

File tree

4 files changed

+84
-44
lines changed

4 files changed

+84
-44
lines changed

src/main/java/com/server/money_touch/domain/consumptionRecord/repository/consumptionRecord/ConsumptionRecordRepositoryCustom.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ List<DailyConsumptionItemProjection> findChunkByMonthUsingDateCursor(
2323
int limit);
2424

2525
// 경계 날짜(boundary date)의 나머지 데이터를 추가 조회.
26-
List<DailyConsumptionItemProjection> findRestOfBoundaryDate(
26+
List<DailyConsumptionItemProjection> findRestOfBoundaryDateClampedToMonth(
2727
Long userId,
28+
LocalDateTime monthStart, LocalDateTime monthEnd,
2829
LocalDateTime boundaryStart, LocalDateTime boundaryEnd,
2930
Long minIncludedId);
3031

src/main/java/com/server/money_touch/domain/consumptionRecord/repository/consumptionRecord/ConsumptionRecordRepositoryImpl.java

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,20 @@ public Slice<DailyConsumptionItemDetailProjection> findDailyConsumptionItemsWith
115115
*
116116
* @param userId 조회할 사용자 ID
117117
* @param monthStart 월 시작일(LocalDateTime, 00:00)
118-
* @param monthEnd 월 종료일(LocalDateTime, 23:59:59)
118+
* @param nextMonthStart 다음달 시작일(LocalDateTime, 00:00) // ⚠️ 상한은 '미만'으로 사용할 것
119119
* @param cursorConsumeDate 커서로 사용할 기준 소비일시 (null이면 첫 페이지)
120120
* @param limit 조회할 데이터 개수(페이지 사이즈)
121121
* @return DailyConsumptionItemProjection 목록
122122
*/
123123
@Override
124124
public List<DailyConsumptionItemProjection> findChunkByMonthUsingDateCursor(
125-
Long userId, LocalDateTime monthStart, LocalDateTime monthEnd,
125+
Long userId, LocalDateTime monthStart, LocalDateTime nextMonthStart,
126126
LocalDateTime cursorConsumeDate, int limit) {
127127

128-
// 기본 조건: 해당 사용자 + 지정한 월 범위 내 소비내역
128+
// ✅ 월 범위를 [monthStart, nextMonthStart) 로 '반열린 구간'으로 고정
129129
BooleanExpression base = record.user.id.eq(userId)
130-
.and(record.consumeDate.between(monthStart, monthEnd));
130+
.and(record.consumeDate.goe(monthStart))
131+
.and(record.consumeDate.lt(nextMonthStart));
131132

132133
// 날짜 커서 조건: 다음 페이지는 cursorConsumeDate의 '자정'보다 이전 데이터만 조회
133134
BooleanExpression dateCursor = null;
@@ -162,31 +163,45 @@ public List<DailyConsumptionItemProjection> findChunkByMonthUsingDateCursor(
162163
* 같은 날짜의 나머지 데이터를 모두 가져오기 위해 호출됨
163164
* - minIncludedId보다 오래된(더 작은) ID 데이터만 조회하여 중복 방지
164165
*
165-
* @param userId 조회할 사용자 ID
166-
* @param boundaryStart 경계 날짜의 시작 시간 (00:00)
167-
* @param boundaryEnd 경계 날짜의 종료 시간 (23:59:59)
168-
* @param minIncludedId 현재 페이지에서 이미 포함된 가장 오래된 데이터의 ID
166+
* @param userId 조회할 사용자 ID
167+
* @param monthStart 월 시작일(LocalDateTime, 00:00)
168+
* @param nextMonthStart 다음달 시작일(LocalDateTime, 00:00) // ⚠️ 상한 미만
169+
* @param boundaryStart 경계 날짜의 시작 시간 (00:00)
170+
* @param boundaryEnd 경계 날짜의 종료 시간 (23:59:59.999999999)
171+
* @param minIncludedId 현재 페이지에서 이미 포함된 가장 오래된 데이터의 ID
169172
* @return DailyConsumptionItemProjection 목록
170173
*/
171174
@Override
172-
public List<DailyConsumptionItemProjection> findRestOfBoundaryDate(
173-
Long userId, LocalDateTime boundaryStart, LocalDateTime boundaryEnd, Long minIncludedId) {
175+
public List<DailyConsumptionItemProjection> findRestOfBoundaryDateClampedToMonth(
176+
Long userId,
177+
LocalDateTime monthStart, LocalDateTime nextMonthStart,
178+
LocalDateTime boundaryStart, LocalDateTime boundaryEnd,
179+
Long minIncludedId) {
174180

175181
return queryFactory
176182
.select(Projections.fields(
177183
DailyConsumptionItemProjection.class,
178184
record.id.as("consumptionRecordId"),
179185
record.consumeDate.as("consumeDate"),
180186
Expressions.cases()
181-
.when(record.isFixed.isTrue()).then("고정비")
182-
.otherwise(category.budgetCategoryName).as("categoryName"),
187+
// ✅ 고정비라도 consumeDate가 월 범위 안인 경우만 "고정비" (표시용)
188+
.when(record.isFixed.isTrue()
189+
.and(record.consumeDate.goe(monthStart))
190+
.and(record.consumeDate.lt(nextMonthStart)))
191+
.then("고정비")
192+
.otherwise(category.budgetCategoryName)
193+
.as("categoryName"),
183194
record.content,
184195
record.amount
185196
))
186197
.from(record)
187198
.leftJoin(record.consumptionCategory, category)
188199
.where(
189200
record.user.id.eq(userId)
201+
// ✅ 월 범위 [monthStart, nextMonthStart)
202+
.and(record.consumeDate.goe(monthStart))
203+
.and(record.consumeDate.lt(nextMonthStart))
204+
// ✅ 하루 범위
190205
.and(record.consumeDate.between(boundaryStart, boundaryEnd))
191206
.and(record.id.lt(minIncludedId))
192207
)
@@ -199,23 +214,18 @@ public List<DailyConsumptionItemProjection> findRestOfBoundaryDate(
199214
*
200215
* - 다음 페이지가 있는지 여부를 판단하는 데 사용
201216
* - boundaryStart 이전에 데이터가 존재하면 true 반환
202-
*
203-
* @param userId 조회할 사용자 ID
204-
* @param monthStart 월 시작일(LocalDateTime, 00:00)
205-
* @param monthEnd 월 종료일(LocalDateTime, 23:59:59)
206-
* @param boundaryStart 비교 기준이 되는 날짜(LocalDateTime, 00:00)
207-
* @return 존재 여부 (true: 데이터 있음, false: 데이터 없음)
208217
*/
209218
@Override
210219
public boolean existsOlderThanDate(
211-
Long userId, LocalDateTime monthStart, LocalDateTime monthEnd, LocalDateTime boundaryStart) {
220+
Long userId, LocalDateTime monthStart, LocalDateTime nextMonthStart, LocalDateTime boundaryStart) {
212221

213222
Long probe = queryFactory
214223
.select(record.id)
215224
.from(record)
216225
.where(
217226
record.user.id.eq(userId)
218-
.and(record.consumeDate.between(monthStart, monthEnd))
227+
.and(record.consumeDate.goe(monthStart))
228+
.and(record.consumeDate.lt(nextMonthStart)) // ✅ 반열린 구간
219229
.and(record.consumeDate.lt(boundaryStart))
220230
)
221231
.limit(1)

src/main/java/com/server/money_touch/domain/consumptionRecord/service/ConsumptionRecordQueryServiceImpl.java

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,19 @@ public HouseholdConsumptionResponse.DailyConsumptionDetailDTO getDailyConsumptio
6363
return ConsumptionRecordConverter.toDailyConsumptionDetailDTO(consumptionRecord, consumptionCategory);
6464
}
6565

66-
// 해당 월의 소비 내역 목록 조회 (커서 기반 무한스크롤)
67-
// - 요구사항: 페이지 사이즈와 관계없이 "특정 날짜의 데이터가 여러 페이지에 분리되지 않도록" 보장.
68-
// - 전략:
69-
// 1) (consumeDate DESC, id DESC) 기준으로 pageSize+1개를 가져와 "경계 날짜(boundaryDate)"를 결정
70-
// 2) 경계 날짜가 잘릴 위험이 있으므로, 경계 날짜의 "나머지 전부"를 추가 쿼리로 모아 합치기
71-
// 3) 다음 페이지 존재 여부는 "경계 날짜보다 과거 데이터가 있는가"로 판정
72-
// 4) nextCursorId는 "이번에 내려준 마지막(가장 오래된) 아이템의 id"로 반환
73-
// (레포에서 커서를 '날짜 기준'으로만 쓰도록 수정하면, 같은 날짜 재등장 이슈가 사라집니다)
66+
/**
67+
* 해당 월의 소비 내역 목록 조회 (커서 기반 무한스크롤)
68+
* - 요구사항: 페이지 사이즈와 관계없이 "특정 날짜의 데이터가 여러 페이지에 분리되지 않도록" 보장.
69+
* - 핵심 규칙: 월 범위는 반드시 [monthStart, nextMonthStart) 반열린 구간으로 통일 (상한 미만)
70+
*
71+
* 전략:
72+
* 1) (consumeDate DESC, id DESC) 기준으로 pageSize+1개를 가져와 "경계 날짜(boundaryDate)"를 결정
73+
* 2) 경계 날짜가 잘릴 위험이 있으므로, 경계 날짜의 "나머지 전부"를 추가 쿼리로 모아 합치기
74+
* - 이때도 월 범위를 [monthStart, nextMonthStart)로 강제(클램핑)하여 월 넘김 혼입 방지
75+
* 3) 다음 페이지 존재 여부는 "경계 날짜보다 과거 데이터가 있는가"로 판정
76+
* 4) nextCursorId는 "이번에 내려준 마지막(가장 오래된) 아이템의 id"로 반환
77+
* (레포에서 커서를 '날짜 기준'으로만 쓰도록 하면, 같은 날짜가 다음 페이지에 재등장하지 않음)
78+
*/
7479
@Override
7580
public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumptionRecords(
7681
Long userId, int year, int month, Long cursorId) {
@@ -79,14 +84,14 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
7984
userRepository.findById(userId)
8085
.orElseThrow(() -> new ErrorHandler(ErrorStatus.USER_NOT_FOUND));
8186

82-
// 1) 해당 월 범위
83-
LocalDate startDate = LocalDate.of(year, month, 1);
84-
LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth());
85-
LocalDateTime monthStart = startDate.atStartOfDay();
86-
LocalDateTime monthEnd = endDate.atTime(23, 59, 59, 999_999_999);
87+
// 1) 해당 월 범위 계산
88+
// ⚠️ 월 범위는 반드시 [monthStart, nextMonthStart) 로 사용 (상한 미만)
89+
LocalDate firstDay = LocalDate.of(year, month, 1);
90+
LocalDateTime monthStart = firstDay.atStartOfDay(); // ex) 2025-07-01 00:00
91+
LocalDateTime nextMonthStart = firstDay.plusMonths(1).atStartOfDay(); // ex) 2025-08-01 00:00
8792

8893
// 2) 커서의 consumeDate 조회 (id → consumeDate)
89-
// 다음 페이지는 "이 날짜보다 과거(< dayStart)"만 보도록 만들 것이므로
94+
// 다음 페이지는 "이 날짜의 0시보다 과거(< dayStart)"만 보도록 만들 것이므로
9095
// 레포지토리에서 id 커서 비교(= 같은 날짜에서 id<...)는 사용하지 않게 해야 "날짜 쪼개짐"이 사라집니다.
9196
LocalDateTime cursorConsumeDate = null;
9297
if (cursorId != null) {
@@ -100,11 +105,17 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
100105
// 즉, "날짜만" 기준으로 다음 페이지를 자르도록 해야 같은 날짜 재등장이 없음.
101106
final int pageSize = PAGE_SIZE; // 기존 상수 재사용
102107
List<DailyConsumptionItemProjection> chunk = consumptionRecordRepository
103-
.findChunkByMonthUsingDateCursor(userId, monthStart, monthEnd, cursorConsumeDate, pageSize + 1);
108+
.findChunkByMonthUsingDateCursor(
109+
userId,
110+
monthStart, // ✅ 하한: 포함
111+
nextMonthStart, // ✅ 상한: 미만
112+
cursorConsumeDate,
113+
pageSize + 1
114+
);
104115

105116
if (chunk.isEmpty()) {
106117
return ConsumptionRecordConverter.toMonthlyHistoryResponseDTO(
107-
List.of(), true, false, null
118+
List.of(), /*isFirst*/ true, /*hasNext*/ false, /*nextCursorId*/ null
108119
);
109120
}
110121

@@ -114,9 +125,19 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
114125
int visibleCount = Math.min(pageSize, chunk.size());
115126
List<DailyConsumptionItemProjection> visible = new ArrayList<>(chunk.subList(0, visibleCount));
116127

117-
LocalDate boundaryDate = visible.get(visible.size() - 1).getConsumeDate().toLocalDate();
118-
LocalDateTime boundaryStart = boundaryDate.atStartOfDay();
119-
LocalDateTime boundaryEnd = boundaryStart.plusDays(1).minusNanos(1);
128+
LocalDate boundaryDate = visible.get(visibleCount - 1).getConsumeDate().toLocalDate();
129+
LocalDateTime boundaryStart = boundaryDate.atStartOfDay(); // ex) 2025-07-31 00:00
130+
LocalDateTime boundaryEnd = boundaryStart.plusDays(1).minusNanos(1); // ex) 2025-07-31 23:59:59.999999999
131+
132+
// 4-1) 경계 날짜 구간도 월 경계를 넘지 않도록 '클램핑'
133+
// - 하한은 monthStart 이상
134+
// - 상한은 nextMonthStart 미만
135+
LocalDateTime boundaryStartClamped = boundaryStart.isBefore(monthStart) ? monthStart : boundaryStart;
136+
LocalDateTime boundaryEndClampedExclusive = boundaryEnd.plusNanos(1); // [start, end] → [start, endExclusive)
137+
LocalDateTime boundaryEndClampedExclusiveFinal =
138+
boundaryEndClampedExclusive.isAfter(nextMonthStart) ? nextMonthStart : boundaryEndClampedExclusive;
139+
// 최종적으로 between 용으로 다시 [start, end] 포함구간으로 변환
140+
LocalDateTime boundaryEndClamped = boundaryEndClampedExclusiveFinal.minusNanos(1);
120141

121142
// 5) 경계 날짜의 나머지 아이템 전부 추가 조회
122143
// - chunk는 pageSize 제한 때문에 "경계 날짜의 일부"만 담겼을 수 있음
@@ -127,8 +148,13 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
127148
.min(Long::compareTo) // 정렬이 DESC이므로 "가장 오래된 id"가 min
128149
.orElse(Long.MAX_VALUE);
129150

130-
List<DailyConsumptionItemProjection> extraSameDate = consumptionRecordRepository
131-
.findRestOfBoundaryDate(userId, boundaryStart, boundaryEnd, minIncludedIdOnBoundary);
151+
List<DailyConsumptionItemProjection> extraSameDate =
152+
consumptionRecordRepository.findRestOfBoundaryDateClampedToMonth(
153+
userId,
154+
monthStart, nextMonthStart, // ✅ 월 범위 [monthStart, nextMonthStart)
155+
boundaryStartClamped, boundaryEndClamped, // ✅ 하루 범위도 월에 클램핑
156+
minIncludedIdOnBoundary
157+
);
132158

133159
// 6) 결과 합치기 (정렬 유지: consumeDate DESC, id DESC)
134160
// - visible(앞부분) + extraSameDate(경계 날짜 나머지) 순으로 합칩니다.
@@ -137,7 +163,9 @@ public HouseholdConsumptionResponse.MonthlyHistoryResponseDTO getMonthlyConsumpt
137163
merged.addAll(extraSameDate);
138164

139165
// 7) hasNext 계산: 경계 날짜보다 과거 데이터가 있는지
140-
boolean hasNext = consumptionRecordRepository.existsOlderThanDate(userId, monthStart, monthEnd, boundaryStart);
166+
boolean hasNext = consumptionRecordRepository.existsOlderThanDate(
167+
userId, monthStart, nextMonthStart, boundaryStartClamped
168+
);
141169

142170
// 8) nextCursorId: 이번에 내려준 리스트 중 "가장 오래된" 아이템의 id (마지막 요소)
143171
Long nextCursorId = hasNext && !merged.isEmpty()
@@ -238,6 +266,5 @@ public HouseholdConsumptionResponse.CalendarDailyConsumeSliceResponse getCalenda
238266
nextCursorId,
239267
isFirst
240268
);
241-
242269
}
243270
}

src/main/java/com/server/money_touch/domain/fixedConsumption/repository/FixedConsumptionRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
import com.server.money_touch.domain.fixedConsumption.entity.FixedConsumption;
44
import com.server.money_touch.domain.user.entity.User;
55
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Query;
67

78
import java.util.List;
89
import java.util.Optional;
910

1011
public interface FixedConsumptionRepository extends JpaRepository<FixedConsumption, Long>, FixedConsumptionRepositoryCustom {
12+
@Query("select distinct fc from FixedConsumption fc where fc.user = :user")
1113
List<FixedConsumption> findAllByUser(User user);
1214

1315
Optional<FixedConsumption> findByIdAndUserId(Long id, Long userId);

0 commit comments

Comments
 (0)