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/controller/KakaoSkillController.java b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java index 419c264..8630dd9 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java +++ b/src/main/java/com/workingdead/chatbot/kakao/controller/KakaoSkillController.java @@ -16,7 +16,6 @@ * 카카오 i 오픈빌더 스킬 서버 컨트롤러 * * 카카오톡 챗봇에서 발화를 받아 처리하고 응답을 반환합니다. - * - 개인챗: userKey 기반 세션 * - 그룹챗: botGroupKey 기반 세션 */ @Tag(name = "Kakao Chatbot", description = "카카오 챗봇 스킬 API") @@ -29,16 +28,6 @@ public class KakaoSkillController { private final KakaoWendyService kakaoWendyService; private final ObjectMapper objectMapper; - /** - * 세션 키 결정 (그룹챗이면 botGroupKey, 개인챗이면 userKey) - */ - private String getSessionKey(KakaoRequest request) { - String botGroupKey = request.getBotGroupKey(); - if (botGroupKey != null && !botGroupKey.isBlank()) { - return botGroupKey; - } - return request.getUserKey(); - } /** * 메인 스킬 엔드포인트 (폴백 블록) @@ -47,7 +36,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 +46,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 +60,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 +70,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 +97,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 +137,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 +155,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 +172,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 +188,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 +209,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 +222,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 +237,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,7 +252,6 @@ 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()); } @@ -266,56 +263,4 @@ public ResponseEntity handleHelp(@RequestBody KakaoRequest reques public ResponseEntity health() { return ResponseEntity.ok("OK"); } - - - /** - * 참석자 botUserKey 목록 추출 - * - 오픈빌더의 "발화에서 멘션된 유저 식별" 결과를 params로 전달받는 것을 1순위로 사용 - * - 지원 키: botUserKeys / participants / mentionedUserKeys - * - 값 형태: "k1,k2,k3" 또는 "k1 k2 k3" 등(구분자는 콤마/공백/개행 모두 허용) - * - * @param request 요청 DTO - * @param fallbackUtterance params가 없을 때 마지막 fallback(테스트/디버그용) - * @return 콤마(,)로 join된 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; - } - - return normalizeKeys(raw); - } - - private String firstNonBlank(String... values) { - if (values == null) return null; - for (String v : values) { - if (v != null && !v.isBlank()) return v; - } - return null; - } - - /** - * 다양한 구분자(콤마/공백/개행)를 콤마 구분 문자열로 정규화 - */ - 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); - } } \ 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/service/KakaoNotifier.java b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java index e6075fb..29cfe5d 100644 --- a/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java +++ b/src/main/java/com/workingdead/chatbot/kakao/service/KakaoNotifier.java @@ -7,8 +7,6 @@ import com.workingdead.meet.dto.VoteResultDtos.VoteResultRes; 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.*; @@ -16,7 +14,6 @@ import org.springframework.web.client.RestTemplate; import java.time.DayOfWeek; -import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -69,16 +66,11 @@ public void sendEventToGroup(String botGroupKey, String eventName) { /** * 투표 현황 공유 (이벤트 메시지) */ - public void shareVoteStatus(String sessionKey) { + public void shareVoteStatus(String botGroupKey) { try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); + Long voteId = kakaoWendyService.getVoteIdByBotGroupKey(botGroupKey); 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(); @@ -89,91 +81,68 @@ public void shareVoteStatus(String sessionKey) { return; } - - boolean allSubmitted = totalCount > 0 && submittedCount == totalCount; - boolean majoritySubmitted = totalCount > 0 && submittedCount * 2 >= totalCount; // 과반(>=) - - // 3분 전 & 과반 미달이면 아무것도 안 보냄 - if (elapsedSeconds < 180 && !majoritySubmitted && !allSubmitted) { - return; - } - - // 3분 경과했는데 0명 투표면 안내 메시지 - if (elapsedSeconds >= 180 && submittedCount == 0) { - sendToGroupIfPossible(voteId, "status_nobody_voted"); - return; + if (submittedCount == 0) { + sendEventToGroup(botGroupKey, "status_nobody_voted"); + } else { + sendEventToGroup(botGroupKey, "status_vote_result"); } - // 결과 공유 - sendToGroupIfPossible(voteId, "status_vote_result"); - - // 전원 투표 완료면 완료 단계로 이동 트리거 + boolean allSubmitted = totalCount > 0 && submittedCount == totalCount; if (allSubmitted) { - sendToGroupIfPossible(voteId, "status_all_done"); + sendEventToGroup(botGroupKey, "status_all_done"); } - } catch (Exception e) { log.error("[Kakao Notifier] Failed to share vote status: {}", e.getMessage()); } } - public void sendFinalNotice(String sessionKey) { + public void sendFinalNotice(String botGroupKey) { try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); + Long voteId = kakaoWendyService.getVoteIdByBotGroupKey(botGroupKey); if (voteId == null) return; List nonVoters = getNonVoterNames(voteId); if (nonVoters.isEmpty()) return; // 미투표자 없으면 전송 X - sendToGroupIfPossible(voteId, "final_notice_24h"); + sendEventToGroup(botGroupKey, "final_notice_24h"); } catch (Exception e) { log.error("[Kakao Notifier] sendFinalNotice failed: {}", e.getMessage(), e); } } - public void finalizeIfNoResponse(String sessionKey) { + public void finalizeIfNoResponse(String botGroupKey) { try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); + Long voteId = kakaoWendyService.getVoteIdByBotGroupKey(botGroupKey); if (voteId == null) return; List nonVoters = getNonVoterNames(voteId); if (nonVoters.isEmpty()) return; // 이미 다 했으면 확정 처리 X - sendToGroupIfPossible(voteId, "finalize_after_60m"); + sendEventToGroup(botGroupKey, "finalize_after_60m"); } 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); - } - } - /** * 미투표자 리마인드 (이벤트 메시지) */ - public void remindNonVoters(String sessionKey, RemindTiming timing) { + public void remindNonVoters(String botGroupKey, RemindTiming timing) { try { - Long voteId = kakaoWendyService.getVoteIdBySessionKey(sessionKey); + Long voteId = kakaoWendyService.getVoteIdByBotGroupKey(botGroupKey); if (voteId == null) { - log.warn("[Kakao Notifier] No vote found for sessionKey: {}", sessionKey); + log.warn("[Kakao Notifier] No vote found for botGroupKey: {}", botGroupKey); return; } List nonVoters = getNonVoterNames(voteId); if (nonVoters.isEmpty()) { - log.info("[Kakao Notifier] No non-voters. Skip reminder: sessionKey={}, timing={}", sessionKey, timing); + log.info("[Kakao Notifier] No non-voters. Skip reminder: botGroupKey={}, timing={}", botGroupKey, timing); return; } - String eventName = switch (timing) { case MIN_30 -> "remind_30min"; case HOUR_2 -> "remind_2hour"; @@ -181,14 +150,7 @@ public void remindNonVoters(String sessionKey, RemindTiming timing) { 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); - } + sendEventToGroup(botGroupKey, eventName); } catch (Exception e) { log.error("[Kakao Notifier] Failed to send reminder: {}", e.getMessage()); @@ -247,56 +209,6 @@ public List getNonVoterNames(Long voteId) { .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 -> "월"; 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..2cce9fd 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,10 @@ package com.workingdead.chatbot.kakao.service; 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; @@ -15,17 +13,12 @@ import java.time.DayOfWeek; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** - * 카카오 챗봇용 웬디 서비스 - * Discord와 독립적으로 세션 관리 - * - 개인챗: userKey 기반 - * - 그룹챗: botGroupKey 기반 + * - 그룹챗 전용: botGroupKey 기반 */ @Service @RequiredArgsConstructor @@ -33,69 +26,37 @@ 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<>(); - 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 +65,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 +96,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 +126,35 @@ 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); + VoteSummary summary = voteService.create(req, botGroupKey); Long voteId = summary.id(); - String shareUrl = summary.shareUrl(); - 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("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 +163,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 +196,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 +214,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 +227,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 +256,21 @@ public KakaoResponse getVoteResult(String userKey) { } /** - * 재투표 (동일 참석자로 새 투표 생성) + * 재투표 (세션 상태를 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 +290,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 +333,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/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..56e0473 100644 --- a/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java +++ b/src/main/java/com/workingdead/meet/dto/ParticipantDtos.java @@ -7,7 +7,7 @@ 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 // 로그인 상태 ) {} 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/repository/ParticipantRepository.java b/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java index cd8650b..1677d1e 100644 --- a/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java +++ b/src/main/java/com/workingdead/meet/repository/ParticipantRepository.java @@ -1,6 +1,7 @@ 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; @@ -9,4 +10,6 @@ public interface ParticipantRepository extends JpaRepository { List findByVoteId(Long voteId); + + Optional findByVoteIdAndBotUserKey(Long voteId, String botUserKey); } diff --git a/src/main/java/com/workingdead/meet/repository/VoteRepository.java b/src/main/java/com/workingdead/meet/repository/VoteRepository.java index 0e62d8f..bfa0f13 100644 --- a/src/main/java/com/workingdead/meet/repository/VoteRepository.java +++ b/src/main/java/com/workingdead/meet/repository/VoteRepository.java @@ -7,4 +7,5 @@ public interface VoteRepository extends JpaRepository { Optional findByCode(String code); + Optional findTopByBotGroupKeyAndStatusOrderByCreatedAtDesc(String botGroupKey, Vote.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..df32706 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,56 @@ 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); } + public ParticipantDtos.ParticipantRes updateParticipant(Long participantId, ParticipantDtos.UpdateParticipantReq request) { Participant participant = participantRepo.findById(participantId) .orElseThrow(() -> new NoSuchElementException("Participant not found")); @@ -61,7 +100,7 @@ public ParticipantDtos.ParticipantRes updateParticipant(Long participantId, Part return new ParticipantDtos.ParticipantRes( saved.getId(), saved.getDisplayName(), - false + Boolean.TRUE.equals(saved.getSubmitted()) ); } @@ -104,7 +143,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 +240,6 @@ public ParticipantDtos.ParticipantScheduleRes submitSchedule( saved.getSubmitted() ); } - - private String genCode(int len) { - StringBuilder sb = new StringBuilder(len); - for (int i=0;i= 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 +97,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 +132,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 +144,8 @@ 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(); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ecc569f..82f0f90 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -69,6 +69,6 @@ discord: kakao: rest-api-key: ${KAKAO_REST_API_KEY} - admin-key: ${KAKAO_ADMIN_KEY:} + event-api-key: ${KAKAO_EVENT_API_KEY:} channel-id: ${KAKAO_CHANNEL_ID:} bot-id: ${KAKAO_BOT_ID:} \ No newline at end of file