Skip to content

Commit 5f33821

Browse files
committed
feat: 결과 집계 / 독촉 / 최후통첩 event api 이용한 연동 추가
1 parent 1b109a1 commit 5f33821

17 files changed

Lines changed: 581 additions & 479 deletions

src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClientImpl.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ public class KakaoChatClientImpl implements KakaoChatClient {
1515

1616
public KakaoChatClientImpl(
1717
RestClient.Builder builder,
18-
@Value("${kakao.bot.base-url}") String baseUrl,
19-
@Value("${kakao.bot.bot-id}") String botId,
20-
@Value("${kakao.rest.api-key}") String restApiKey
18+
@Value("${kakao.bot-base-url}") String baseUrl,
19+
@Value("${kakao.bot-id}") String botId,
20+
@Value("${kakao.rest-api-key}") String restApiKey
2121
) {
2222
this.botId = botId;
2323
this.restClient = builder

src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
import lombok.extern.slf4j.Slf4j;
1212
import org.springframework.http.ResponseEntity;
1313
import org.springframework.web.bind.annotation.*;
14+
import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes;
15+
import com.workingdead.meet.service.ParticipantService;
16+
17+
import java.util.*;
18+
import java.util.stream.Collectors;
1419

1520
/**
1621
* 카카오 i 오픈빌더 스킬 서버 컨트롤러
@@ -27,6 +32,7 @@ public class KakaoSkillController {
2732

2833
private final KakaoWendyService kakaoWendyService;
2934
private final ObjectMapper objectMapper;
35+
private final ParticipantService participantService;
3036

3137

3238
/**
@@ -255,6 +261,120 @@ public ResponseEntity<KakaoResponse> handleHelp(@RequestBody KakaoRequest reques
255261
return ResponseEntity.ok(kakaoWendyService.help());
256262
}
257263

264+
/**
265+
* 독촉 스킬 (이벤트 API로 트리거되는 전용 블록)
266+
* - Event API data로 voteId, timing(NUDGE_30M/NUDGE_2H/...)을 전달받아
267+
* 스킬 응답에서 멘션 + 고정 문구(simpleText)를 생성합니다.
268+
*/
269+
@Operation(summary = "독촉 (이벤트 트리거)")
270+
@PostMapping("/notify/remind")
271+
public ResponseEntity<KakaoResponse> handleRemind(@RequestBody KakaoRequest request) {
272+
Long voteId = parseLongParam(request.getParam("voteId"));
273+
String timing = safe(request.getParam("timing"));
274+
275+
if (voteId == null) {
276+
return ResponseEntity.ok(KakaoResponse.simpleText("voteId가 없어 독촉 메시지를 만들 수 없어요."));
277+
}
278+
279+
// 미투표자 botUserKey 목록 조회
280+
List<ParticipantStatusRes> statuses = participantService.getParticipantStatusByVoteId(voteId);
281+
List<String> nonVoterKeys = statuses.stream()
282+
.filter(s -> !Boolean.TRUE.equals(s.submitted()))
283+
.map(ParticipantStatusRes::botUserKey)
284+
.filter(Objects::nonNull)
285+
.filter(k -> !k.isBlank())
286+
.collect(Collectors.toList());
287+
288+
if (nonVoterKeys.isEmpty()) {
289+
return ResponseEntity.ok(KakaoResponse.simpleText("이미 모두 투표를 완료했어요! :D"));
290+
}
291+
292+
String message = buildRemindMessage(timing);
293+
return ResponseEntity.ok(buildMentionSimpleText(nonVoterKeys, message));
294+
}
295+
296+
/**
297+
* 최후통첩 스킬 (이벤트 API로 트리거되는 전용 블록)
298+
*/
299+
@Operation(summary = "최후통첩 (이벤트 트리거)")
300+
@PostMapping("/notify/final")
301+
public ResponseEntity<KakaoResponse> handleFinal(@RequestBody KakaoRequest request) {
302+
Long voteId = parseLongParam(request.getParam("voteId"));
303+
304+
if (voteId == null) {
305+
return ResponseEntity.ok(KakaoResponse.simpleText("voteId가 없어 최후통첩 메시지를 만들 수 없어요."));
306+
}
307+
308+
List<ParticipantStatusRes> statuses = participantService.getParticipantStatusByVoteId(voteId);
309+
List<String> nonVoterKeys = statuses.stream()
310+
.filter(s -> !Boolean.TRUE.equals(s.submitted()))
311+
.map(ParticipantStatusRes::botUserKey)
312+
.filter(Objects::nonNull)
313+
.filter(k -> !k.isBlank())
314+
.collect(Collectors.toList());
315+
316+
if (nonVoterKeys.isEmpty()) {
317+
return ResponseEntity.ok(KakaoResponse.simpleText("이미 모두 투표를 완료했어요! :D"));
318+
}
319+
320+
// PRD 2.4 최후통첩 메시지 동적 조립 (링크 제외)
321+
String message = kakaoWendyService.buildFinalUltimatumMessage(voteId);
322+
323+
return ResponseEntity.ok(buildMentionSimpleText(nonVoterKeys, message));
324+
}
325+
326+
/**
327+
* 멘션 + simpleText 응답 생성
328+
* - text 본문에는 #{mentions.user1} 형태의 플레이스홀더를 넣고
329+
* - extra.mentions에 key(user1) -> botUserKey 값을 매핑합니다.
330+
*/
331+
private KakaoResponse buildMentionSimpleText(List<String> botUserKeys, String message) {
332+
// 카카오 멘션은 너무 길면 UX가 깨지므로 상한을 둡니다(필요 시 조정)
333+
int limit = Math.min(botUserKeys.size(), 10);
334+
335+
Map<String, String> mentions = new LinkedHashMap<>();
336+
StringBuilder sb = new StringBuilder();
337+
338+
for (int i = 0; i < limit; i++) {
339+
String key = "user" + (i + 1);
340+
mentions.put(key, botUserKeys.get(i));
341+
// KakaoResponse 규약: #{mentions.key}
342+
sb.append(KakaoResponse.buildMentionText(key)).append(" ");
343+
}
344+
345+
sb.append("\n\n");
346+
sb.append(message);
347+
348+
return KakaoResponse.simpleTextWithMentions(sb.toString().trim(), mentions);
349+
}
350+
351+
private String buildRemindMessage(String timing) {
352+
// timing 값은 NotificationType.name() (예: NUDGE_30M)
353+
if (timing == null) {
354+
return "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D";
355+
}
356+
return switch (timing) {
357+
case "NUDGE_30M" -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D";
358+
case "NUDGE_2H" -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D";
359+
case "NUDGE_6H" -> "다들 투표를 기다리고 있어요🤔";
360+
case "NUDGE_12H" -> "웬디 기다리다 지쳐버림....🥺 혹시 대머리신가요....?";
361+
default -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D";
362+
};
363+
}
364+
365+
private static Long parseLongParam(String raw) {
366+
try {
367+
if (raw == null || raw.isBlank()) return null;
368+
return Long.parseLong(raw.trim());
369+
} catch (Exception e) {
370+
return null;
371+
}
372+
}
373+
374+
private static String safe(String s) {
375+
return (s == null) ? null : s.trim();
376+
}
377+
258378
/**
259379
* 헬스체크 (카카오 스킬 서버 상태 확인용)
260380
*/

src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,28 @@ public static Button messageButton(String label, String messageText) {
282282
.build();
283283
}
284284

285+
/**
286+
* 단순 텍스트 + 멘션(extra.mentions) 응답 생성
287+
* - text에는 buildMentionText("user1") -> "#{mentions.user1}" 형태로 삽입하세요.
288+
* - mentions 맵은 key(예: user1) -> botUserKey(value) 로 넣습니다.
289+
*/
290+
public static KakaoResponse simpleTextWithMentions(String text, Map<String, String> mentions) {
291+
return KakaoResponse.builder()
292+
.version("2.0")
293+
.template(Template.builder()
294+
.outputs(List.of(
295+
Output.builder()
296+
.simpleText(SimpleText.builder().text(text).build())
297+
.build()
298+
))
299+
.build())
300+
.extra((mentions == null || mentions.isEmpty()) ? null : Extra.builder().mentions(mentions).build())
301+
.build();
302+
}
303+
285304
/**
286305
* 텍스트 + 퀵리플라이 + 멘션(extra.mentions) 응답 생성
287-
* text에 #{mentions.key} 형식으로 멘션 삽입 가능
306+
* text에는 buildMentionText("user1") -> "#{mentions.user1}" 형식으로 멘션을 삽입합니다.
288307
*/
289308
public static KakaoResponse textWithQuickRepliesAndMentions(
290309
String text,
Lines changed: 82 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,114 @@
11
package 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;
37
import 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;
511
import lombok.extern.slf4j.Slf4j;
12+
import org.springframework.scheduling.annotation.Scheduled;
613
import org.springframework.stereotype.Component;
714

15+
import java.time.Duration;
16+
import java.time.Instant;
817
import 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
2029
public 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

Comments
 (0)