Skip to content

Commit 8c28f17

Browse files
authored
Merge pull request #147 from prgrms-aibe-devcourse/feat/139-quest-redesign
[Feat] 오늘의 목표 퀘스트 조건 재설계
2 parents 9284d81 + 3886a8d commit 8c28f17

15 files changed

Lines changed: 487 additions & 101 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ task stopTestDb(type: Exec) {
120120

121121
tasks.named('test') {
122122
useJUnitPlatform()
123+
// CI(Ubuntu) 기본 타임존이 UTC이므로 로컬(KST)과 동일한 환경으로 고정합니다.
124+
// LocalDate.now() 등 타임존 의존 코드가 환경에 따라 다르게 동작하는 flaky test를 방지합니다.
125+
jvmArgs '-Duser.timezone=Asia/Seoul'
123126
dependsOn startTestDb
124127
finalizedBy stopTestDb
125128
}

src/main/java/com/Rootin/RootinApplication.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55

6+
import java.util.TimeZone;
7+
68
@SpringBootApplication
79
public class RootinApplication {
810
public static void main(String[] args) {
11+
// JVM 타임존을 서비스 기준(KST)으로 고정합니다.
12+
// Docker/CI 환경의 기본 타임존(UTC)과 관계없이 LocalDate.now() 등이 일관된 KST 기준으로 동작합니다.
13+
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
914
SpringApplication.run(RootinApplication.class, args);
1015
}
1116
}

src/main/java/com/Rootin/domain/dashboard/service/DashboardService.java

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.Rootin.domain.dashboard.service;
22

33
import com.Rootin.domain.dashboard.dto.*;
4+
import com.Rootin.domain.gamification.entity.PointLog;
5+
import com.Rootin.domain.gamification.entity.enums.PointLogReason;
6+
import com.Rootin.domain.gamification.repository.PointLogRepository;
47
import com.Rootin.domain.garden.entity.Pot;
58
import com.Rootin.domain.garden.entity.WateringLog;
69
import com.Rootin.domain.garden.repository.PotRepository;
@@ -12,6 +15,7 @@
1215
import com.Rootin.domain.til.repository.TilTagRepository;
1316
import com.Rootin.domain.user.entity.User;
1417
import com.Rootin.domain.user.repository.UserRepository;
18+
import com.Rootin.global.exception.CustomException;
1519
import lombok.RequiredArgsConstructor;
1620
import org.springframework.stereotype.Service;
1721
import org.springframework.transaction.annotation.Transactional;
@@ -33,6 +37,7 @@ public class DashboardService {
3337
private final TilTagRepository tilTagRepository;
3438
private final PotRepository potRepository;
3539
private final UserRepository userRepository;
40+
private final PointLogRepository pointLogRepository;
3641
private final LevelCalculator levelCalculator;
3742

3843
public GrassGraphResponse getGrassGraph(Long userId, int months) {
@@ -162,34 +167,34 @@ public InterestsResponse getInterests(Long userId, int months) {
162167
return new InterestsResponse(interests);
163168
}
164169

170+
@Transactional
165171
public QuestResponse getQuests(Long userId) {
166-
LocalDate today = LocalDate.now();
167-
LocalDateTime todayStart = today.atStartOfDay();
168-
LocalDateTime todayEnd = today.atTime(23, 59, 59);
172+
LocalDate today = LocalDate.now();
173+
LocalDateTime todayStart = today.atStartOfDay();
174+
// 반열린 구간 [todayStart, tomorrowStart) — datetime(6) microsecond 누락 방지
175+
LocalDateTime tomorrowStart = today.plusDays(1).atStartOfDay();
169176

170-
List<WateringLog> todayLogs = wateringLogRepository.findByUserIdAndWateredAtBetween(userId, todayStart, todayEnd);
177+
List<WateringLog> todayLogs = wateringLogRepository
178+
.findByUserIdAndWateredAtGreaterThanEqualAndWateredAtLessThan(userId, todayStart, tomorrowStart);
171179

172180
// Q1: 오늘 TIL >= 1개
173181
boolean q1 = !todayLogs.isEmpty();
174182

175-
// Q2: 연속 기록 2일 이상
176-
List<LocalDateTime> publishedTimes = tilRepository.findPublishedAtByUserId(userId, PostStatus.PUBLISHED);
177-
boolean q2 = levelCalculator.calculateStreak(publishedTimes) >= 2;
183+
// Q2: 오늘 TIL에 태그 >= 1개
184+
long todayTagCount = tilTagRepository.countByUserTodayTil(userId, PostStatus.PUBLISHED, todayStart, tomorrowStart);
185+
boolean q2 = todayTagCount >= 1;
178186

179-
// Q3: 오늘 총 글자 수 >= 500
187+
// Q3: 오늘 총 글자 수 >= 200
180188
int todayCharCount = todayLogs.stream().mapToInt(WateringLog::getContentLength).sum();
181-
boolean q3 = todayCharCount >= 500;
189+
boolean q3 = todayCharCount >= 200;
182190

183-
// Q4: 주말이면 TIL >= 1개, 평일이면 자동 달성
184-
DayOfWeek dow = today.getDayOfWeek();
185-
boolean isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY;
186-
boolean q4 = !isWeekend || q1;
191+
// 달성된 퀘스트에 대해 오늘 첫 달성이면 포인트 지급
192+
awardQuestPoints(userId, q1, q2, q3, today);
187193

188194
List<QuestDto> quests = List.of(
189195
new QuestDto("Q1", "TIL 1개 작성하기", q1, 50),
190-
new QuestDto("Q2", "연속 기록 이어가기", q2, 30),
191-
new QuestDto("Q3", "500자 이상 작성", q3, 20),
192-
new QuestDto("Q4", "주말에도 한 줄 기록", q4, 10)
196+
new QuestDto("Q2", "TIL에 태그 달기", q2, 30),
197+
new QuestDto("Q3", "200자 이상 작성", q3, 20)
193198
);
194199

195200
int earnedToday = quests.stream().filter(QuestDto::done).mapToInt(QuestDto::point).sum();
@@ -198,6 +203,35 @@ public QuestResponse getQuests(Long userId) {
198203
return new QuestResponse(quests, earnedToday, totalToday);
199204
}
200205

206+
private static final Set<PointLogReason> QUEST_REASONS =
207+
Set.of(PointLogReason.QUEST_Q1, PointLogReason.QUEST_Q2, PointLogReason.QUEST_Q3);
208+
209+
private void awardQuestPoints(Long userId, boolean q1, boolean q2, boolean q3, LocalDate today) {
210+
// 달성된 퀘스트가 없으면 DB 조회 없이 early return
211+
if (!q1 && !q2 && !q3) return;
212+
213+
// awardedDate 기준으로 오늘 이미 지급된 퀘스트 reason 조회 (createdAt BETWEEN 대신)
214+
Set<PointLogReason> awardedToday =
215+
pointLogRepository.findQuestReasonsByUserIdAndAwardedDate(userId, today, QUEST_REASONS);
216+
217+
// User 풀 로딩 없이 프록시 참조만 사용 (PointLog FK 저장용)
218+
User userRef = userRepository.getReferenceById(userId);
219+
220+
awardIfNew(userId, userRef, q1, PointLogReason.QUEST_Q1, 50, awardedToday, today);
221+
awardIfNew(userId, userRef, q2, PointLogReason.QUEST_Q2, 30, awardedToday, today);
222+
awardIfNew(userId, userRef, q3, PointLogReason.QUEST_Q3, 20, awardedToday, today);
223+
}
224+
225+
private void awardIfNew(Long userId, User userRef, boolean done, PointLogReason reason,
226+
int point, Set<PointLogReason> awardedToday, LocalDate awardedDate) {
227+
if (!done) return;
228+
if (awardedToday.contains(reason)) return;
229+
230+
// 원자적 UPDATE — 동시 요청 시 lost update 방지
231+
userRepository.incrementPoint(userId, point);
232+
pointLogRepository.save(PointLog.forQuest(userRef, reason, point, awardedDate));
233+
}
234+
201235
private int calculateMaxStreak(Set<LocalDate> dateSet) {
202236
if (dateSet.isEmpty()) return 0;
203237

src/main/java/com/Rootin/domain/gamification/entity/PointLog.java

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,26 @@
88
import lombok.Getter;
99
import lombok.NoArgsConstructor;
1010

11+
import java.time.LocalDate;
1112
import java.time.LocalDateTime;
1213

1314
/**
1415
* 포인트 사용/적립 내역 기록 엔티티
15-
* - amount 양수: 적립 (TIL 계열), 음수: 소모 (AI 계열)
16+
* - amount 양수: 적립 (퀘스트), 음수: 소모 (AI)
17+
*
18+
* 중복 지급 방지:
19+
* - QUEST 계열 reason은 awarded_date 를 오늘 날짜로 세팅하여 (user_id, reason, awarded_date) 유니크 제약으로 하루 1회 보장
20+
* - AI 계열 reason은 awarded_date = null → NULL != NULL 규칙으로 유니크 제약 적용 안 됨 (하루 여러 번 소모 가능)
1621
*/
1722
@Getter
1823
@Entity
19-
@Table(name = "point_log")
24+
@Table(
25+
name = "point_log",
26+
uniqueConstraints = @UniqueConstraint(
27+
name = "uk_point_log_user_reason_date",
28+
columnNames = {"user_id", "reason", "awarded_date"}
29+
)
30+
)
2031
@NoArgsConstructor(access = AccessLevel.PROTECTED)
2132
public class PointLog {
2233

@@ -30,13 +41,20 @@ public class PointLog {
3041

3142
/** 포인트 변동 사유 */
3243
@Enumerated(EnumType.STRING)
33-
@Column(nullable = false, length = 20)
44+
@Column(nullable = false, length = 50)
3445
private PointLogReason reason;
3546

3647
/** 변동량 — 양수: 적립, 음수: 소모 */
3748
@Column(nullable = false)
3849
private int amount;
3950

51+
/**
52+
* 퀘스트 포인트 지급 날짜 (QUEST 계열만 세팅, AI 계열은 null)
53+
* (user_id, reason, awarded_date) 유니크 제약의 날짜 축으로 사용
54+
*/
55+
@Column(name = "awarded_date")
56+
private LocalDate awardedDate;
57+
4058
@Column(name = "created_at", nullable = false, updatable = false)
4159
private LocalDateTime createdAt;
4260

@@ -46,9 +64,31 @@ protected void onCreate() {
4664
}
4765

4866
@Builder
49-
public PointLog(User user, PointLogReason reason, int amount) {
67+
public PointLog(User user, PointLogReason reason, int amount, LocalDate awardedDate) {
5068
this.user = user;
5169
this.reason = reason;
5270
this.amount = amount;
71+
this.awardedDate = awardedDate;
72+
}
73+
74+
/**
75+
* QUEST 계열 포인트 지급 전용 팩토리 메서드.
76+
* awardedDate 를 반드시 지정하도록 강제하여 (user_id, reason, awarded_date) 유니크 제약이 올바르게 동작함을 보장합니다.
77+
*
78+
* @param user 수령 유저 (프록시 허용)
79+
* @param reason QUEST_Q1 / QUEST_Q2 / QUEST_Q3 중 하나
80+
* @param amount 지급 포인트 (양수)
81+
* @param awardedDate 지급 날짜 (오늘, non-null)
82+
*/
83+
public static PointLog forQuest(User user, PointLogReason reason, int amount, LocalDate awardedDate) {
84+
if (awardedDate == null) {
85+
throw new IllegalArgumentException("QUEST 계열 PointLog는 awardedDate가 필수입니다.");
86+
}
87+
return PointLog.builder()
88+
.user(user)
89+
.reason(reason)
90+
.amount(amount)
91+
.awardedDate(awardedDate)
92+
.build();
5393
}
5494
}

src/main/java/com/Rootin/domain/gamification/entity/enums/PointLogReason.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,21 @@
55

66
/**
77
* 포인트 변동 사유
8-
* - TIL 계열: 양수 (적립) → TODO [동기부여 담당자]: TIL 작성 완료 시 적립 로직 구현 필요
9-
* - AI 계열: 음수 (소모) AI 담당자 구현 완료
8+
* - 퀘스트 계열: 양수 (적립) — 오늘의 목표 달성 시 하루 1회 지급
9+
* - AI 계열: 음수 (소모) AI 담당자 구현 완료
1010
*/
1111
@Getter
1212
@RequiredArgsConstructor
1313
public enum PointLogReason {
1414

15-
// ── 적립 (TIL) — TODO [동기부여 담당자]: 아래 항목 사용하여 적립 로직 구현 ──
15+
// ── 적립 (퀘스트) ────────────────────────────────────────────────────────
16+
QUEST_Q1("오늘의 목표 Q1 달성: TIL 1개 작성"),
17+
QUEST_Q2("오늘의 목표 Q2 달성: TIL에 태그 달기"),
18+
QUEST_Q3("오늘의 목표 Q3 달성: 200자 이상 작성"),
19+
20+
// ── 적립 (레거시 — 더 이상 사용하지 않음, 기존 DB 레코드 보존용) ──────────
21+
/** @deprecated 포인트는 오늘의 목표(QUEST_Q*)에서만 지급됩니다. */
22+
@Deprecated
1623
TIL_WRITE("TIL 작성"),
1724

1825
// ── 소모 (AI) ───────────────────────────────────────────────────────────

src/main/java/com/Rootin/domain/gamification/repository/PointLogRepository.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
import org.springframework.data.repository.query.Param;
99

1010
import org.springframework.data.domain.Pageable;
11+
import java.time.LocalDate;
1112
import java.time.LocalDateTime;
13+
import java.util.Set;
1214

1315
public interface PointLogRepository extends JpaRepository<PointLog, Long> {
1416

@@ -24,12 +26,16 @@ int sumAmountByUserIdAndPeriod(
2426
@Param("to") LocalDateTime to
2527
);
2628

27-
// MT-01 오늘의 목표 - 하루 1회 중복 지급 방지용
28-
boolean existsByUserIdAndReasonAndCreatedAtBetween(
29-
Long userId,
30-
PointLogReason reason,
31-
LocalDateTime from,
32-
LocalDateTime to
29+
// MT-01 오늘의 목표 - 오늘 지급된 퀘스트 reason 목록 단건 조회 (중복 지급 방지용)
30+
// createdAt BETWEEN 대신 awardedDate = :awardedDate 로 조회하여 datetime(6) microsecond 누락 문제 방지
31+
@Query("SELECT pl.reason FROM PointLog pl " +
32+
"WHERE pl.user.id = :userId " +
33+
"AND pl.awardedDate = :awardedDate " +
34+
"AND pl.reason IN :questReasons")
35+
Set<PointLogReason> findQuestReasonsByUserIdAndAwardedDate(
36+
@Param("userId") Long userId,
37+
@Param("awardedDate") LocalDate awardedDate,
38+
@Param("questReasons") Set<PointLogReason> questReasons
3339
);
3440

3541
// MT-02 포인트 현황 - 총 적립 포인트 (양수 합산)

src/main/java/com/Rootin/domain/garden/repository/WateringLogRepository.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,20 @@ public interface WateringLogRepository extends JpaRepository<WateringLog, Long>
4848
// 성장 이력 차트용 - 최근 30건
4949
List<WateringLog> findTop30ByUserIdOrderByWateredAtDesc(Long userId);
5050

51-
// 활동 캘린더용 - 기간별 물주기 이력
51+
// 활동 캘린더용 - 기간별 물주기 이력 (inclusive BETWEEN)
5252
List<WateringLog> findByUserIdAndWateredAtBetween(
5353
Long userId,
5454
LocalDateTime from,
5555
LocalDateTime to
5656
);
5757

58+
// 퀘스트용 - 반열린 구간 [from, to) 조회. datetime(6) microsecond 누락 방지
59+
List<WateringLog> findByUserIdAndWateredAtGreaterThanEqualAndWateredAtLessThan(
60+
Long userId,
61+
LocalDateTime from,
62+
LocalDateTime to
63+
);
64+
5865
// 식물 성장 단계 날짜 계산용 — 특정 시점 이후 해당 화분의 물주기 이력 (시간순)
5966
List<WateringLog> findByPotIdAndWateredAtGreaterThanEqualOrderByWateredAtAsc(
6067
Long potId,

src/main/java/com/Rootin/domain/garden/service/ExperienceService.java

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
package com.Rootin.domain.garden.service;
22

3-
import com.Rootin.domain.gamification.entity.PointLog;
4-
import com.Rootin.domain.gamification.entity.enums.PointLogReason;
5-
import com.Rootin.domain.gamification.repository.PointLogRepository;
63
import com.Rootin.domain.garden.entity.PlantItem;
74
import com.Rootin.domain.garden.entity.Pot;
85
import com.Rootin.domain.garden.entity.WateringLog;
@@ -38,7 +35,6 @@ public class ExperienceService {
3835

3936
private final UserRepository userRepository;
4037
private final WateringLogRepository wateringLogRepository;
41-
private final PointLogRepository pointLogRepository;
4238
private final TilRepository tilRepository;
4339
private final LevelCalculator levelCalculator;
4440
private final PlantItemRepository plantItemRepository;
@@ -101,12 +97,12 @@ public void applyWatering(Long userId, Pot pot, int contentLength, Long tilId) {
10197
int streakDays = levelCalculator.calculatePreviousStreak(publishedTimes);
10298
log.info("조회된 이전 연속 작성일 수 (Streak Days): {}일", streakDays);
10399

104-
// 5. 경험치 및 포인트 획득량 계산.
100+
// 5. 경험치 획득량 계산.
105101
// 순수 계산은 LevelCalculator에 위임하여, 이 서비스는 "조회-검증-상태변경-저장" 흐름에 집중합니다.
102+
// 포인트는 TIL 작성 시 지급하지 않으며, DashboardService의 퀘스트 달성 시점에 지급됩니다.
106103
int gainedExp = levelCalculator.calculateExperience(contentLength, streakDays);
107-
int gainedPoint = levelCalculator.calculatePoints(gainedExp);
108104
double appliedMultiplier = levelCalculator.calculateStreakMultiplier(streakDays);
109-
log.info("획득 경험치: {} Exp (글자 수: {}, 배율: {}x), 적립 포인트: {} P", gainedExp, contentLength, appliedMultiplier, gainedPoint);
105+
log.info("획득 경험치: {} Exp (글자 수: {}, 배율: {}x)", gainedExp, contentLength, appliedMultiplier);
110106

111107
// 6. 화분 경험치 가산 및 레벨 계산.
112108
// WateringLog에 전/후 상태를 남겨야 하므로 변경 전 값을 먼저 백업합니다.
@@ -127,26 +123,15 @@ public void applyWatering(Long userId, Pot pot, int contentLength, Long tilId) {
127123
plantItem.increaseGrowthExp(gainedExp);
128124
log.info("식물 경험치 변동: {} Exp -> {} Exp (획득 경험치: {})", beforePlantExp, plantItem.getGrowthExp(), gainedExp);
129125

130-
// 7. 유저 포인트 가산 및 포인트 변동 이력(PointLog) 저장.
131-
// User.point는 현재 총액이고, PointLog는 "왜 포인트가 늘었는지"를 추적하기 위한 감사 로그입니다.
132-
user.addPoint(gainedPoint);
133-
if (gainedPoint > 0) {
134-
PointLog pointLog = PointLog.builder()
135-
.user(user)
136-
.reason(PointLogReason.TIL_WRITE)
137-
.amount(gainedPoint)
138-
.build();
139-
pointLogRepository.save(pointLog);
140-
}
141-
142-
// 8. 물주기 상세 이력(WateringLog) 저장.
126+
// 7. 물주기 상세 이력(WateringLog) 저장.
127+
// 포인트는 오늘의 목표(퀘스트) 달성 시 DashboardService에서 지급됩니다.
143128
// 대시보드의 최근 물주기 시각, 운영 중 정산 검증, 사용자 성장 히스토리 분석에 쓰입니다.
144129
WateringLog wateringLog = WateringLog.builder()
145130
.userId(userId)
146131
.potId(pot.getId())
147132
.postId(tilId)
148133
.expGained(gainedExp)
149-
.pointGained(gainedPoint)
134+
.pointGained(0)
150135
.contentLength(contentLength)
151136
.streakDays(streakDays)
152137
.appliedMultiplier(appliedMultiplier)

src/main/java/com/Rootin/domain/til/repository/TilTagRepository.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,20 @@ List<TilTag> findTagsSince(
2525
@Param("status") PostStatus status,
2626
@Param("from") LocalDateTime from
2727
);
28+
29+
// 퀘스트 Q2용 - 오늘 발행된 TIL에 붙은 태그 수 반환 (반열린 구간 [from, to))
30+
@Query("""
31+
SELECT COUNT(tt) FROM TilTag tt
32+
JOIN tt.til t
33+
WHERE t.user.id = :userId
34+
AND t.status = :status
35+
AND t.publishedAt >= :from
36+
AND t.publishedAt < :to
37+
""")
38+
long countByUserTodayTil(
39+
@Param("userId") Long userId,
40+
@Param("status") PostStatus status,
41+
@Param("from") LocalDateTime from,
42+
@Param("to") LocalDateTime to
43+
);
2844
}

0 commit comments

Comments
 (0)