11package com .Rootin .domain .dashboard .service ;
22
33import 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 ;
47import com .Rootin .domain .garden .entity .Pot ;
58import com .Rootin .domain .garden .entity .WateringLog ;
69import com .Rootin .domain .garden .repository .PotRepository ;
1215import com .Rootin .domain .til .repository .TilTagRepository ;
1316import com .Rootin .domain .user .entity .User ;
1417import com .Rootin .domain .user .repository .UserRepository ;
18+ import com .Rootin .global .exception .CustomException ;
1519import lombok .RequiredArgsConstructor ;
1620import org .springframework .stereotype .Service ;
1721import 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
0 commit comments