11package com .workingdead .chatbot .kakao .scheduler ;
22
3+ import com .workingdead .meet .entity .NotificationType ;
4+ import com .workingdead .meet .entity .Vote ;
5+ import com .workingdead .meet .entity .Vote .VoteStatus ;
6+ import com .workingdead .meet .repository .VoteRepository ;
37import com .workingdead .chatbot .kakao .service .KakaoNotifier ;
4- import com .workingdead .chatbot .kakao .service .KakaoNotifier .RemindTiming ;
8+ import com .workingdead .meet .service .VoteNotificationService ;
9+ import com .workingdead .meet .service .VoteStatsService ;
10+ import com .workingdead .meet .service .VoteStatsService .VoteStats ;
511import lombok .extern .slf4j .Slf4j ;
12+ import org .springframework .scheduling .annotation .Scheduled ;
613import org .springframework .stereotype .Component ;
714
15+ import java .time .Duration ;
16+ import java .time .Instant ;
817import java .util .List ;
9- import java .util .Map ;
10- import java .util .concurrent .*;
1118
1219/**
13- * 카카오 챗봇용 스케줄러
20+ * 카카오 챗봇 투표 알림 스케줄러 (DB 기반)
1421 *
15- * Discord WendyScheduler와 동일한 타이밍으로 알림을 전송합니다.
16- * sessionKey(그룹챗이면 botGroupKey, 개인챗이면 userKey) 기반으로 스케줄을 관리합니다.
17- **/
22+ * - In-memory ScheduledFuture 사용 ❌
23+ * - 매 1분마다 ACTIVE 투표를 조회하여
24+ * PRD 기준(집계 / 독촉 / 최후통첩 / 완료)을 판단
25+ * - vote_notification 테이블로 중복 발송 방지
26+ */
1827@ Component
1928@ Slf4j
2029public class KakaoWendyScheduler {
2130
22- private final ScheduledExecutorService scheduler = Executors . newScheduledThreadPool ( 2 ) ;
31+ private final VoteRepository voteRepository ;
2332 private final KakaoNotifier notifier ;
24- private final Map <String , List <ScheduledFuture <?>>> userTasks = new ConcurrentHashMap <>();
33+ private final VoteNotificationService notificationService ;
34+ private final VoteStatsService voteStatsService ;
2535
26- public KakaoWendyScheduler (KakaoNotifier notifier ) {
36+ public KakaoWendyScheduler (
37+ VoteRepository voteRepository ,
38+ KakaoNotifier notifier ,
39+ VoteNotificationService notificationService ,
40+ VoteStatsService voteStatsService
41+ ) {
42+ this .voteRepository = voteRepository ;
2743 this .notifier = notifier ;
44+ this .notificationService = notificationService ;
45+ this .voteStatsService = voteStatsService ;
2846 }
2947
3048 /**
31- * 스케줄 시작 (투표 생성 후 호출)
49+ * 1분 주기로 ACTIVE 투표를 스캔
3250 */
33- public void startSchedule (String sessionKey ) {
34- stopSchedule (sessionKey );
51+ @ Scheduled (fixedDelay = 60_000 )
52+ public void tick () {
53+ List <Vote > activeVotes = voteRepository .findAllByStatus (VoteStatus .ACTIVE );
54+ for (Vote vote : activeVotes ) {
55+ try {
56+ processVote (vote );
57+ } catch (Exception e ) {
58+ log .error ("[Kakao Scheduler] Failed to process voteId={}" , vote .getId (), e );
59+ }
60+ }
61+ }
3562
36- CopyOnWriteArrayList <ScheduledFuture <?>> tasks = new CopyOnWriteArrayList <>();
63+ private void processVote (Vote vote ) {
64+ Instant now = Instant .now ();
65+ Duration elapsed = Duration .between (vote .getCreatedAt (), now );
3766
38- // 1) 결과 집계 시작: 3분
39- tasks .add (scheduler .schedule (
40- () -> notifier .shareVoteStatus (sessionKey ),
41- 3 , TimeUnit .MINUTES
42- ));
67+ VoteStats stats = voteStatsService .getStats (vote .getId ());
4368
44- // 2) 미투표자 독촉
45- tasks .add (scheduler .schedule (
46- () -> notifier .remindNonVoters (sessionKey , RemindTiming .MIN_30 ),
47- 30 , TimeUnit .MINUTES
48- ));
49- tasks .add (scheduler .schedule (
50- () -> notifier .remindNonVoters (sessionKey , RemindTiming .HOUR_2 ),
51- 2 , TimeUnit .HOURS
52- ));
53- tasks .add (scheduler .schedule (
54- () -> notifier .remindNonVoters (sessionKey , RemindTiming .HOUR_6 ),
55- 6 , TimeUnit .HOURS
56- ));
57- tasks .add (scheduler .schedule (
58- () -> notifier .remindNonVoters (sessionKey , RemindTiming .HOUR_12 ),
59- 12 , TimeUnit .HOURS
60- ));
69+ // === 1. 모든 인원이 투표 완료 (PRD 2.5) ===
70+ if (stats .allVoted ()) {
71+ if (notificationService .markSentIfFirst (vote .getId (), NotificationType .DONE_ALL_VOTED )) {
72+ notifier .sendAllVoted (vote );
73+ vote .close ();
74+ log .info ("[Kakao Scheduler] All voted. voteId={} closed" , vote .getId ());
75+ }
76+ return ;
77+ }
6178
62- // 3) 최후통첩: 24시간
63- tasks .add (scheduler .schedule (
64- () -> notifier .sendFinalNotice (sessionKey ),
65- 24 , TimeUnit .HOURS
66- ));
67- // 4) 최후통첩 후 60분 내 미응답 시 확정
68- tasks .add (scheduler .schedule (
69- () -> notifier .finalizeIfNoResponse (sessionKey ),
70- 25 , TimeUnit .HOURS // 24h + 1h
71- ));
79+ // === 2. 결과 집계 (PRD 2.2)
80+ // 기본: 3분 경과 후
81+ // 예외: 3분 전이라도 과반 달성 시
82+ boolean shouldAggregate = elapsed .toMinutes () >= 3
83+ || (elapsed .toMinutes () < 3 && stats .majorityReached ());
7284
73- userTasks .put (sessionKey , tasks );
74- log .info ("[Kakao Scheduler] Schedule started: {}" , sessionKey );
75- }
85+ if (shouldAggregate ) {
86+ if (notificationService .markSentIfFirst (vote .getId (), NotificationType .AGGREGATION_3M )) {
87+ notifier .shareVoteStatus (vote );
88+ }
89+ }
7690
77- /**
78- * 스케줄 중지 (세션 종료 또는 재투표 시 호출)
79- */
80- public void stopSchedule (String sessionKey ) {
81- List <ScheduledFuture <?>> tasks = userTasks .remove (sessionKey );
82- if (tasks != null ) {
83- tasks .forEach (task -> task .cancel (false ));
84- log .info ("[Kakao Scheduler] Schedule stopped: {}" , sessionKey );
91+ // === 3. 독촉 (PRD 2.3) ===
92+ if (stats .hasNonVoters ()) {
93+ checkAndNudge (vote , elapsed , 30 , NotificationType .NUDGE_30M );
94+ checkAndNudge (vote , elapsed , 120 , NotificationType .NUDGE_2H );
95+ checkAndNudge (vote , elapsed , 360 , NotificationType .NUDGE_6H );
96+ checkAndNudge (vote , elapsed , 720 , NotificationType .NUDGE_12H );
97+ }
98+
99+ // === 4. 최후통첩 (PRD 2.4) ===
100+ if (elapsed .toHours () >= 24 && stats .hasNonVoters ()) {
101+ if (notificationService .markSentIfFirst (vote .getId (), NotificationType .ULTIMATUM_24H )) {
102+ notifier .sendFinalNotice (vote );
103+ }
85104 }
86105 }
87106
88- /**
89- * 활성 스케줄 여부 확인
90- */
91- public boolean hasActiveSchedule (String sessionKey ) {
92- return userTasks .containsKey (sessionKey );
107+ private void checkAndNudge (Vote vote , Duration elapsed , long minutes , NotificationType type ) {
108+ if (elapsed .toMinutes () >= minutes ) {
109+ if (notificationService .markSentIfFirst (vote .getId (), type )) {
110+ notifier .remindNonVoters (vote , type );
111+ }
112+ }
93113 }
94114}
0 commit comments