diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8dd1719..c9b38f8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - feature/kakao-api jobs: deploy: runs-on: ubuntu-latest @@ -60,6 +61,8 @@ jobs: export DB_PASSWORD='${{ secrets.DB_PASSWORD }}' export DISCORD_TOKEN='${{ secrets.DISCORD_TOKEN }}' + export KAKAO_REST_API_KEY='${{ secrets.KAKAO_REST_API_KEY }}' + export KAKAO_EVENT_API_KEY='${{ secrets.KAKAO_EVENT_API_KEY }}' nohup java -jar build/libs/workingdead-0.0.1-SNAPSHOT.jar > app.log 2>&1 & diff --git a/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java index 16a2e50..2550c78 100644 --- a/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java +++ b/src/main/java/com/workingdead/chatbot/discord/service/DiscordWendyServiceImpl.java @@ -101,7 +101,7 @@ public void addParticipant(String channelId, String memberId, String memberName) return; } - ParticipantRes pRes = participantService.add(voteId, memberName); + ParticipantRes pRes = participantService.add(voteId, null, memberName); System.out.println("[Discord When:D] Participant added AFTER vote: " + memberName + " (discordId=" + memberId + ", participantId=" + pRes.id() + ")"); } diff --git a/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClient.java b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClient.java new file mode 100644 index 0000000..8156376 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClient.java @@ -0,0 +1,7 @@ +package com.workingdead.chatbot.kakao.client; + +import java.util.List; + +public interface KakaoChatClient { + List fetchChatUsers(String botGroupKey); +} diff --git a/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClientImpl.java b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClientImpl.java new file mode 100644 index 0000000..c827455 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatClientImpl.java @@ -0,0 +1,43 @@ +package com.workingdead.chatbot.kakao.client; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; + +@Component +public class KakaoChatClientImpl implements KakaoChatClient { + + private final RestClient restClient; + private final String botId; + + public KakaoChatClientImpl( + RestClient.Builder builder, + @Value("${kakao.bot-base-url}") String baseUrl, + @Value("${kakao.bot-id}") String botId, + @Value("${kakao.rest-api-key}") String restApiKey + ) { + this.botId = botId; + this.restClient = builder + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + restApiKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, "application/json") + .build(); + } + + @Override + public List fetchChatUsers(String botGroupKey) { + KakaoChatMembersResponse res = restClient.get() + .uri("/v2/bots/{botId}/group-chat-rooms/{botGroupKey}/members", botId, botGroupKey) + .retrieve() + .body(KakaoChatMembersResponse.class); + + if (res == null || res.users() == null) return List.of(); + + return res.users().stream() + .map(KakaoChatUser::new) // botUserKey 그대로 + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatMembersResponse.java b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatMembersResponse.java new file mode 100644 index 0000000..b8be4b5 --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatMembersResponse.java @@ -0,0 +1,7 @@ +package com.workingdead.chatbot.kakao.client; + +import java.util.List; + +public record KakaoChatMembersResponse(List users) { + +} diff --git a/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatUser.java b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatUser.java new file mode 100644 index 0000000..94ec0fc --- /dev/null +++ b/src/main/java/com/workingdead/chatbot/kakao/client/KakaoChatUser.java @@ -0,0 +1,5 @@ +package com.workingdead.chatbot.kakao.client; + +public record KakaoChatUser(String botUserKey) { + +} diff --git a/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java index 419c264..b73f67e 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java +++ b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java @@ -11,12 +11,16 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; +import com.workingdead.meet.service.ParticipantService; + +import java.util.*; +import java.util.stream.Collectors; /** * 카카오 i 오픈빌더 스킬 서버 컨트롤러 * * 카카오톡 챗봇에서 발화를 받아 처리하고 응답을 반환합니다. - * - 개인챗: userKey 기반 세션 * - 그룹챗: botGroupKey 기반 세션 */ @Tag(name = "Kakao Chatbot", description = "카카오 챗봇 스킬 API") @@ -28,17 +32,8 @@ public class KakaoSkillController { private final KakaoWendyService kakaoWendyService; private final ObjectMapper objectMapper; + private final ParticipantService participantService; - /** - * 세션 키 결정 (그룹챗이면 botGroupKey, 개인챗이면 userKey) - */ - private String getSessionKey(KakaoRequest request) { - String botGroupKey = request.getBotGroupKey(); - if (botGroupKey != null && !botGroupKey.isBlank()) { - return botGroupKey; - } - return request.getUserKey(); - } /** * 메인 스킬 엔드포인트 (폴백 블록) @@ -47,7 +42,6 @@ private String getSessionKey(KakaoRequest request) { @Operation(summary = "메인 스킬 (폴백)") @PostMapping("/main") public ResponseEntity handleMain(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); String botGroupKey = request.getBotGroupKey(); String botUserKey = request.getBotUserKey(); String utterance = request.getUtterance(); @@ -58,18 +52,12 @@ public ResponseEntity handleMain(@RequestBody KakaoRequest reques log.warn("[Kakao Skill] Failed to log raw request: {}", e.getMessage()); } - - - log.info("[Kakao Skill] sessionKey={}, botGroupKey={}, botUserKey={}, utterance={}", - sessionKey, botGroupKey, botUserKey, utterance); - - if (sessionKey == null || sessionKey.isBlank()) { - log.warn("[Kakao Skill] Missing sessionKey. botGroupKey={}, userKey={}, botUserKey={}", - botGroupKey, request.getUserKey(), botUserKey); - return ResponseEntity.ok(kakaoWendyService.help()); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); } - if (utterance == null || utterance.isBlank()) { return ResponseEntity.ok(kakaoWendyService.help()); } @@ -78,7 +66,7 @@ public ResponseEntity handleMain(@RequestBody KakaoRequest reques // 1. 웬디 시작 if (trimmed.equals("웬디 시작") || trimmed.equals("시작")) { - return ResponseEntity.ok(kakaoWendyService.startSession(sessionKey, botGroupKey)); + return ResponseEntity.ok(kakaoWendyService.startSession(botGroupKey)); } // 2. 도움말 @@ -88,21 +76,21 @@ public ResponseEntity handleMain(@RequestBody KakaoRequest reques // 3. 웬디 종료 if (trimmed.equals("웬디 종료") || trimmed.equals("종료")) { - return ResponseEntity.ok(kakaoWendyService.endSession(sessionKey)); + return ResponseEntity.ok(kakaoWendyService.endSession(botGroupKey)); } // 4. 웬디 결과 if (trimmed.equals("웬디 결과") || trimmed.equals("결과") || trimmed.equals("결과 확인")) { - return ResponseEntity.ok(kakaoWendyService.getVoteResult(sessionKey)); + return ResponseEntity.ok(kakaoWendyService.getVoteResult(botGroupKey)); } // 5. 웬디 재투표 if (trimmed.equals("웬디 재투표") || trimmed.equals("재투표")) { - return ResponseEntity.ok(kakaoWendyService.revote(sessionKey)); + return ResponseEntity.ok(kakaoWendyService.revote(botGroupKey)); } // 6. 웬디 {기간} (예: "웬디 2주 후", "웬디 이번주") - // 멘션/푸시 기능 없이, 기간 입력을 받으면 바로 투표 URL을 생성해 반환 + // 기간 입력을 받으면 서버에서 날짜 범위를 계산해 투표를 생성하고 /open-vote 링크를 제공합니다. if (trimmed.startsWith("웬디 ")) { String arg = trimmed.substring("웬디 ".length()).trim(); // 예약어는 위에서 이미 처리했지만, 안전하게 한 번 더 방어 @@ -115,39 +103,38 @@ public ResponseEntity handleMain(@RequestBody KakaoRequest reques && !arg.equals("독촉")) { Integer weeks = kakaoWendyService.parseWeeks(arg); if (weeks != null) { - KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); + KakaoResponse response = kakaoWendyService.createVote(botGroupKey, weeks); return ResponseEntity.ok(response); } } } - // 세션 상태에 따른 처리 - SessionState state = kakaoWendyService.getSessionState(sessionKey); + SessionState state = kakaoWendyService.getSessionState(botGroupKey); switch (state) { - case WAITING_PARTICIPANTS: - // 참석자 입력: PRD 기준으로 botUserKey(멘션된 유저 키) 기반을 우선 사용 - // 멘션 기반 참석자 수집 기능을 사용하지 않는 정책으로 전환 + case IDLE: return ResponseEntity.ok(KakaoResponse.simpleText( - "\"@웬디 2주 후\"처럼 기간을 입력하면 바로 날짜 투표 링크를 만들어드릴게요!" + "\"웬디 시작\"을 입력해 투표를 시작해 주세요!" )); - case WAITING_WEEKS: // 주차 선택 - Integer weeks = kakaoWendyService.parseWeeks(trimmed); - if (weeks != null) { - KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); - return ResponseEntity.ok(response); - } - break; - + return ResponseEntity.ok(KakaoResponse.simpleText( + "기간을 선택해 주세요! 예) 이번주, 다음주, 2주 후" + )); + case VOTE_CREATED: + return ResponseEntity.ok(KakaoResponse.simpleText( + "이미 진행 중인 투표가 있어요.\n" + + "- 결과: \"웬디 결과\"\n" + + "- 새로 시작: \"웬디 재투표\"\n\n" + + "투표하러 가기: /open-vote" + )); default: break; } // 알 수 없는 입력 - return ResponseEntity.ok(kakaoWendyService.unknownInput(sessionKey)); + return ResponseEntity.ok(kakaoWendyService.unknownInput(botGroupKey)); } /** @@ -156,10 +143,14 @@ public ResponseEntity handleMain(@RequestBody KakaoRequest reques @Operation(summary = "웬디 시작") @PostMapping("/start") public ResponseEntity handleStart(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); String botGroupKey = request.getBotGroupKey(); - log.info("[Kakao Skill] START - sessionKey={}, botGroupKey={}", sessionKey, botGroupKey); - return ResponseEntity.ok(kakaoWendyService.startSession(sessionKey, botGroupKey)); + log.info("botGroupKey={}", request.getBotGroupKey()); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); + } + return ResponseEntity.ok(kakaoWendyService.startSession(botGroupKey)); } /** @@ -170,10 +161,14 @@ public ResponseEntity handleStart(@RequestBody KakaoRequest reque @Operation(summary = "참석자 등록") @PostMapping("/participants") public ResponseEntity handleParticipants(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); - log.info("[Kakao Skill] PARTICIPANTS - disabled. sessionKey={}", sessionKey); + String botGroupKey = request.getBotGroupKey(); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); + } return ResponseEntity.ok(KakaoResponse.simpleText( - "\"@웬디 2주 후\"처럼 기간을 입력하면 날짜 투표 링크를 만들어드릴게요!" + "참석자 단계는 생략했어요. 기간을 입력해 주세요! 예) 이번주, 다음주, 2주 후" )); } @@ -183,9 +178,13 @@ public ResponseEntity handleParticipants(@RequestBody KakaoReques @Operation(summary = "웬디 종료") @PostMapping("/end") public ResponseEntity handleEnd(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); - log.info("[Kakao Skill] END - sessionKey={}", sessionKey); - return ResponseEntity.ok(kakaoWendyService.endSession(sessionKey)); + String botGroupKey = request.getBotGroupKey(); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); + } + return ResponseEntity.ok(kakaoWendyService.endSession(botGroupKey)); } /** @@ -195,22 +194,20 @@ public ResponseEntity handleEnd(@RequestBody KakaoRequest request @Operation(summary = "주차 선택") @PostMapping("/select-week") public ResponseEntity handleSelectWeek(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); String botGroupKey = request.getBotGroupKey(); String weeksParam = request.getParam("weeks"); + log.info("[SKILL] select-week called botGroupKey={}", botGroupKey); - log.info("[Kakao Skill] SELECT_WEEK - sessionKey={}, botGroupKey={}, weeksParam={}", sessionKey, botGroupKey, weeksParam); - - if (sessionKey == null || sessionKey.isBlank()) { - log.warn("[Kakao Skill] SELECT_WEEK missing sessionKey. botGroupKey={}, userKey={}", - botGroupKey, request.getUserKey()); - return ResponseEntity.ok(KakaoResponse.simpleText("세션 정보를 확인하지 못했어요. 다시 시작해 주세요.")); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); } + // 상태 검증: 주차 선택 단계에서만 허용 - SessionState state = kakaoWendyService.getSessionState(sessionKey); + SessionState state = kakaoWendyService.getSessionState(botGroupKey); if (state != SessionState.WAITING_WEEKS) { - log.warn("[Kakao Skill] SELECT_WEEK called in invalid state. sessionKey={}, state={}", sessionKey, state); - return ResponseEntity.ok(kakaoWendyService.unknownInput(sessionKey)); + return ResponseEntity.ok(kakaoWendyService.unknownInput(botGroupKey)); } // weeks 파싱 (param 우선, 없으면 utterance로 보조) @@ -218,11 +215,10 @@ public ResponseEntity handleSelectWeek(@RequestBody KakaoRequest Integer weeks = (candidate == null) ? null : kakaoWendyService.parseWeeks(candidate.trim()); if (weeks == null || weeks < 0) { - log.warn("[Kakao Skill] SELECT_WEEK invalid weeks. sessionKey={}, candidate={}", sessionKey, candidate); return ResponseEntity.ok(KakaoResponse.simpleText("주차 선택 값을 확인하지 못했어요. 다시 선택해 주세요.")); } - KakaoResponse response = kakaoWendyService.createVote(sessionKey, weeks, botGroupKey); + KakaoResponse response = kakaoWendyService.createVote(botGroupKey, weeks); return ResponseEntity.ok(response); } @@ -232,9 +228,13 @@ public ResponseEntity handleSelectWeek(@RequestBody KakaoRequest @Operation(summary = "투표 결과 조회") @PostMapping("/result") public ResponseEntity handleResult(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); - log.info("[Kakao Skill] RESULT - sessionKey={}", sessionKey); - return ResponseEntity.ok(kakaoWendyService.getVoteResult(sessionKey)); + String botGroupKey = request.getBotGroupKey(); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); + } + return ResponseEntity.ok(kakaoWendyService.getVoteResult(botGroupKey)); } /** @@ -243,9 +243,13 @@ public ResponseEntity handleResult(@RequestBody KakaoRequest requ @Operation(summary = "재투표") @PostMapping("/revote") public ResponseEntity handleRevote(@RequestBody KakaoRequest request) { - String sessionKey = getSessionKey(request); - log.info("[Kakao Skill] REVOTE - sessionKey={}", sessionKey); - return ResponseEntity.ok(kakaoWendyService.revote(sessionKey)); + String botGroupKey = request.getBotGroupKey(); + if (botGroupKey == null || botGroupKey.isBlank()) { + return ResponseEntity.ok(KakaoResponse.simpleText( + "이 기능은 그룹 채팅방에서만 사용할 수 있어요. 그룹방에서 @웬디를 불러주세요!" + )); + } + return ResponseEntity.ok(kakaoWendyService.revote(botGroupKey)); } /** @@ -254,68 +258,129 @@ public ResponseEntity handleRevote(@RequestBody KakaoRequest requ @Operation(summary = "도움말") @PostMapping("/help") public ResponseEntity handleHelp(@RequestBody KakaoRequest request) { - log.info("[Kakao Skill] HELP - sessionKey={}", getSessionKey(request)); return ResponseEntity.ok(kakaoWendyService.help()); } /** - * 헬스체크 (카카오 스킬 서버 상태 확인용) + * 독촉 스킬 (이벤트 API로 트리거되는 전용 블록) + * - Event API data로 voteId, timing(NUDGE_30M/NUDGE_2H/...)을 전달받아 + * 스킬 응답에서 멘션 + 고정 문구(simpleText)를 생성합니다. */ - @Operation(summary = "헬스체크") - @GetMapping("/health") - public ResponseEntity health() { - return ResponseEntity.ok("OK"); + @Operation(summary = "독촉 (이벤트 트리거)") + @PostMapping("/notify/remind") + public ResponseEntity handleRemind(@RequestBody KakaoRequest request) { + Long voteId = parseLongParam(request.getParam("voteId")); + String timing = safe(request.getParam("timing")); + + if (voteId == null) { + return ResponseEntity.ok(KakaoResponse.simpleText("voteId가 없어 독촉 메시지를 만들 수 없어요.")); + } + + // 미투표자 botUserKey 목록 조회 + List statuses = participantService.getParticipantStatusByVoteId(voteId); + List nonVoterKeys = statuses.stream() + .filter(s -> !Boolean.TRUE.equals(s.submitted())) + .map(ParticipantStatusRes::botUserKey) + .filter(Objects::nonNull) + .filter(k -> !k.isBlank()) + .collect(Collectors.toList()); + + if (nonVoterKeys.isEmpty()) { + return ResponseEntity.ok(KakaoResponse.simpleText("이미 모두 투표를 완료했어요! :D")); + } + + String message = buildRemindMessage(timing); + return ResponseEntity.ok(buildMentionSimpleText(nonVoterKeys, message)); } + /** + * 최후통첩 스킬 (이벤트 API로 트리거되는 전용 블록) + */ + @Operation(summary = "최후통첩 (이벤트 트리거)") + @PostMapping("/notify/final") + public ResponseEntity handleFinal(@RequestBody KakaoRequest request) { + Long voteId = parseLongParam(request.getParam("voteId")); + + if (voteId == null) { + return ResponseEntity.ok(KakaoResponse.simpleText("voteId가 없어 최후통첩 메시지를 만들 수 없어요.")); + } + + List statuses = participantService.getParticipantStatusByVoteId(voteId); + List nonVoterKeys = statuses.stream() + .filter(s -> !Boolean.TRUE.equals(s.submitted())) + .map(ParticipantStatusRes::botUserKey) + .filter(Objects::nonNull) + .filter(k -> !k.isBlank()) + .collect(Collectors.toList()); + + if (nonVoterKeys.isEmpty()) { + return ResponseEntity.ok(KakaoResponse.simpleText("이미 모두 투표를 완료했어요! :D")); + } + + // PRD 2.4 최후통첩 메시지 동적 조립 (링크 제외) + String message = kakaoWendyService.buildFinalUltimatumMessage(voteId); + + return ResponseEntity.ok(buildMentionSimpleText(nonVoterKeys, message)); + } /** - * 참석자 botUserKey 목록 추출 - * - 오픈빌더의 "발화에서 멘션된 유저 식별" 결과를 params로 전달받는 것을 1순위로 사용 - * - 지원 키: botUserKeys / participants / mentionedUserKeys - * - 값 형태: "k1,k2,k3" 또는 "k1 k2 k3" 등(구분자는 콤마/공백/개행 모두 허용) - * - * @param request 요청 DTO - * @param fallbackUtterance params가 없을 때 마지막 fallback(테스트/디버그용) - * @return 콤마(,)로 join된 botUserKey 목록 문자열 + * 멘션 + simpleText 응답 생성 + * - text 본문에는 #{mentions.user1} 형태의 플레이스홀더를 넣고 + * - extra.mentions에 key(user1) -> botUserKey 값을 매핑합니다. */ - private String extractParticipantKeys(KakaoRequest request, String fallbackUtterance) { - String raw = firstNonBlank( - request.getParam("botUserKeys"), - request.getParam("participants"), - request.getParam("mentionedUserKeys") - ); - - if (raw == null || raw.isBlank()) { - raw = (fallbackUtterance == null) ? "" : fallbackUtterance; + private KakaoResponse buildMentionSimpleText(List botUserKeys, String message) { + // 카카오 멘션은 너무 길면 UX가 깨지므로 상한을 둡니다(필요 시 조정) + int limit = Math.min(botUserKeys.size(), 10); + + Map mentions = new LinkedHashMap<>(); + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < limit; i++) { + String key = "user" + (i + 1); + mentions.put(key, botUserKeys.get(i)); + // KakaoResponse 규약: #{mentions.key} + sb.append(KakaoResponse.buildMentionText(key)).append(" "); } - return normalizeKeys(raw); + sb.append("\n\n"); + sb.append(message); + + return KakaoResponse.simpleTextWithMentions(sb.toString().trim(), mentions); + } + + private String buildRemindMessage(String timing) { + // timing 값은 NotificationType.name() (예: NUDGE_30M) + if (timing == null) { + return "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D"; + } + return switch (timing) { + case "NUDGE_30M" -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D"; + case "NUDGE_2H" -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D"; + case "NUDGE_6H" -> "다들 투표를 기다리고 있어요🤔"; + case "NUDGE_12H" -> "웬디 기다리다 지쳐버림....🥺 혹시 대머리신가요....?"; + default -> "투표가 시작됐어요! 다른 분들을 위해 빠른 참여 부탁드려요 :D"; + }; } - private String firstNonBlank(String... values) { - if (values == null) return null; - for (String v : values) { - if (v != null && !v.isBlank()) return v; + private static Long parseLongParam(String raw) { + try { + if (raw == null || raw.isBlank()) return null; + return Long.parseLong(raw.trim()); + } catch (Exception e) { + return null; } - return null; + } + + private static String safe(String s) { + return (s == null) ? null : s.trim(); } /** - * 다양한 구분자(콤마/공백/개행)를 콤마 구분 문자열로 정규화 + * 헬스체크 (카카오 스킬 서버 상태 확인용) */ - private String normalizeKeys(String raw) { - if (raw == null) return ""; - String trimmed = raw.trim(); - if (trimmed.isEmpty()) return ""; - - // 콤마, 공백, 개행, 탭을 모두 구분자로 처리 - String[] parts = trimmed.split("[\\s,]+"); - java.util.LinkedHashSet set = new java.util.LinkedHashSet<>(); - for (String p : parts) { - if (p == null) continue; - String s = p.trim(); - if (!s.isEmpty()) set.add(s); - } - return String.join(",", set); + @Operation(summary = "헬스체크") + @GetMapping("/health") + public ResponseEntity health() { + return ResponseEntity.ok("OK"); } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java index e0531ab..683cca0 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoRequest.java @@ -29,6 +29,15 @@ public class KakaoRequest { public static class Chat { private String id; // 채팅방 ID (botGroupKey) private String type; // 채팅방 타입 + private ChatProperties properties; + } + + @Getter + @Setter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ChatProperties { + private String botGroupKey; } @Getter @@ -51,6 +60,7 @@ public static class UserRequest { private String utterance; private String lang; private User user; + private Chat chat; } @Getter @@ -80,6 +90,7 @@ public static class Properties { private String plusfriendUserKey; private String appUserId; private Boolean isFriend; + private String botUserKey; } @Getter @@ -152,10 +163,26 @@ public String getBotId() { /** * 그룹 채팅방 키 (botGroupKey) 조회 + * - 카카오 요청에서는 userRequest.chat.id 가 botGroupKey로 사용 가능하며, + * userRequest.chat.properties.botGroupKey 로도 전달됩니다. */ public String getBotGroupKey() { + // 1) userRequest.chat.id (요청에 기본 포함) + if (userRequest != null && userRequest.getChat() != null) { + String id = userRequest.getChat().getId(); + if (id != null && !id.isBlank()) return id; + + // 2) userRequest.chat.properties.botGroupKey + if (userRequest.getChat().getProperties() != null) { + String key = userRequest.getChat().getProperties().getBotGroupKey(); + if (key != null && !key.isBlank()) return key; + } + } + + // 3) 호환: 최상위 chat.id (현재 JSON에는 없지만 혹시 몰라 유지) if (chat != null) { - return chat.getId(); + String id = chat.getId(); + if (id != null && !id.isBlank()) return id; } return null; } @@ -164,16 +191,22 @@ public String getBotGroupKey() { * 그룹 채팅방 여부 확인 */ public boolean isGroupChat() { - return chat != null && chat.getId() != null; + String key = getBotGroupKey(); + return key != null && !key.isBlank(); } /** * 그룹챗 사용자 식별용 botUserKey - * - PRD 기준: 멘션/참석자 식별 키로 사용 - * - 현재 구조에서는 user.id를 사용 + * - 카카오 요청에서는 userRequest.user.properties.botUserKey 로 전달됩니다. + * - fallback: userRequest.user.id */ public String getBotUserKey() { if (userRequest != null && userRequest.getUser() != null) { + Properties p = userRequest.getUser().getProperties(); + if (p != null) { + String key = p.getBotUserKey(); + if (key != null && !key.isBlank()) return key; + } String id = userRequest.getUser().getId(); return (id == null || id.isBlank()) ? null : id; } diff --git a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java index 9bb4f7f..91d3952 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java +++ b/src/main/java/com/workingdead/chatbot/kakao/dto/KakaoResponse.java @@ -282,9 +282,28 @@ public static Button messageButton(String label, String messageText) { .build(); } + /** + * 단순 텍스트 + 멘션(extra.mentions) 응답 생성 + * - text에는 buildMentionText("user1") -> "#{mentions.user1}" 형태로 삽입하세요. + * - mentions 맵은 key(예: user1) -> botUserKey(value) 로 넣습니다. + */ + public static KakaoResponse simpleTextWithMentions(String text, Map mentions) { + return KakaoResponse.builder() + .version("2.0") + .template(Template.builder() + .outputs(List.of( + Output.builder() + .simpleText(SimpleText.builder().text(text).build()) + .build() + )) + .build()) + .extra((mentions == null || mentions.isEmpty()) ? null : Extra.builder().mentions(mentions).build()) + .build(); + } + /** * 텍스트 + 퀵리플라이 + 멘션(extra.mentions) 응답 생성 - * text에 #{mentions.key} 형식으로 멘션 삽입 가능 + * text에는 buildMentionText("user1") -> "#{mentions.user1}" 형식으로 멘션을 삽입합니다. */ public static KakaoResponse textWithQuickRepliesAndMentions( String text, diff --git a/src/main/java/com/workingdead/chatbot/kakao/scheduler/KakaoWendyScheduler.java b/src/main/java/com/workingdead/chatbot/kakao/scheduler/KakaoWendyScheduler.java index dbc4428..2f96286 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/scheduler/KakaoWendyScheduler.java +++ b/src/main/java/com/workingdead/chatbot/kakao/scheduler/KakaoWendyScheduler.java @@ -1,94 +1,114 @@ package com.workingdead.chatbot.kakao.scheduler; +import com.workingdead.meet.entity.NotificationType; +import com.workingdead.meet.entity.Vote; +import com.workingdead.meet.entity.Vote.VoteStatus; +import com.workingdead.meet.repository.VoteRepository; import com.workingdead.chatbot.kakao.service.KakaoNotifier; -import com.workingdead.chatbot.kakao.service.KakaoNotifier.RemindTiming; +import com.workingdead.meet.service.VoteNotificationService; +import com.workingdead.meet.service.VoteStatsService; +import com.workingdead.meet.service.VoteStatsService.VoteStats; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import java.time.Duration; +import java.time.Instant; import java.util.List; -import java.util.Map; -import java.util.concurrent.*; /** - * 카카오 챗봇용 스케줄러 + * 카카오 챗봇 투표 알림 스케줄러 (DB 기반) * - * Discord WendyScheduler와 동일한 타이밍으로 알림을 전송합니다. - * sessionKey(그룹챗이면 botGroupKey, 개인챗이면 userKey) 기반으로 스케줄을 관리합니다. - **/ + * - In-memory ScheduledFuture 사용 ❌ + * - 매 1분마다 ACTIVE 투표를 조회하여 + * PRD 기준(집계 / 독촉 / 최후통첩 / 완료)을 판단 + * - vote_notification 테이블로 중복 발송 방지 + */ @Component @Slf4j public class KakaoWendyScheduler { - private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); + private final VoteRepository voteRepository; private final KakaoNotifier notifier; - private final Map>> userTasks = new ConcurrentHashMap<>(); + private final VoteNotificationService notificationService; + private final VoteStatsService voteStatsService; - public KakaoWendyScheduler(KakaoNotifier notifier) { + public KakaoWendyScheduler( + VoteRepository voteRepository, + KakaoNotifier notifier, + VoteNotificationService notificationService, + VoteStatsService voteStatsService + ) { + this.voteRepository = voteRepository; this.notifier = notifier; + this.notificationService = notificationService; + this.voteStatsService = voteStatsService; } /** - * 스케줄 시작 (투표 생성 후 호출) + * 1분 주기로 ACTIVE 투표를 스캔 */ - public void startSchedule(String sessionKey) { - stopSchedule(sessionKey); + @Scheduled(fixedDelay = 60_000) + public void tick() { + List activeVotes = voteRepository.findAllByStatus(VoteStatus.ACTIVE); + for (Vote vote : activeVotes) { + try { + processVote(vote); + } catch (Exception e) { + log.error("[Kakao Scheduler] Failed to process voteId={}", vote.getId(), e); + } + } + } - CopyOnWriteArrayList> tasks = new CopyOnWriteArrayList<>(); + private void processVote(Vote vote) { + Instant now = Instant.now(); + Duration elapsed = Duration.between(vote.getCreatedAt(), now); - // 1) 결과 집계 시작: 3분 - tasks.add(scheduler.schedule( - () -> notifier.shareVoteStatus(sessionKey), - 3, TimeUnit.MINUTES - )); + VoteStats stats = voteStatsService.getStats(vote.getId()); - // 2) 미투표자 독촉 - tasks.add(scheduler.schedule( - () -> notifier.remindNonVoters(sessionKey, RemindTiming.MIN_30), - 30, TimeUnit.MINUTES - )); - tasks.add(scheduler.schedule( - () -> notifier.remindNonVoters(sessionKey, RemindTiming.HOUR_2), - 2, TimeUnit.HOURS - )); - tasks.add(scheduler.schedule( - () -> notifier.remindNonVoters(sessionKey, RemindTiming.HOUR_6), - 6, TimeUnit.HOURS - )); - tasks.add(scheduler.schedule( - () -> notifier.remindNonVoters(sessionKey, RemindTiming.HOUR_12), - 12, TimeUnit.HOURS - )); + // === 1. 모든 인원이 투표 완료 (PRD 2.5) === + if (stats.allVoted()) { + if (notificationService.markSentIfFirst(vote.getId(), NotificationType.DONE_ALL_VOTED)) { + notifier.sendAllVoted(vote); + vote.close(); + log.info("[Kakao Scheduler] All voted. voteId={} closed", vote.getId()); + } + return; + } - // 3) 최후통첩: 24시간 - tasks.add(scheduler.schedule( - () -> notifier.sendFinalNotice(sessionKey), - 24, TimeUnit.HOURS - )); - // 4) 최후통첩 후 60분 내 미응답 시 확정 - tasks.add(scheduler.schedule( - () -> notifier.finalizeIfNoResponse(sessionKey), - 25, TimeUnit.HOURS // 24h + 1h - )); + // === 2. 결과 집계 (PRD 2.2) + // 기본: 3분 경과 후 + // 예외: 3분 전이라도 과반 달성 시 + boolean shouldAggregate = elapsed.toMinutes() >= 3 + || (elapsed.toMinutes() < 3 && stats.majorityReached()); - userTasks.put(sessionKey, tasks); - log.info("[Kakao Scheduler] Schedule started: {}", sessionKey); - } + if (shouldAggregate) { + if (notificationService.markSentIfFirst(vote.getId(), NotificationType.AGGREGATION_3M)) { + notifier.shareVoteStatus(vote); + } + } - /** - * 스케줄 중지 (세션 종료 또는 재투표 시 호출) - */ - public void stopSchedule(String sessionKey) { - List> tasks = userTasks.remove(sessionKey); - if (tasks != null) { - tasks.forEach(task -> task.cancel(false)); - log.info("[Kakao Scheduler] Schedule stopped: {}", sessionKey); + // === 3. 독촉 (PRD 2.3) === + if (stats.hasNonVoters()) { + checkAndNudge(vote, elapsed, 30, NotificationType.NUDGE_30M); + checkAndNudge(vote, elapsed, 120, NotificationType.NUDGE_2H); + checkAndNudge(vote, elapsed, 360, NotificationType.NUDGE_6H); + checkAndNudge(vote, elapsed, 720, NotificationType.NUDGE_12H); + } + + // === 4. 최후통첩 (PRD 2.4) === + if (elapsed.toHours() >= 24 && stats.hasNonVoters()) { + if (notificationService.markSentIfFirst(vote.getId(), NotificationType.ULTIMATUM_24H)) { + notifier.sendFinalNotice(vote); + } } } - /** - * 활성 스케줄 여부 확인 - */ - public boolean hasActiveSchedule(String sessionKey) { - return userTasks.containsKey(sessionKey); + private void checkAndNudge(Vote vote, Duration elapsed, long minutes, NotificationType type) { + if (elapsed.toMinutes() >= minutes) { + if (notificationService.markSentIfFirst(vote.getId(), type)) { + notifier.remindNonVoters(vote, type); + } + } } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoBotApiClient.java b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoBotApiClient.java deleted file mode 100644 index 636a72b..0000000 --- a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoBotApiClient.java +++ /dev/null @@ -1,241 +0,0 @@ -package com.workingdead.chatbot.kakao.service; - -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.workingdead.config.KakaoConfig; -import lombok.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.List; -import java.util.Map; - -/** - * 카카오 Bot API 클라이언트 - * - * - Event API: 그룹 채팅방에 메시지 발송 - * - Chatroom Info API: 참여 채팅방 조회, 멤버 조회 - */ -@Service -@RequiredArgsConstructor -@Slf4j -public class KakaoBotApiClient { - - private final KakaoConfig kakaoConfig; - private final RestTemplate kakaoRestTemplate; - - // ==================== Event API ==================== - - /** - * 그룹 채팅방에 이벤트 메시지 발송 - * POST /v2/bots/{botId}/group - * - * @param botGroupKeys 발송 대상 채팅방 키 리스트 (최대 100개) - * @param eventName 관리자센터에 등록한 이벤트 블록 이름 - * @return EventResponse (taskId, status) - */ - public EventResponse sendEventMessage(List botGroupKeys, String eventName) { - String url = KakaoConfig.BOT_API_BASE_URL + "/v2/bots/" + kakaoConfig.getBotId() + "/group"; - - EventRequest request = EventRequest.builder() - .chat(botGroupKeys.stream() - .map(key -> Map.of("id", key)) - .toList()) - .event(Map.of("name", eventName)) - .build(); - - HttpEntity entity = new HttpEntity<>(request, createHeaders()); - - try { - ResponseEntity response = kakaoRestTemplate.exchange( - url, HttpMethod.POST, entity, EventResponse.class); - log.info("[KakaoBotApi] Event message sent. taskId: {}", response.getBody().getTaskId()); - return response.getBody(); - } catch (Exception e) { - log.error("[KakaoBotApi] Failed to send event message: {}", e.getMessage()); - throw new RuntimeException("Failed to send Kakao event message", e); - } - } - - /** - * 이벤트 발송 결과 조회 - * GET /v1/tasks/{taskId} - */ - public TaskResultResponse getTaskResult(String taskId) { - String url = KakaoConfig.BOT_API_BASE_URL + "/v1/tasks/" + taskId; - - HttpEntity entity = new HttpEntity<>(createHeaders()); - - try { - ResponseEntity response = kakaoRestTemplate.exchange( - url, HttpMethod.GET, entity, TaskResultResponse.class); - return response.getBody(); - } catch (Exception e) { - log.error("[KakaoBotApi] Failed to get task result: {}", e.getMessage()); - throw new RuntimeException("Failed to get task result", e); - } - } - - // ==================== Chatroom Info API ==================== - - /** - * 봇이 참여 중인 채팅방 키 조회 - * GET /v2/bots/{botId}/bot-group-keys - */ - public BotGroupKeysResponse getBotGroupKeys(int pageNumber, int pageSize) { - String url = KakaoConfig.BOT_API_BASE_URL + "/v2/bots/" + kakaoConfig.getBotId() - + "/bot-group-keys?pageNumber=" + pageNumber + "&pageSize=" + pageSize; - - HttpEntity entity = new HttpEntity<>(createHeaders()); - - try { - ResponseEntity response = kakaoRestTemplate.exchange( - url, HttpMethod.GET, entity, BotGroupKeysResponse.class); - log.info("[KakaoBotApi] Got {} bot group keys", response.getBody().getTotal()); - return response.getBody(); - } catch (Exception e) { - log.error("[KakaoBotApi] Failed to get bot group keys: {}", e.getMessage()); - throw new RuntimeException("Failed to get bot group keys", e); - } - } - - /** - * 봇이 참여 중인 채팅방 키 전체 조회 (페이징 자동 처리) - */ - public List getAllBotGroupKeys() { - BotGroupKeysResponse response = getBotGroupKeys(0, 100); - return response.getBotGroupKeys(); - } - - /** - * 채팅방 리스트 및 구독 상태 조회 - * GET /v2/bots/{botId}/group-chat-rooms - */ - public GroupChatRoomsResponse getGroupChatRooms(int pageNumber, int pageSize) { - String url = KakaoConfig.BOT_API_BASE_URL + "/v2/bots/" + kakaoConfig.getBotId() - + "/group-chat-rooms?pageNumber=" + pageNumber + "&pageSize=" + pageSize; - - HttpEntity entity = new HttpEntity<>(createHeaders()); - - try { - ResponseEntity response = kakaoRestTemplate.exchange( - url, HttpMethod.GET, entity, GroupChatRoomsResponse.class); - return response.getBody(); - } catch (Exception e) { - log.error("[KakaoBotApi] Failed to get group chat rooms: {}", e.getMessage()); - throw new RuntimeException("Failed to get group chat rooms", e); - } - } - - /** - * 특정 채팅방의 참여 유저 목록 조회 - * GET /v2/bots/{botId}/group-chat-rooms/{botGroupKey}/members - */ - public ChatRoomMembersResponse getChatRoomMembers(String botGroupKey) { - String url = KakaoConfig.BOT_API_BASE_URL + "/v2/bots/" + kakaoConfig.getBotId() - + "/group-chat-rooms/" + botGroupKey + "/members"; - - HttpEntity entity = new HttpEntity<>(createHeaders()); - - try { - ResponseEntity response = kakaoRestTemplate.exchange( - url, HttpMethod.GET, entity, ChatRoomMembersResponse.class); - log.info("[KakaoBotApi] Got {} members in chat room {}", - response.getBody().getMembers().size(), botGroupKey); - return response.getBody(); - } catch (Exception e) { - log.error("[KakaoBotApi] Failed to get chat room members: {}", e.getMessage()); - throw new RuntimeException("Failed to get chat room members", e); - } - } - - // ==================== Helper Methods ==================== - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "KakaoAK " + kakaoConfig.getRestApiKey()); - return headers; - } - - // ==================== Request/Response DTOs ==================== - - @Getter - @Builder - @JsonInclude(JsonInclude.Include.NON_NULL) - public static class EventRequest { - private List> chat; // [{"id": "botGroupKey1"}, {"id": "botGroupKey2"}] - private Map event; // {"name": "eventName"} - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class EventResponse { - private String taskId; - private String status; - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class TaskResultResponse { - private String taskId; - private String status; // ALL SUCCESS, PARTIAL SUCCESS, ALL FAIL - private Integer successCount; - private Integer failCount; - private List fail; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class FailDetail { - private String botGroupKey; - private String errorCode; - private String errorMessage; - } - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class BotGroupKeysResponse { - private Integer total; - private List botGroupKeys; - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class GroupChatRoomsResponse { - private Integer total; - private List chatRooms; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class GroupChatRoom { - private String botGroupKey; - private Boolean isSubscribed; // 이벤트 메시지 수신 여부 - } - } - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class ChatRoomMembersResponse { - private List members; - - @Getter - @NoArgsConstructor - @AllArgsConstructor - public static class ChatRoomMember { - private String botUserKey; - private String nickname; - private String profileImageUrl; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java index e6075fb..35c04fe 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java +++ b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java @@ -1,315 +1,191 @@ package com.workingdead.chatbot.kakao.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.workingdead.config.KakaoConfig; import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; -import com.workingdead.meet.dto.VoteResultDtos.RankingRes; -import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; +import com.workingdead.meet.entity.NotificationType; +import com.workingdead.meet.entity.Vote; import com.workingdead.meet.service.ParticipantService; -import com.workingdead.meet.service.VoteResultService; -import java.time.Duration; -import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.*; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; -/** - * 카카오톡 알림 서비스 - * - * 카카오 Bot API를 통해 그룹 채팅방에 이벤트 메시지를 전송합니다. - * - Event API: 그룹 채팅방에 Push 메시지 전송 - * - 개인챗은 스킬 응답으로만 메시지 전송 가능 (Pull 방식) - */ @Service @RequiredArgsConstructor @Slf4j public class KakaoNotifier { private final KakaoConfig kakaoConfig; - private final KakaoWendyService kakaoWendyService; - private final KakaoBotApiClient kakaoBotApiClient; - private final VoteResultService voteResultService; private final ParticipantService participantService; - private final RestTemplate kakaoRestTemplate; - private final ObjectMapper objectMapper; - // ========== Event API (그룹 채팅방 메시지 발송) ========== + /** + * Kakao Event API base URL + */ + private static final String BASE_URL = "https://bot-api.kakao.com"; /** - * 그룹 채팅방에 이벤트 메시지 발송 - * - * @param botGroupKey 채팅방 키 - * @param eventName 관리자센터에 등록된 이벤트 블록 이름 + * (Guide) Event API: POST /v2/bots/{botId}/group + * - Authorization: KakaoAK {REST API Key} + * - Body: { chat: [{id, type}], event: {name, data} } */ - public void sendEventToGroup(String botGroupKey, String eventName) { - try { - if (botGroupKey == null || botGroupKey.isBlank()) { - log.warn("[Kakao Notifier] botGroupKey is empty. Cannot send event message."); - return; - } + public void sendEventToGroup(String botGroupKey, String eventName, Map eventData) { + if (botGroupKey == null || botGroupKey.isBlank()) { + log.warn("[Kakao Notifier] botGroupKey is empty. Skip send."); + return; + } + if (eventName == null || eventName.isBlank()) { + log.warn("[Kakao Notifier] eventName is empty. Skip send."); + return; + } - KakaoBotApiClient.EventResponse response = - kakaoBotApiClient.sendEventMessage(List.of(botGroupKey), eventName); - log.info("[Kakao Notifier] Event sent: botGroupKey={}, eventName={}, taskId={}", - botGroupKey, eventName, response.getTaskId()); + try { + String botId = kakaoConfig.getBotId(); + String restApiKey = kakaoConfig.getRestApiKey(); + + Map body = new HashMap<>(); + + Map chat = new HashMap<>(); + chat.put("id", botGroupKey); + chat.put("type", "botGroupKey"); + body.put("chat", List.of(chat)); + + Map event = new HashMap<>(); + event.put("name", eventName); + event.put("data", eventData == null ? Map.of() : eventData); + body.put("event", event); + + RestClient client = RestClient.builder() + .baseUrl(BASE_URL) + .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK " + restApiKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + + Map res = client.post() + .uri("/v2/bots/{botId}/group", botId) + .body(body) + .retrieve() + .body(Map.class); + + Object taskId = res == null ? null : res.get("taskId"); + Object status = res == null ? null : res.get("status"); + log.info("[Kakao Notifier] Event sent: botGroupKey={}, eventName={}, taskId={}, status={}", + botGroupKey, eventName, taskId, status); } catch (Exception e) { - log.error("[Kakao Notifier] Failed to send event message: {}", e.getMessage()); + log.error("[Kakao Notifier] Failed to send event: botGroupKey={}, eventName={}", botGroupKey, eventName, e); } } - /** - * 투표 현황 공유 (이벤트 메시지) - */ - public void shareVoteStatus(String sessionKey) { - try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); - if (voteId == null) return; - - LocalDateTime createdAt = kakaoWendyService.getVoteCreatedAtBySessionKey(sessionKey); - if (createdAt == null) return; - - long elapsedSeconds = Duration.between(createdAt, LocalDateTime.now()).getSeconds(); - - List statuses = participantService.getParticipantStatusByVoteId(voteId); - long submittedCount = statuses.stream().filter(s -> Boolean.TRUE.equals(s.submitted())).count(); - long totalCount = statuses.size(); + /** 편의 오버로드 (data 없는 이벤트) */ + public void sendEventToGroup(String botGroupKey, String eventName) { + sendEventToGroup(botGroupKey, eventName, Map.of()); + } - // 참가자 자체가 없는 비정상 케이스(생성 꼬임 등) 방어 - if (totalCount == 0) { - log.warn("[Kakao Notifier] No participants found for voteId={}", voteId); - return; - } + // ========== Scheduler가 호출하는 Vote-centric API ========== + /** 미투표자 존재 여부 */ + public boolean hasNonVoters(Vote vote) { + if (vote == null) return false; + List statuses = participantService.getParticipantStatusByVoteId(vote.getId()); + return statuses.stream().anyMatch(s -> !Boolean.TRUE.equals(s.submitted())); + } - boolean allSubmitted = totalCount > 0 && submittedCount == totalCount; - boolean majoritySubmitted = totalCount > 0 && submittedCount * 2 >= totalCount; // 과반(>=) + /** 전체 투표 완료 여부 */ + public boolean isAllVoted(Vote vote) { + if (vote == null) return false; + List statuses = participantService.getParticipantStatusByVoteId(vote.getId()); + if (statuses.isEmpty()) return false; + return statuses.stream().allMatch(s -> Boolean.TRUE.equals(s.submitted())); + } - // 3분 전 & 과반 미달이면 아무것도 안 보냄 - if (elapsedSeconds < 180 && !majoritySubmitted && !allSubmitted) { - return; - } + /** (PRD 2.2) 투표 현황 공유 */ + public void shareVoteStatus(Vote vote) { + if (vote == null) return; + String botGroupKey = vote.getBotGroupKey(); - // 3분 경과했는데 0명 투표면 안내 메시지 - if (elapsedSeconds >= 180 && submittedCount == 0) { - sendToGroupIfPossible(voteId, "status_nobody_voted"); - return; - } + List statuses = participantService.getParticipantStatusByVoteId(vote.getId()); + long submitted = statuses.stream().filter(s -> Boolean.TRUE.equals(s.submitted())).count(); - // 결과 공유 - sendToGroupIfPossible(voteId, "status_vote_result"); + // 블록은 고정, 서버는 분기만: 아무도 투표 안함 vs 결과 존재 + String eventName = (submitted == 0) ? "status_nobody_voted" : "status_vote_result"; - // 전원 투표 완료면 완료 단계로 이동 트리거 - if (allSubmitted) { - sendToGroupIfPossible(voteId, "status_all_done"); - } + Map data = new HashMap<>(); + data.put("voteId", String.valueOf(vote.getId())); + data.put("submittedCount", submitted); + data.put("totalCount", statuses.size()); + sendEventToGroup(botGroupKey, eventName, data); - } catch (Exception e) { - log.error("[Kakao Notifier] Failed to share vote status: {}", e.getMessage()); + // 전원 완료면 별도 이벤트 (필요하면) + if (!statuses.isEmpty() && submitted == statuses.size()) { + sendEventToGroup(botGroupKey, "status_all_done", data); } } - public void sendFinalNotice(String sessionKey) { - try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); - if (voteId == null) return; + /** (PRD 2.3) 독촉 */ + public void remindNonVoters(Vote vote, NotificationType type) { + if (vote == null) return; + + // 스킬에서 멘션/문구를 생성하므로, 여기서는 이벤트 블록 트리거만 수행 + String eventName = switch (type) { + case NUDGE_30M -> "remind_30min"; + case NUDGE_2H -> "remind_2hour"; + case NUDGE_6H -> "remind_6hour"; + case NUDGE_12H -> "remind_12hour"; + default -> "remind_30min"; // fallback + }; - List nonVoters = getNonVoterNames(voteId); - if (nonVoters.isEmpty()) return; // 미투표자 없으면 전송 X + Map data = new HashMap<>(); + data.put("voteId", String.valueOf(vote.getId())); + // 스킬에서 timing에 따라 고정 문구를 선택할 수 있게 전달 + data.put("timing", type.name()); - sendToGroupIfPossible(voteId, "final_notice_24h"); - } catch (Exception e) { - log.error("[Kakao Notifier] sendFinalNotice failed: {}", e.getMessage(), e); - } + sendEventToGroup(vote.getBotGroupKey(), eventName, data); } - public void finalizeIfNoResponse(String sessionKey) { - try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); - if (voteId == null) return; - - List nonVoters = getNonVoterNames(voteId); - if (nonVoters.isEmpty()) return; // 이미 다 했으면 확정 처리 X - - sendToGroupIfPossible(voteId, "finalize_after_60m"); + /** (PRD 2.4) 최후통첩 */ + public void sendFinalNotice(Vote vote) { + if (vote == null) return; - } catch (Exception e) { - log.error("[Kakao Notifier] finalizeIfNoResponse failed: {}", e.getMessage(), e); - } - } - - private void sendToGroupIfPossible(Long voteId, String eventName) { - String botGroupKey = kakaoWendyService.getBotGroupKeyByVoteId(voteId); - if (botGroupKey != null && !botGroupKey.isBlank()) { - sendEventToGroup(botGroupKey, eventName); - } else { - log.info("[Kakao Notifier] Individual chat: voteId={}, eventName={} (cannot push)", voteId, eventName); + String botGroupKey = vote.getBotGroupKey(); + if (botGroupKey == null || botGroupKey.isBlank()) { + log.warn("[Kakao Notifier] botGroupKey is empty. Skip final notice. voteId={}", vote.getId()); + return; } - } - /** - * 미투표자 리마인드 (이벤트 메시지) - */ - public void remindNonVoters(String sessionKey, RemindTiming timing) { - try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); - if (voteId == null) { - log.warn("[Kakao Notifier] No vote found for sessionKey: {}", sessionKey); - return; - } - - List nonVoters = getNonVoterNames(voteId); - if (nonVoters.isEmpty()) { - log.info("[Kakao Notifier] No non-voters. Skip reminder: sessionKey={}, timing={}", sessionKey, timing); - return; - } - - - String eventName = switch (timing) { - case MIN_30 -> "remind_30min"; - case HOUR_2 -> "remind_2hour"; - case HOUR_6 -> "remind_6hour"; - case HOUR_12 -> "remind_12hour"; - }; - - // botGroupKey 조회 (그룹챗인 경우에만 이벤트 발송) - String botGroupKey = kakaoWendyService.getBotGroupKeyByVoteId(voteId); - if (botGroupKey != null) { - sendEventToGroup(botGroupKey, eventName); - } else { - log.info("[Kakao Notifier] Reminder for individual chat (cannot push): sessionKey={}, timing={}", - sessionKey, timing); - } + // 스킬(/kakao/skill/notify/final)에서 voteId로 1등 후보 + deadline(now+60m)를 계산한다. + Map data = new HashMap<>(); + data.put("voteId", String.valueOf(vote.getId())); + data.put("timing", NotificationType.ULTIMATUM_24H.name()); - } catch (Exception e) { - log.error("[Kakao Notifier] Failed to send reminder: {}", e.getMessage()); - } + sendEventToGroup(botGroupKey, "final_notice_24h", data); } - /** - * 투표 결과 메시지 생성 - */ - public String buildVoteResultMessage(Long voteId) { - VoteResultRes result = voteResultService.getVoteResult(voteId); - - if (result == null || result.rankings() == null || result.rankings().isEmpty()) { - return "아직 투표 결과가 없어요."; - } - - StringBuilder sb = new StringBuilder(); - sb.append("📊 투표 현황\n\n"); - - for (RankingRes ranking : result.rankings()) { - String medal = switch (ranking.rank()) { - case 1 -> "🥇"; - case 2 -> "🥈"; - case 3 -> "🥉"; - default -> " "; - }; - - String dayLabel = getDayLabel(ranking.date().getDayOfWeek()); - String periodLabel = "LUNCH".equals(ranking.period()) ? "점심" : "저녁"; - - sb.append(medal) - .append(" ") - .append(ranking.rank()) - .append("위: ") - .append(ranking.date().format(DateTimeFormatter.ofPattern("MM/dd"))) - .append("(") - .append(dayLabel) - .append(") ") - .append(periodLabel) - .append(" - ") - .append(ranking.voteCount()) - .append("명\n"); - } - - return sb.toString(); + /** (PRD 2.5) 모두 투표 완료 */ + public void sendAllVoted(Vote vote) { + if (vote == null) return; + Map data = new HashMap<>(); + data.put("voteId", String.valueOf(vote.getId())); + sendEventToGroup(vote.getBotGroupKey(), "done_all_voted", data); } /** - * 미투표자 목록 조회 + * 미투표자 이름 목록 + * - displayName이 null/blank면 "미등록"으로 표시 */ public List getNonVoterNames(Long voteId) { List statuses = participantService.getParticipantStatusByVoteId(voteId); return statuses.stream() .filter(s -> !Boolean.TRUE.equals(s.submitted())) .map(ParticipantStatusRes::displayName) + .map(name -> (name == null || name.isBlank()) ? "미등록" : name) .collect(Collectors.toList()); } - - /** - * 카카오 메시지 API 호출 (템플릿) - * - * 참고: 실제 사용하려면 카카오 비즈메시지 설정 필요 - * - 카카오톡 채널 개설 - * - 발신 프로필 등록 - * - 알림톡 템플릿 승인 - */ -// public boolean sendKakaoMessage(String userKey, String templateId, Map templateArgs) { -// try { -// String adminKey = kakaoConfig.getAdminKey(); -// if (adminKey == null || adminKey.isBlank()) { -// log.warn("[Kakao Notifier] Admin key not configured. Message not sent."); -// return false; -// } -// -// HttpHeaders headers = new HttpHeaders(); -// headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); -// headers.set("Authorization", "KakaoAK " + adminKey); -// -// // 템플릿 기반 메시지 구성 -// Map templateObject = new HashMap<>(); -// templateObject.put("object_type", "text"); -// templateObject.put("text", templateArgs.getOrDefault("message", "웬디 알림")); -// templateObject.put("link", Map.of( -// "web_url", templateArgs.getOrDefault("link", "https://whendy.netlify.app"), -// "mobile_web_url", templateArgs.getOrDefault("link", "https://whendy.netlify.app") -// )); -// -// String templateObjectJson = objectMapper.writeValueAsString(templateObject); -// String body = "receiver_uuids=[\"" + userKey + "\"]&template_object=" + templateObjectJson; -// -// HttpEntity request = new HttpEntity<>(body, headers); -// -// ResponseEntity response = kakaoRestTemplate.exchange( -// KAKAO_FRIEND_MESSAGE_URL, -// HttpMethod.POST, -// request, -// String.class -// ); -// -// log.info("[Kakao Notifier] Message sent. Response: {}", response.getBody()); -// return response.getStatusCode().is2xxSuccessful(); -// -// } catch (Exception e) { -// log.error("[Kakao Notifier] Failed to send Kakao message: {}", e.getMessage()); -// return false; -// } -// } - - private String getDayLabel(DayOfWeek dayOfWeek) { - return switch (dayOfWeek) { - case MONDAY -> "월"; - case TUESDAY -> "화"; - case WEDNESDAY -> "수"; - case THURSDAY -> "목"; - case FRIDAY -> "금"; - case SATURDAY -> "토"; - case SUNDAY -> "일"; - }; - } - - public enum RemindTiming { - MIN_30, HOUR_2, HOUR_6, HOUR_12 - } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoWendyService.java b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoWendyService.java index fcdbb37..eae6e13 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoWendyService.java +++ b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoWendyService.java @@ -1,12 +1,12 @@ package com.workingdead.chatbot.kakao.service; +import com.workingdead.chatbot.kakao.client.KakaoChatClient; +import com.workingdead.chatbot.kakao.client.KakaoChatUser; import com.workingdead.chatbot.kakao.dto.KakaoResponse; -import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; import com.workingdead.meet.dto.VoteDtos.CreateVoteReq; import com.workingdead.meet.dto.VoteDtos.VoteSummary; import com.workingdead.meet.dto.VoteResultDtos.RankingRes; import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; -import com.workingdead.meet.service.ParticipantService; import com.workingdead.meet.service.VoteResultService; import com.workingdead.meet.service.VoteService; import lombok.RequiredArgsConstructor; @@ -22,10 +22,7 @@ import java.util.stream.Collectors; /** - * 카카오 챗봇용 웬디 서비스 - * Discord와 독립적으로 세션 관리 - * - 개인챗: userKey 기반 - * - 그룹챗: botGroupKey 기반 + * - 그룹챗 전용: botGroupKey 기반 */ @Service @RequiredArgsConstructor @@ -33,69 +30,38 @@ public class KakaoWendyService { private final VoteService voteService; - private final ParticipantService participantService; private final VoteResultService voteResultService; - // ========== 세션 관리 (sessionKey = botGroupKey 또는 userKey) ========== + // ========== 세션 관리 (key = botGroupKey) ========== // 활성 세션 관리 private final Set activeSessions = ConcurrentHashMap.newKeySet(); - - // 참석자 목록 (sessionKey -> List) - private final Map> participants = new ConcurrentHashMap<>(); - - // 참석자 표시명 (sessionKey -> List<표시명>) - private final Map> participantDisplayNames = new ConcurrentHashMap<>(); - - // 생성된 투표 ID (sessionKey -> voteId) + // 생성된 투표 ID (botGroupKey -> voteId) private final Map sessionVoteId = new ConcurrentHashMap<>(); - - // 생성된 투표 링크 (sessionKey -> shareUrl) - private final Map sessionShareUrl = new ConcurrentHashMap<>(); - - // 투표 생성 시각 (sessionKey -> createdAt) - private final Map voteCreatedAt = new ConcurrentHashMap<>(); - - // 세션 상태 (sessionKey -> state) + // 세션 상태 (botGroupKey -> state) private final Map sessionStates = new ConcurrentHashMap<>(); - - // botGroupKey -> voteId 매핑 (이벤트 메시지 발송용) - private final Map groupVoteId = new ConcurrentHashMap<>(); - - // voteId -> botGroupKey 역매핑 - private final Map voteIdToGroupKey = new ConcurrentHashMap<>(); + private final KakaoChatClient kakaoChatClient; public enum SessionState { IDLE, - WAITING_PARTICIPANTS, WAITING_WEEKS, VOTE_CREATED } - // ========== Deprecated: 하위 호환성 ========== - @Deprecated - private final Map userVoteId = sessionVoteId; - @Deprecated - private final Map userShareUrl = sessionShareUrl; - - // ========== 세션 관리 ========== - /** - * 세션 시작 (웬디 시작) + * 세션 시작 (@웬디 시작) + * - 그룹챗 전용: botGroupKey를 세션 키로 사용 + * - 다음 단계: 주차(기간) 선택 대기 */ - public KakaoResponse startSession(String userKey) { - activeSessions.add(userKey); - participants.put(userKey, new ArrayList<>()); - participantDisplayNames.put(userKey, new ArrayList<>()); - userVoteId.remove(userKey); - userShareUrl.remove(userKey); - voteCreatedAt.remove(userKey); - sessionStates.put(userKey, SessionState.WAITING_WEEKS); + public KakaoResponse startSession(String botGroupKey) { + activeSessions.add(botGroupKey); + sessionVoteId.remove(botGroupKey); + sessionStates.put(botGroupKey, SessionState.WAITING_WEEKS); - log.info("[Kakao When:D] Session started: {}", userKey); + log.info("[Kakao When:D] Session started: botGroupKey={}", botGroupKey); Map data = new HashMap<>(); - data.put("sessionKey", userKey); + data.put("botGroupKey", botGroupKey); data.put("state", SessionState.WAITING_WEEKS.name()); data.put("active", true); return dataOnly(data); @@ -104,26 +70,29 @@ public KakaoResponse startSession(String userKey) { /** * 세션 활성 여부 확인 */ - public boolean isSessionActive(String userKey) { - return activeSessions.contains(userKey); + public boolean isSessionActive(String botGroupKey) { + return activeSessions.contains(botGroupKey); } /** * 세션 종료 (웬디 종료) */ - public KakaoResponse endSession(String userKey) { - activeSessions.remove(userKey); - participants.remove(userKey); - userVoteId.remove(userKey); - participantDisplayNames.remove(userKey); - userShareUrl.remove(userKey); - voteCreatedAt.remove(userKey); - sessionStates.remove(userKey); + public KakaoResponse endSession(String botGroupKey) { + Long voteId = sessionVoteId.get(botGroupKey); - log.info("[Kakao When:D] Session ended: {}", userKey); + if (voteId != null) { + voteService.closeVote(voteId); // status = CLOSED + log.info("[Kakao When:D] Vote closed: voteId={}", voteId); + } + + activeSessions.remove(botGroupKey); + sessionVoteId.remove(botGroupKey); + sessionStates.remove(botGroupKey); + + log.info("[Kakao When:D] Session ended: botGroupKey={}", botGroupKey); Map data = new HashMap<>(); - data.put("sessionKey", userKey); + data.put("botGroupKey", botGroupKey); data.put("state", SessionState.IDLE.name()); data.put("active", false); return dataOnly(data); @@ -132,65 +101,22 @@ public KakaoResponse endSession(String userKey) { /** * 현재 세션 상태 조회 */ - public SessionState getSessionState(String userKey) { - return sessionStates.getOrDefault(userKey, SessionState.IDLE); + public SessionState getSessionState(String botGroupKey) { + return sessionStates.getOrDefault(botGroupKey, SessionState.IDLE); } - // ========== 참석자 관리 ========== - - /** - * 참석자 추가 (botUserKey 리스트 입력) - **/ - public KakaoResponse addParticipants(String userKey, String input) { - // input: 컨트롤러에서 botUserKey 목록을 ","로 정규화하여 전달한다고 가정 - String raw = Optional.ofNullable(input).orElse(""); - - List keys = Arrays.stream(raw.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .distinct() - .collect(Collectors.toList()); - - if (keys.isEmpty()) { - Map data = new HashMap<>(); - data.put("sessionKey", userKey); - data.put("state", getSessionState(userKey).name()); - data.put("participantCount", 0); - data.put("enabled", false); - return dataOnly(data); - } - - // 표시명은 PRD 상 botUserKey만 받는 상황을 고려해 임시 생성 - List displayNames = new ArrayList<>(); - for (int i = 0; i < keys.size(); i++) { - displayNames.add("참석자" + (i + 1)); - } - - participants.put(userKey, keys); - participantDisplayNames.put(userKey, displayNames); - sessionStates.put(userKey, SessionState.WAITING_WEEKS); - - log.info("[Kakao When:D] Participants added: {} -> {}", userKey, keys); - - Map data = new HashMap<>(); - data.put("sessionKey", userKey); - data.put("state", SessionState.WAITING_WEEKS.name()); - data.put("participantCount", keys.size()); - data.put("botUserKeys", keys); - - data.put("participantDisplayNames", displayNames); - return dataOnly(data); - } + // 참석자 관리 섹션 삭제됨 // ========== 투표 생성 ========== /** - * 투표 생성 (주차 선택 후) + * 투표 생성 (주차/기간 선택 후) + * - 사용자 입력(weeks)을 기준으로 서버에서 날짜 범위를 계산합니다. + * - Vote 엔티티에 botGroupKey를 포함하여 저장해야 합니다. (Vote.botGroupKey, status=ACTIVE) + * - 사용자에게는 고정 리다이렉트 엔드포인트(/open-vote) 링크를 제공합니다. */ - public KakaoResponse createVote(String userKey, int weeks) { - voteCreatedAt.put(userKey, LocalDateTime.now()); - - // 1. 날짜 범위 계산 + public KakaoResponse createVote(String botGroupKey, int weeks) { + // 날짜 범위 계산 LocalDate today = LocalDate.now(); LocalDate startDate; LocalDate endDate; @@ -205,38 +131,51 @@ public KakaoResponse createVote(String userKey, int weeks) { endDate = startDate.plusDays(6); } - // 2. 참여자 표시명 리스트 - List participantNames = participantDisplayNames.getOrDefault(userKey, List.of()); + // 세션 상태 정리 + activeSessions.add(botGroupKey); + sessionStates.put(botGroupKey, SessionState.VOTE_CREATED); - // 3. 투표 생성 CreateVoteReq req = new CreateVoteReq( "카카오 투표", startDate, endDate, - participantNames.isEmpty() ? null : participantNames + null ); - VoteSummary summary = voteService.create(req); - Long voteId = summary.id(); - String shareUrl = summary.shareUrl(); + // 채팅방 참여자 목록 조회 (botUserKey 리스트) + List botUserKeys; + try { + botUserKeys = kakaoChatClient.fetchChatUsers(botGroupKey).stream() + .map(KakaoChatUser::botUserKey) + .distinct() + .toList(); + } catch (Exception e) { + log.error("[Kakao When:D] Failed to fetch chat members: botGroupKey={}", botGroupKey, e); + Map err = new HashMap<>(); + err.put("botGroupKey", botGroupKey); + err.put("state", SessionState.WAITING_WEEKS.name()); + err.put("error", "채팅방 참여자 목록 조회에 실패했어요. 잠시 후 다시 시도해주세요."); + return dataOnly(err); + } + + VoteSummary summary = voteService.createKakaoVote(req, botGroupKey, botUserKeys); Long voteId = summary.id(); - userVoteId.put(userKey, voteId); - userShareUrl.put(userKey, shareUrl); - sessionStates.put(userKey, SessionState.VOTE_CREATED); + // 고정 리다이렉트 링크 (카카오가 botGroupKey/botUserKey/appUserId를 자동 append) + String redirectUrl = "/open-vote"; - log.info("[Kakao When:D] Vote created: userKey={}, voteId={}, weeks={}", userKey, voteId, weeks); + sessionVoteId.put(botGroupKey, voteId); - String weekLabel = weeks == 0 ? "이번 주" : weeks + "주 뒤"; + log.info("[Kakao When:D] Vote created: botGroupKey={}, voteId={}, startDate={}, endDate={}, weeks={}", + botGroupKey, voteId, startDate, endDate, weeks); Map data = new HashMap<>(); + data.put("botGroupKey", botGroupKey); data.put("voteId", voteId); - data.put("shareUrl", shareUrl); - data.put("weekLabel", weekLabel); + data.put("memberCount", botUserKeys.size()); + data.put("redirectUrl", redirectUrl); data.put("startDate", startDate.toString()); data.put("endDate", endDate.toString()); - data.put("participants", participantNames); - - data.put("sessionKey", userKey); + data.put("weeks", weeks); data.put("state", SessionState.VOTE_CREATED.name()); return dataOnly(data); } @@ -245,16 +184,25 @@ public KakaoResponse createVote(String userKey, int weeks) { * 주차 파싱 (0 = 이번 주, 1~6 = n주 뒤) */ public Integer parseWeeks(String input) { - if (input.contains("이번")) return 0; - if (input.contains("1주")) return 1; - if (input.contains("2주")) return 2; - if (input.contains("3주")) return 3; - if (input.contains("4주")) return 4; - if (input.contains("5주")) return 5; - if (input.contains("6주")) return 6; - - // 숫자만 추출 - String numbers = input.replaceAll("[^0-9]", ""); + if (input == null || input.isBlank()) return null; + + String s = input.trim(); + + // 자주 쓰는 자연어 표현 + if (s.contains("이번")) return 0; + if (s.contains("다다음")) return 2; + if (s.contains("다음")) return 1; + + // 명시적 "n주" 표현 + if (s.contains("1주")) return 1; + if (s.contains("2주")) return 2; + if (s.contains("3주")) return 3; + if (s.contains("4주")) return 4; + if (s.contains("5주")) return 5; + if (s.contains("6주")) return 6; + + // 숫자만 추출 (예: "2주 후", "3주뒤" 등) + String numbers = s.replaceAll("[^0-9]", ""); if (!numbers.isEmpty()) { try { int weeks = Integer.parseInt(numbers); @@ -269,9 +217,9 @@ public Integer parseWeeks(String input) { /** * 투표 결과 조회 */ - public KakaoResponse getVoteResult(String userKey) { - Long voteId = userVoteId.get(userKey); - String shareUrl = userShareUrl.get(userKey); + public KakaoResponse getVoteResult(String botGroupKey) { + Long voteId = sessionVoteId.get(botGroupKey); + String redirectUrl = "/open-vote"; if (voteId == null) { return textOnly(""" @@ -287,13 +235,10 @@ public KakaoResponse getVoteResult(String userKey) { StringBuilder sb = new StringBuilder(); sb.append("웬디가 투표 현황을 공유드려요! :D\n\n"); sb.append("엥 아직 아무도 투표를 안 했네요 :(\n"); - if (shareUrl != null && !shareUrl.isBlank()) { - sb.append("\n투표하러 가기: ").append(shareUrl); - } + sb.append("\n투표하러 가기: ").append(redirectUrl); return textOnly(sb.toString().trim()); } - // 1~3순위만 출력 (없는 순위는 생략) List top3 = result.rankings().stream() .filter(r -> r.rank() != null) @@ -303,12 +248,7 @@ public KakaoResponse getVoteResult(String userKey) { StringBuilder sb = new StringBuilder(); sb.append("웬디가 투표 현황을 공유드려요! :D\n"); - - if (shareUrl != null && !shareUrl.isBlank()) { - sb.append("\n투표하러 가기: ").append(shareUrl).append("\n\n"); - } else { - sb.append("\n투표 링크가 준비되지 않았어요 😢\n\n"); - } + sb.append("\n투표하러 가기: ").append(redirectUrl).append("\n\n"); for (RankingRes rank : top3) { String periodLabel = "LUNCH".equals(rank.period()) ? "점심" : "저녁"; @@ -337,18 +277,67 @@ public KakaoResponse getVoteResult(String userKey) { } /** - * 재투표 (동일 참석자로 새 투표 생성) + * 최후통첩 문구에 들어갈 '현재 1등 후보' 텍스트를 반환합니다. + * - 결과 조회 로직(voteResultService.getVoteResult)을 그대로 재사용합니다. + * - 반환 형식: "11/25(화) 점심" 또는 "11/25(화) 저녁" + */ + public String getTopChoiceForFinal(Long voteId) { + if (voteId == null) { + throw new IllegalArgumentException("voteId must not be null"); + } + + VoteResultRes res = voteResultService.getVoteResult(voteId); + if (res == null || res.rankings() == null || res.rankings().isEmpty()) { + // 집계 불가 시 기본 문구(PRD에서 요구하는 형태로 대체 가능) + return "00/00(월) 점심/저녁"; + } + + // rankings는 이미 rank 기준으로 정렬되어 온다고 가정하지만, 안전하게 1순위를 한 번 더 보장 + RankingRes r1 = res.rankings().stream() + .filter(r -> r.rank() != null) + .min(Comparator.comparingInt(RankingRes::rank)) + .orElse(res.rankings().get(0)); + + String periodLabel = "LUNCH".equalsIgnoreCase(r1.period()) ? "점심" : "저녁"; + String dateText = (r1.date() == null) ? "00/00(월)" : String.valueOf(r1.date()); + return dateText + " " + periodLabel; + } + + /** + * PRD 2.4 최후통첩 메시지(링크 제외)를 동적으로 조립합니다. + * - deadline: 최후통첩 발송 시각 기준 + 60분 + * - 확정 대상: 현재 1등 후보(날짜 + 점심/저녁) + */ + public String buildFinalUltimatumMessage(Long voteId) { + LocalDateTime deadline = LocalDateTime.now().plusMinutes(60); + String deadlineText = deadline.format(DateTimeFormatter.ofPattern("HH:mm")); + String topChoice = getTopChoiceForFinal(voteId); + + return "스케쥴리도 이제 한계다. 그냥 투표하지 마라. 바쁘다 탓, 정신없다 탓하지 마라. 나도 충분히 기다려줬다.\n\n" + + "나나 너나 현생 살기 바쁘고 연락 하나 하는 것도 에너지 써야 하는 힘든 시절인 거 안다. 그래서 이번 약속만큼은 우리끼리라도 즐겁게 시간 보내자고 제안했던 거다. 너에게 언제나 최고의 장소는 아니더라도 최선의 맛집을 찾아주고 싶었다.\n\n" + + "내가 업무에 치이고 잠 줄여가며 네 답장 기다리고, 식당 예약 알아보고, 일정 조율하는 거, 이거 다 우리 좋은 시간 보내게 해주고 싶어서였다. 네가 읽씹하거나 답장이 늦을 때도, 겉으로는 \"바쁜가 보다\" 했지만 뒤에서는 '내가 너무 보채나' 하며 휴대폰만 만지작거렸다. 그래도 우리 만나면 즐겁겠지, 만나서 회포 풀면 스트레스 풀리겠지. 이 생각만 하며 꾹 참으며 며칠을 보냈다.\n\n" + + "그런데 이게 뭐냐? 너 지금 투표 올라온 지 몇 시간인지 알긴 하냐? 도대체 그 나이에 약속 하나 확답하는 게 그렇게 힘든 일이란 말이냐? 늘 만나자고는 말만 하면서 정작 실천하는 게 뭐냔 말이다.\n\n" + + "오늘 문득 너랑 약속 잡으려고 안달 난 내가 잘못했다는 생각이 든다. 거울 속 핸드폰만 붙잡고 있는 내 모습에 눈물이 나더라. 그냥... 이제 투표하지 마라. 나를 원망하지도 말고 네 스케줄대로 알아서 살아라. 나도 지쳤다. 당장 톡방을 나가라.\n\n" + + "📩 최후통첩\n\n" + + deadlineText + "까지 투표 불참 시, " + topChoice + "으로 확정할게요!😤"; + } + + /** + * 재투표 (세션 상태를 WAITING_WEEKS로 되돌리고 voteId를 제거) */ - public KakaoResponse revote(String userKey) { - if (!userVoteId.containsKey(userKey)) { + public KakaoResponse revote(String botGroupKey) { + if (!sessionVoteId.containsKey(botGroupKey)) { + activeSessions.add(botGroupKey); + sessionStates.put(botGroupKey, SessionState.WAITING_WEEKS); Map data = new HashMap<>(); data.put("hasVote", false); - data.put("state", getSessionState(userKey).name()); + data.put("state", SessionState.WAITING_WEEKS.name()); return dataOnly(data); } - userVoteId.remove(userKey); - sessionStates.put(userKey, SessionState.WAITING_WEEKS); + sessionVoteId.remove(botGroupKey); + activeSessions.add(botGroupKey); + sessionStates.put(botGroupKey, SessionState.WAITING_WEEKS); Map data = new HashMap<>(); data.put("hasVote", true); @@ -368,89 +357,21 @@ public KakaoResponse help() { /** * 알 수 없는 입력 처리 */ - public KakaoResponse unknownInput(String userKey) { - SessionState state = getSessionState(userKey); + public KakaoResponse unknownInput(String botGroupKey) { + SessionState state = getSessionState(botGroupKey); Map data = new HashMap<>(); data.put("state", state.name()); - String shareUrl = userShareUrl.get(userKey); - if (shareUrl != null) { - data.put("shareUrl", shareUrl); - } - Long voteId = userVoteId.get(userKey); + Long voteId = sessionVoteId.get(botGroupKey); if (voteId != null) { data.put("voteId", voteId); + data.put("redirectUrl", "/open-vote"); } + data.put("botGroupKey", botGroupKey); return dataOnly(data); } - // ========== 그룹챗 지원 메서드 ========== - - /** - * 세션 시작 (그룹챗용) - */ - public KakaoResponse startSession(String sessionKey, String botGroupKey) { - KakaoResponse response = startSession(sessionKey); - - // 그룹챗인 경우 botGroupKey 추가 저장 - if (botGroupKey != null && !botGroupKey.isBlank()) { - log.info("[Kakao When:D] Group session started: sessionKey={}, botGroupKey={}", sessionKey, botGroupKey); - } - - return response; - } - - /** - * 투표 생성 (그룹챗용) - */ - public KakaoResponse createVote(String sessionKey, int weeks, String botGroupKey) { - KakaoResponse response = createVote(sessionKey, weeks); - - // 그룹챗인 경우 botGroupKey -> voteId 매핑 저장 - if (botGroupKey != null && !botGroupKey.isBlank()) { - Long voteId = sessionVoteId.get(sessionKey); - if (voteId != null) { - groupVoteId.put(botGroupKey, voteId); - voteIdToGroupKey.put(voteId, botGroupKey); - log.info("[Kakao When:D] Group vote mapping: botGroupKey={}, voteId={}", botGroupKey, voteId); - } - } - - return response; - } - - /** - * botGroupKey로 voteId 조회 - */ public Long getVoteIdByBotGroupKey(String botGroupKey) { - return groupVoteId.get(botGroupKey); - } - - /** - * voteId로 botGroupKey 조회 - */ - public String getBotGroupKeyByVoteId(Long voteId) { - return voteIdToGroupKey.get(voteId); - } - - /** - * sessionKey로 voteId 조회 - */ - public Long getVoteIdBySessionKey(String sessionKey) { - return sessionVoteId.get(sessionKey); - } - - /** - * sessionKey로 shareUrl 조회 - */ - public String getShareUrlBySessionKey(String sessionKey) { - return sessionShareUrl.get(sessionKey); - } - - /** - * sessionKey로 voteCreatedAt 조회 - */ - public LocalDateTime getVoteCreatedAtBySessionKey(String sessionKey) { - return voteCreatedAt.get(sessionKey); + return sessionVoteId.get(botGroupKey); } // ========== 헬퍼 메서드 ========== @@ -479,15 +400,4 @@ private KakaoResponse textOnly(String text) { .build(); } - private String getDayLabel(DayOfWeek dayOfWeek) { - return switch (dayOfWeek) { - case MONDAY -> "월"; - case TUESDAY -> "화"; - case WEDNESDAY -> "수"; - case THURSDAY -> "목"; - case FRIDAY -> "금"; - case SATURDAY -> "토"; - case SUNDAY -> "일"; - }; - } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/config/KakaoConfig.java b/src/main/java/com/workingdead/config/KakaoConfig.java index d016ca9..0f95f20 100644 --- a/src/main/java/com/workingdead/config/KakaoConfig.java +++ b/src/main/java/com/workingdead/config/KakaoConfig.java @@ -21,9 +21,6 @@ public class KakaoConfig { private String channelId; private String botId; - // Bot API Base URL - public static final String BOT_API_BASE_URL = "https://bot-api.kakao.com"; - @Bean public RestTemplate kakaoRestTemplate() { return new RestTemplate(); diff --git a/src/main/java/com/workingdead/meet/controller/ParticipantController.java b/src/main/java/com/workingdead/meet/controller/ParticipantController.java index 0b4217e..62d4536 100644 --- a/src/main/java/com/workingdead/meet/controller/ParticipantController.java +++ b/src/main/java/com/workingdead/meet/controller/ParticipantController.java @@ -43,19 +43,18 @@ public ParticipantController( // 0.2 참여자 추가/삭제 @Operation( summary = "참여자 추가", - description = "특정 투표에 새로운 참여자를 추가합니다. displayName을 기반으로 참여자 칩이 생성됩니다." - ) + description = "PRD: 카카오가 전달한 botUserKey(voteUserKey)로 참여자를 식별합니다. 해당 voteId에 (botUserKey) 참가자가 없으면 생성하고, displayName은 웹에서 사용자가 입력하는 값이라 비어 있을 수 있으며 전달되면 저장/갱신합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "참여자 추가 성공", content = @Content(schema = @Schema(implementation = ParticipantDtos.ParticipantRes.class))), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (displayName이 비어있는 경우)", content = @Content), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (botUserKey가 비어있는 경우)", content = @Content), @ApiResponse(responseCode = "404", description = "투표를 찾을 수 없음", content = @Content) }) @PostMapping("/votes/{voteId}/participants") public ResponseEntity add( - @PathVariable Long voteId, - @RequestBody @Valid ParticipantDtos.CreateParticipantReq req) { - var res = participantService.add(voteId, req.displayName()); + @PathVariable Long voteId, + @RequestBody ParticipantDtos.CreateParticipantReq req) { + var res = participantService.add(voteId, req.botUserKey(), req.displayName()); return ResponseEntity.ok(res); } diff --git a/src/main/java/com/workingdead/meet/controller/RedirectController.java b/src/main/java/com/workingdead/meet/controller/RedirectController.java new file mode 100644 index 0000000..37bdf3c --- /dev/null +++ b/src/main/java/com/workingdead/meet/controller/RedirectController.java @@ -0,0 +1,41 @@ +package com.workingdead.meet.controller; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.workingdead.meet.entity.Vote; +import com.workingdead.meet.service.VoteService; +import java.net.URI; + +import java.net.URLEncoder; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class RedirectController { + + private final VoteService voteService; + + /** + * 카카오 챗봇 버튼 클릭 시 호출되는 리다이렉트 엔드포인트 + * - botGroupKey(채팅방 ID)를 기준으로 활성 투표를 조회 + * - 실제 투표 웹 페이지로 302 Redirect 수행 + */ + @GetMapping("/open-vote") + public ResponseEntity openVote( + @RequestParam String botGroupKey, + @RequestParam String botUserKey + ) { + // 1) 현재 ACTIVE 투표 찾기 + Vote vote = voteService.findActiveVoteByBotGroupKey(botGroupKey); + + // 2) 웹으로 redirect (web 라우트: /{shareCode}) + URI target = URI.create("https://whendy.netlify.app/" + vote.getCode() + + "?botUserKey=" + URLEncoder.encode(botUserKey, UTF_8) + ); + return ResponseEntity.status(302).location(target).build(); + } +} diff --git a/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java b/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java index fcd569d..3388108 100644 --- a/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java +++ b/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java @@ -7,14 +7,16 @@ public class ParticipantDtos { - public record CreateParticipantReq(@NotBlank String displayName) {} + public record CreateParticipantReq(@NotBlank String botUserKey, String displayName) {} public record UpdateParticipantReq(String displayName) {} public record ParticipantRes(Long id, String displayName, boolean loggedIn // 로그인 상태 ) {} - // 단순 상태 조회용 DTO (투표 여부 판단 전용) + // 단순 상태 조회용 DTO (투표 여부 판단/멘션 전용) + // - voteUserKey: 카카오 botUserKey(=vote_user_key 컬럼) 값 public record ParticipantStatusRes( Long id, + String botUserKey, String displayName, boolean submitted ) {} diff --git a/src/main/java/com/workingdead/meet/entity/NotificationType.java b/src/main/java/com/workingdead/meet/entity/NotificationType.java new file mode 100644 index 0000000..3c5c96a --- /dev/null +++ b/src/main/java/com/workingdead/meet/entity/NotificationType.java @@ -0,0 +1,11 @@ +package com.workingdead.meet.entity; + +public enum NotificationType { + AGGREGATION_3M, + NUDGE_30M, + NUDGE_2H, + NUDGE_6H, + NUDGE_12H, + ULTIMATUM_24H, + DONE_ALL_VOTED +} diff --git a/src/main/java/com/workingdead/meet/entity/Participant.java b/src/main/java/com/workingdead/meet/entity/Participant.java index 61fcd35..2f6ae75 100644 --- a/src/main/java/com/workingdead/meet/entity/Participant.java +++ b/src/main/java/com/workingdead/meet/entity/Participant.java @@ -13,8 +13,15 @@ @AllArgsConstructor @Builder @Entity -@Table(name = "participant") -public class Participant { +@Table( + name = "participant", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_participant_vote_bot_user", + columnNames = {"vote_id", "bot_user_key"} + ) + } +)public class Participant { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,13 +31,16 @@ public class Participant { @JoinColumn(name = "vote_id", nullable = false) private Vote vote; - @Column(name = "display_name", nullable = false) + @Column(name = "display_name") private String displayName; + @Column(name = "bot_user_key", nullable = false) + private String botUserKey; + @Column(name = "submitted_at") private LocalDateTime submittedAt; - private Boolean submitted; + private Boolean submitted = false; // 일정 선택 정보 @OneToMany(mappedBy = "participant", cascade = CascadeType.ALL, orphanRemoval = true) @@ -43,8 +53,9 @@ public class Participant { private List priorities = new ArrayList<>(); // 편의 생성자 - public Participant(Vote vote, String displayName) { + public Participant(Vote vote, String displayName, String botUserKey) { this.vote = vote; this.displayName = displayName; + this.botUserKey = botUserKey; } } \ No newline at end of file diff --git a/src/main/java/com/workingdead/meet/entity/Vote.java b/src/main/java/com/workingdead/meet/entity/Vote.java index 9f8e49d..a861977 100644 --- a/src/main/java/com/workingdead/meet/entity/Vote.java +++ b/src/main/java/com/workingdead/meet/entity/Vote.java @@ -20,6 +20,13 @@ public class Vote { @Column(unique = true, nullable = false, updatable = false) private String code; // 공유용 짧은 코드 (링크) + @Column(nullable = false) + private String botGroupKey; // 카카오톡 채팅방 ID + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private VoteStatus status = VoteStatus.ACTIVE; + private LocalDate startDate; // 선택 범위 시작 private LocalDate endDate; // 선택 범위 끝 @@ -37,4 +44,14 @@ public Vote(String name, String code) { // getters/setters public void setDateRange(LocalDate start, LocalDate end) { this.startDate = start; this.endDate = end; } + + + public enum VoteStatus { + ACTIVE, + CLOSED + } + + public void close() { + this.status = VoteStatus.CLOSED; + } } diff --git a/src/main/java/com/workingdead/meet/entity/VoteNotification.java b/src/main/java/com/workingdead/meet/entity/VoteNotification.java new file mode 100644 index 0000000..4c8c93b --- /dev/null +++ b/src/main/java/com/workingdead/meet/entity/VoteNotification.java @@ -0,0 +1,37 @@ +package com.workingdead.meet.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import lombok.Getter; + +@Getter +@Entity +@Table( + name = "vote_notification", + uniqueConstraints = @UniqueConstraint(name = "uk_vote_notification_vote_type", columnNames = {"vote_id", "type"}) +) +public class VoteNotification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "vote_id", nullable = false) + private Long voteId; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) + private NotificationType type; + + @Column(name = "sent_at", nullable = false) + private Instant sentAt; + + protected VoteNotification() {} + + public VoteNotification(Long voteId, NotificationType type) { + this.voteId = voteId; + this.type = type; + this.sentAt = Instant.now(); + } + +} diff --git a/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java b/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java index cd8650b..8c3e2da 100644 --- a/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java +++ b/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java @@ -1,12 +1,19 @@ package com.workingdead.meet.repository; import com.workingdead.meet.entity.Participant; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.ArrayList; import java.util.stream.Collectors; +import org.springframework.data.jpa.repository.Query; public interface ParticipantRepository extends JpaRepository { List findByVoteId(Long voteId); + + @Query("select p.botUserKey from Participant p where p.vote.id = :voteId and p.botUserKey in :keys") + List findBotUserKeysByVoteIdAndBotUserKeyIn(Long voteId, List keys); + + Optional findByVoteIdAndBotUserKey(Long voteId, String botUserKey); } diff --git a/src/main/java/com/workingdead/meet/repository/VoteNotificationRepository.java b/src/main/java/com/workingdead/meet/repository/VoteNotificationRepository.java new file mode 100644 index 0000000..707d864 --- /dev/null +++ b/src/main/java/com/workingdead/meet/repository/VoteNotificationRepository.java @@ -0,0 +1,9 @@ +package com.workingdead.meet.repository; + +import com.workingdead.meet.entity.NotificationType; +import com.workingdead.meet.entity.VoteNotification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface VoteNotificationRepository extends JpaRepository { + boolean existsByVoteIdAndType(Long voteId, NotificationType type); +} diff --git a/src/main/java/com/workingdead/meet/repository/VoteRepository.java b/src/main/java/com/workingdead/meet/repository/VoteRepository.java index 0e62d8f..c806000 100644 --- a/src/main/java/com/workingdead/meet/repository/VoteRepository.java +++ b/src/main/java/com/workingdead/meet/repository/VoteRepository.java @@ -1,10 +1,15 @@ package com.workingdead.meet.repository; import com.workingdead.meet.entity.Vote; +import com.workingdead.meet.entity.Vote.VoteStatus; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface VoteRepository extends JpaRepository { Optional findByCode(String code); + Optional findTopByBotGroupKeyAndStatusOrderByCreatedAtDesc(String botGroupKey, Vote.VoteStatus status); + + List findAllByStatus(VoteStatus status); } diff --git a/src/main/java/com/workingdead/meet/service/ParticipantService.java b/src/main/java/com/workingdead/meet/service/ParticipantService.java index 1eb06cc..c4dcd31 100644 --- a/src/main/java/com/workingdead/meet/service/ParticipantService.java +++ b/src/main/java/com/workingdead/meet/service/ParticipantService.java @@ -4,13 +4,12 @@ import com.workingdead.meet.entity.*; import com.workingdead.meet.repository.*; import java.util.Objects; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.security.SecureRandom; import java.util.NoSuchElementException; import java.util.List; -import java.util.ArrayList; import java.util.stream.Collectors; import org.springframework.web.server.ResponseStatusException; @@ -24,8 +23,6 @@ public class ParticipantService { private final VoteRepository voteRepo; private final ParticipantSelectionRepository selectionRepo; // 추가! private final PriorityPreferenceRepository priorityRepo; // 추가! - private static final String CODE_ALPHABET = "abcdefghijkmnopqrstuvwxyz23456789"; - private final SecureRandom rnd = new SecureRandom(); // 생성자 수정 public ParticipantService( @@ -39,14 +36,98 @@ public ParticipantService( this.priorityRepo = priorityRepo; // 추가! } - public ParticipantDtos.ParticipantRes add(Long voteId, String displayName) { - Vote v = voteRepo.findById(voteId) - .orElseThrow(() -> new NoSuchElementException("vote not found")); - Participant p = new Participant(v, displayName); - participantRepo.save(p); - return new ParticipantDtos.ParticipantRes(p.getId(), p.getDisplayName(), false); + /** + * (PRD) 참가자 upsert + * - 식별은 (voteId, botUserKey) + * - displayName은 웹에서 사용자가 입력하므로 처음에는 비어 있을 수 있음 + */ + public ParticipantDtos.ParticipantRes add(Long voteId, String botUserKey, String displayName) { + Participant participant = getOrCreateByVoteAndBotUserKey(voteId, botUserKey); + + // displayName은 웹에서 입력/수정될 수 있으므로, 값이 들어오면 항상 최신으로 반영합니다. + if (displayName != null && !displayName.isBlank()) { + String trimmed = displayName.trim(); + if (!Objects.equals(trimmed, participant.getDisplayName())) { + participant.setDisplayName(trimmed); + } + } + + Participant saved = participantRepo.save(participant); + return new ParticipantDtos.ParticipantRes( + saved.getId(), + saved.getDisplayName(), + Boolean.TRUE.equals(saved.getSubmitted()) + ); + } + + /** + * (voteId, botUserKey)로 Participant 확보 + * - 없으면 생성하며, displayName은 이후 웹에서 업데이트될 수 있음 + */ + private Participant getOrCreateByVoteAndBotUserKey(Long voteId, String botUserKey) { + + if (voteId == null) { + throw new IllegalArgumentException("voteId is required"); + } + + if (botUserKey == null || botUserKey.isBlank()) { + throw new IllegalArgumentException("botUserKey is required"); + } + + Optional existing = participantRepo.findByVoteIdAndBotUserKey(voteId, botUserKey); + if (existing.isPresent()) { + return existing.get(); + } + + Vote vote = voteRepo.findById(voteId) + .orElseThrow(() -> new NoSuchElementException("vote not found")); + + return new Participant(vote, null, botUserKey); } + /** + * (PRD) 투표 생성 직후, 채팅방 참여자(botUserKey) 목록으로 Participant를 미리 생성합니다. + * - JPA만 사용합니다. + * - 중복 생성 방지: 기존 (voteId, botUserKey) 존재 여부를 먼저 조회한 뒤, 없는 것만 saveAll + * - displayName은 이후 웹에서 입력되므로 null로 둡니다. + */ + public int precreateParticipants(Long voteId, List botUserKeys) { + if (voteId == null) { + throw new IllegalArgumentException("voteId is required"); + } + if (botUserKeys == null || botUserKeys.isEmpty()) { + return 0; + } + // 공백/중복 제거 + List keys = botUserKeys.stream() + .filter(k -> k != null && !k.isBlank()) + .map(String::trim) + .distinct() + .toList(); + + if (keys.isEmpty()) return 0; + + // vote 존재 확인 + Vote vote = voteRepo.findById(voteId) + .orElseThrow(() -> new NoSuchElementException("vote not found")); + + // 이미 존재하는 botUserKey 조회 + List existingKeys = participantRepo.findBotUserKeysByVoteIdAndBotUserKeyIn(voteId, keys); + + List toSave = keys.stream() + .filter(k -> existingKeys == null || !existingKeys.contains(k)) + .map(k -> new Participant(vote, null, k)) + .toList(); + + if (toSave.isEmpty()) return 0; + + participantRepo.saveAll(toSave); + return toSave.size(); + } + + + + public ParticipantDtos.ParticipantRes updateParticipant(Long participantId, ParticipantDtos.UpdateParticipantReq request) { Participant participant = participantRepo.findById(participantId) .orElseThrow(() -> new NoSuchElementException("Participant not found")); @@ -61,7 +142,7 @@ public ParticipantDtos.ParticipantRes updateParticipant(Long participantId, Part return new ParticipantDtos.ParticipantRes( saved.getId(), saved.getDisplayName(), - false + Boolean.TRUE.equals(saved.getSubmitted()) ); } @@ -70,6 +151,7 @@ public List getParticipantStatusByVoteId(L .map(p -> new ParticipantDtos.ParticipantStatusRes( p.getId(), p.getDisplayName(), + p.getBotUserKey(), Boolean.TRUE.equals(p.getSubmitted()) )) .toList(); @@ -104,7 +186,7 @@ public List getParticipantsForVote(Long voteId) .map(p -> new ParticipantDtos.ParticipantRes( p.getId(), p.getDisplayName(), - false + Boolean.TRUE.equals(p.getSubmitted()) )) .collect(Collectors.toList()); } @@ -201,12 +283,6 @@ public ParticipantDtos.ParticipantScheduleRes submitSchedule( saved.getSubmitted() ); } - - private String genCode(int len) { - StringBuilder sb = new StringBuilder(len); - for (int i=0;i botUserKeys) { + if (botGroupKey == null || botGroupKey.isBlank()) { + throw new IllegalArgumentException("botGroupKey must not be blank"); + } + + // 0) 기존 ACTIVE 투표가 있으면 종료 (status 활용) + voteRepo.findTopByBotGroupKeyAndStatusOrderByCreatedAtDesc(botGroupKey, VoteStatus.ACTIVE) + .ifPresent(Vote::close); + + // 1) Vote 생성 String code = genCode(8); Vote v = new Vote(req.name(), code); - // 2. 날짜 범위 설정 (있으면) + // botGroupKey 세팅 (Vote.botGroupKey NOT NULL) + v.setBotGroupKey(botGroupKey); + + // 2) 날짜 범위 설정 (있으면) if (req.startDate() != null && req.endDate() != null) { if (req.endDate().isBefore(req.startDate())) { throw new IllegalArgumentException("endDate must be >= startDate"); } v.setDateRange(req.startDate(), req.endDate()); - - // 3. 참여자 추가 (있으면) - if (req.participantNames() != null && !req.participantNames().isEmpty()) { - for (String name : req.participantNames()) { - if (name != null && !name.isBlank()) { - Participant p = new Participant(v, name.trim()); - v.getParticipants().add(p); - } - } - } } voteRepo.save(v); @@ -82,11 +108,29 @@ public VoteDtos.VoteDetail update(Long id, VoteDtos.UpdateVoteReq req) { return toDetail(v); } + @Transactional + public void closeVote(Long voteId) { + Vote vote = voteRepo.findById(voteId) + .orElseThrow(() -> new NoSuchElementException("vote not found")); + vote.close(); // status = CLOSED + } + public void delete(Long id) { voteRepo.deleteById(id); } + /** + * 채팅방(botGroupKey) 기준으로 "현재 활성" 투표를 찾아야 할 때 사용합니다. + */ + public Vote findActiveVoteByBotGroupKey(String botGroupKey) { + return voteRepo + .findTopByBotGroupKeyAndStatusOrderByCreatedAtDesc( + botGroupKey, VoteStatus.ACTIVE + ) + .orElseThrow(() -> new NoSuchElementException("active vote not found")); + } + private String genCode(int len) { StringBuilder sb = new StringBuilder(len); @@ -99,7 +143,7 @@ private String genCode(int len) { private VoteDtos.VoteSummary toSummary(Vote v) { String admin = baseUrl + "/admin/votes/" + v.getId(); - String share = baseUrl + "/v/" + v.getCode(); + String share = shareUrl(v); return new VoteDtos.VoteSummary(v.getId(), v.getName(), v.getCode(), admin, share, v.getStartDate(), v.getEndDate()); } @@ -111,4 +155,21 @@ private VoteDtos.VoteDetail toDetail(Vote v) { return new VoteDtos.VoteDetail(v.getId(), v.getName(), v.getCode(), v.getStartDate(), v.getEndDate(), participants); } + + private String shareUrl(Vote v) { + return baseUrl + "/v/" + v.getCode(); + } + + /** + * 최후통첩/독촉용 개인화 투표 링크 + * - botUserKey를 쿼리로 포함하여 웹 진입 시 참여자 식별에 사용 + */ + public String shareUrlForUser(Vote v, String botUserKey) { + if (botUserKey == null || botUserKey.isBlank()) { + throw new IllegalArgumentException("botUserKey must not be blank"); + } + return baseUrl + "/v/" + v.getCode() + "?botUserKey=" + botUserKey; + } + + } diff --git a/src/main/java/com/workingdead/meet/service/VoteStatsService.java b/src/main/java/com/workingdead/meet/service/VoteStatsService.java new file mode 100644 index 0000000..86a22b0 --- /dev/null +++ b/src/main/java/com/workingdead/meet/service/VoteStatsService.java @@ -0,0 +1,65 @@ +package com.workingdead.meet.service; + +import com.workingdead.meet.dto.ParticipantDtos.ParticipantStatusRes; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 투표 통계(집계/독촉 판단용) 계산 서비스 + * + * - Participant 프리생성(모수 확정) 이후의 PRD 흐름을 위해 + * total/submitted/nonVoted/majorityReached 등을 계산합니다. + */ +@Service +@RequiredArgsConstructor +public class VoteStatsService { + + private final ParticipantService participantService; + + /** + * 계산 결과 + */ + public record VoteStats( + long total, + long submitted, + long nonVoted, + boolean majorityReached + ) { + public long majorityThreshold() { + // 과반: floor(total/2) + 1 + return (total / 2) + 1; + } + + public boolean hasParticipants() { + return total > 0; + } + + public boolean allVoted() { + return total > 0 && nonVoted == 0; + } + + public boolean hasNonVoters() { + return nonVoted > 0; + } + } + + /** + * voteId 기준 통계 계산 + */ + public VoteStats getStats(Long voteId) { + List statuses = participantService.getParticipantStatusByVoteId(voteId); + + long total = statuses.size(); + long submitted = statuses.stream() + .filter(s -> Boolean.TRUE.equals(s.submitted())) + .count(); + + long nonVoted = total - submitted; + long majority = (total / 2) + 1; + boolean majorityReached = total > 0 && submitted >= majority; + + return new VoteStats(total, submitted, nonVoted, majorityReached); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ecc569f..e09407b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -69,6 +69,7 @@ discord: kakao: rest-api-key: ${KAKAO_REST_API_KEY} - admin-key: ${KAKAO_ADMIN_KEY:} + event-api-key: ${KAKAO_EVENT_API_KEY:} + bot-base-url: ${KAKAO_BOT_BASE_URL:} channel-id: ${KAKAO_CHANNEL_ID:} bot-id: ${KAKAO_BOT_ID:} \ No newline at end of file