diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java index b8a7d453..fddc60b7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/enums/MessageType.java @@ -22,7 +22,16 @@ public enum MessageType { // 방 관련 메시지 타입 ROOM_STATUS_CHANGE("room_status_change", "방 상태 변경"), - HOST_CHANGE("host_change", "방장 변경"); + HOST_CHANGE("host_change", "방장 변경"), + + // 투표 관련 메시지 타입 + POLL_CREATE("poll_create", "투표 생성"), + POLL_VOTE("poll_vote", "투표 참여"), + POLL_END("poll_end", "투표 종료"), + + // 유틸리티 메시지 타입 + CLEAR_CHAT("clear_chat", "채팅 삭제"), + LEAVE_ROOM("leave_room", "채팅방 나가기"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java new file mode 100644 index 00000000..0c8eced9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/model/Poll.java @@ -0,0 +1,80 @@ +package com.mzc.secondproject.serverless.domain.chatting.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * 채팅방 투표 모델 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@DynamoDbBean +public class Poll { + + private String pk; // ROOM#{roomId} + private String sk; // POLL#{pollId} + + private String pollId; + private String roomId; + private String question; + private List options; + private Map votes; // optionIndex -> count + private Map userVotes; // userId -> optionIndex + private String createdBy; + private String createdAt; + private Boolean isActive; + private Long ttl; + + @DynamoDbPartitionKey + @DynamoDbAttribute("PK") + public String getPk() { + return pk; + } + + @DynamoDbSortKey + @DynamoDbAttribute("SK") + public String getSk() { + return sk; + } + + /** + * 투표 추가 + */ + public boolean addVote(String userId, int optionIndex) { + if (optionIndex < 0 || optionIndex >= options.size()) { + return false; + } + + // 이미 투표했는지 확인 + if (userVotes.containsKey(userId)) { + return false; + } + + userVotes.put(userId, optionIndex); + votes.merge(String.valueOf(optionIndex), 1, Integer::sum); + return true; + } + + /** + * 사용자가 이미 투표했는지 확인 + */ + public boolean hasVoted(String userId) { + return userVotes != null && userVotes.containsKey(userId); + } + + /** + * 총 투표 수 + */ + public int getTotalVotes() { + if (votes == null) return 0; + return votes.values().stream().mapToInt(Integer::intValue).sum(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java new file mode 100644 index 00000000..f49a6908 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/repository/PollRepository.java @@ -0,0 +1,70 @@ +package com.mzc.secondproject.serverless.domain.chatting.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +import java.util.Optional; + +/** + * Poll Repository + */ +public class PollRepository { + + private static final Logger logger = LoggerFactory.getLogger(PollRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); + + private final DynamoDbTable table; + + public PollRepository() { + this.table = AwsClients.dynamoDbEnhanced().table(TABLE_NAME, TableSchema.fromBean(Poll.class)); + } + + public PollRepository(DynamoDbTable table) { + this.table = table; + } + + public void save(Poll poll) { + table.putItem(poll); + logger.debug("Saved poll: {}", poll.getPollId()); + } + + public Optional findById(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + Poll poll = table.getItem(key); + return Optional.ofNullable(poll); + } + + /** + * 방의 활성 투표 조회 + */ + public Optional findActiveByRoomId(String roomId) { + return table.query(QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#") + .build())) + .items() + .stream() + .filter(poll -> Boolean.TRUE.equals(poll.getIsActive())) + .findFirst(); + } + + public void delete(String roomId, String pollId) { + Key key = Key.builder() + .partitionValue("ROOM#" + roomId) + .sortValue("POLL#" + pollId) + .build(); + table.deleteItem(key); + logger.debug("Deleted poll: {}", pollId); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java index 71e0ddf2..89d6e098 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/CommandService.java @@ -3,44 +3,49 @@ import com.mzc.secondproject.serverless.domain.chatting.dto.response.CommandResult; import com.mzc.secondproject.serverless.domain.chatting.enums.MessageType; import com.mzc.secondproject.serverless.domain.chatting.model.Connection; -import com.mzc.secondproject.serverless.domain.chatting.model.GameSession; +import com.mzc.secondproject.serverless.domain.chatting.model.Poll; import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; -import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.chatting.repository.PollRepository; +import com.mzc.secondproject.serverless.domain.user.model.User; +import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Optional; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; /** * 슬래시 명령어 처리 서비스 */ public class CommandService { - + private static final Logger logger = LoggerFactory.getLogger(CommandService.class); - + private final ConnectionRepository connectionRepository; - private final GameSessionRepository gameSessionRepository; - private final GameService gameService; - + private final PollRepository pollRepository; + private final UserRepository userRepository; + private final Random random; + /** * 기본 생성자 (Lambda에서 사용) */ public CommandService() { - this(new ConnectionRepository(), new GameSessionRepository(), new GameService()); + this(new ConnectionRepository(), new PollRepository(), new UserRepository()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public CommandService(ConnectionRepository connectionRepository, - GameSessionRepository gameSessionRepository, - GameService gameService) { + PollRepository pollRepository, + UserRepository userRepository) { this.connectionRepository = connectionRepository; - this.gameSessionRepository = gameSessionRepository; - this.gameService = gameService; + this.pollRepository = pollRepository; + this.userRepository = userRepository; + this.random = new Random(); } - + /** * 명령어 처리 * @@ -53,119 +58,426 @@ public Optional processCommand(String content, String roomId, Str if (content == null || !content.startsWith("/")) { return Optional.empty(); } - + String[] parts = content.trim().split("\\s+", 2); String command = parts[0].toLowerCase(); - + String args = parts.length > 1 ? parts[1] : ""; + logger.info("Processing command: {} from user: {} in room: {}", command, userId, roomId); - + return switch (command) { - case "/member", "/members" -> Optional.of(handleMemberCommand(roomId)); - case "/start" -> Optional.of(handleStartCommand(roomId, userId)); - case "/stop" -> Optional.of(handleStopCommand(roomId, userId)); - case "/score" -> Optional.of(handleScoreCommand(roomId)); - case "/skip" -> Optional.of(handleSkipCommand(roomId, userId)); - case "/hint" -> Optional.of(handleHintCommand(roomId, userId)); + // 기본 명령어 case "/help" -> Optional.of(handleHelpCommand()); + case "/member", "/members" -> Optional.of(handleMembersCommand(roomId)); + case "/leave" -> Optional.of(handleLeaveCommand(roomId, userId)); + case "/clear" -> Optional.of(handleClearCommand(roomId, userId)); + + // 재미 명령어 + case "/dice" -> Optional.of(handleDiceCommand(roomId, userId)); + case "/coin" -> Optional.of(handleCoinCommand(roomId, userId)); + case "/random" -> Optional.of(handleRandomCommand(roomId, userId, args)); + + // 투표 명령어 + case "/poll" -> Optional.of(handlePollCommand(roomId, userId, args)); + case "/vote" -> Optional.of(handleVoteCommand(roomId, userId, args)); + case "/endpoll" -> Optional.of(handleEndPollCommand(roomId, userId)); + default -> Optional.empty(); }; } - + + // ========== 기본 명령어 ========== + + /** + * /help - 도움말 + */ + private CommandResult handleHelpCommand() { + String helpMessage = """ + 📖 사용 가능한 명령어: + + [기본] + /members - 현재 접속자 목록 + /leave - 채팅방 나가기 + /clear - 내 채팅 내역 삭제 + + [재미] + /dice - 주사위 굴리기 (1-6) + /coin - 동전 던지기 + /random [옵션1] [옵션2] ... - 랜덤 선택 + + [투표] + /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 + /vote [번호] - 투표하기 + /endpoll - 투표 종료 (생성자만) + """; + return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + } + /** - * /member - 현재 접속자 수 조회 + * /members - 접속자 목록 */ - private CommandResult handleMemberCommand(String roomId) { + private CommandResult handleMembersCommand(String roomId) { List connections = connectionRepository.findByRoomId(roomId); - + if (connections.isEmpty()) { return CommandResult.success(MessageType.SYSTEM_COMMAND, "현재 접속자가 없습니다."); } - - String message = String.format("현재 접속자: %d명", connections.size()); - return CommandResult.success(MessageType.SYSTEM_COMMAND, message, connections.size()); + + // 닉네임 조회 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("👥 현재 접속자: %d명\n", connections.size())); + + for (Connection conn : connections) { + String nickname = userRepository.findByCognitoSub(conn.getUserId()) + .map(User::getNickname) + .orElse(conn.getUserId()); + sb.append(String.format(" • %s\n", nickname)); + } + + Map data = new HashMap<>(); + data.put("count", connections.size()); + data.put("members", connections.stream() + .map(c -> { + Map member = new HashMap<>(); + member.put("userId", c.getUserId()); + member.put("nickname", userRepository.findByCognitoSub(c.getUserId()) + .map(User::getNickname).orElse(c.getUserId())); + return member; + }) + .collect(Collectors.toList())); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, sb.toString(), data); } - + /** - * /start - 게임 시작 + * /leave - 채팅방 나가기 */ - private CommandResult handleStartCommand(String roomId, String userId) { - GameService.GameStartResult result = gameService.startGame(roomId, userId); - - if (!result.success()) { - return CommandResult.error(result.error()); - } - - String message = String.format(""" - 🎮 게임 시작! - 총 %d 라운드 - - 라운드 1 시작! - 출제자: %s - """, - result.session().getTotalRounds(), - result.session().getCurrentDrawerId()); - - return CommandResult.success(MessageType.GAME_START, message, result); + private CommandResult handleLeaveCommand(String roomId, String userId) { + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("action", "leave"); + + return CommandResult.success(MessageType.LEAVE_ROOM, + String.format("👋 %s님이 퇴장합니다.", nickname), data); } - + /** - * /stop - 게임 중단 + * /clear - 내 채팅 내역 삭제 */ - private CommandResult handleStopCommand(String roomId, String userId) { - return gameService.stopGame(roomId, userId); + private CommandResult handleClearCommand(String roomId, String userId) { + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("action", "clear"); + + return CommandResult.success(MessageType.CLEAR_CHAT, + "🗑️ 채팅 내역 삭제를 요청했습니다.", data); } - + + // ========== 재미 명령어 ========== + /** - * /score - 현재 점수 조회 + * /dice - 주사위 굴리기 */ - private CommandResult handleScoreCommand(String roomId) { - Optional optSession = gameSessionRepository.findActiveByRoomId(roomId); - if (optSession.isEmpty()) { - return CommandResult.error("진행 중인 게임이 없습니다."); - } - - GameSession session = optSession.get(); - - if (session.getScores() == null || session.getScores().isEmpty()) { - return CommandResult.success(MessageType.SCORE_UPDATE, "아직 점수가 없습니다."); - } - - StringBuilder sb = new StringBuilder("📊 현재 점수:\n"); - session.getScores().entrySet().stream() - .sorted((a, b) -> b.getValue().compareTo(a.getValue())) - .forEach(entry -> sb.append(String.format(" %s: %d점\n", entry.getKey(), entry.getValue()))); - - return CommandResult.success(MessageType.SCORE_UPDATE, sb.toString(), session.getScores()); + private CommandResult handleDiceCommand(String roomId, String userId) { + int result = random.nextInt(6) + 1; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String emoji = switch (result) { + case 1 -> "⚀"; + case 2 -> "⚁"; + case 3 -> "⚂"; + case 4 -> "⚃"; + case 5 -> "⚄"; + case 6 -> "⚅"; + default -> "🎲"; + }; + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", result); + data.put("type", "dice"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎲 %s님이 주사위를 굴렸습니다: %s %d", nickname, emoji, result), data); } - + /** - * /skip - 라운드 스킵 (출제자만) + * /coin - 동전 던지기 */ - private CommandResult handleSkipCommand(String roomId, String userId) { - return gameService.skipRound(roomId, userId); + private CommandResult handleCoinCommand(String roomId, String userId) { + boolean isHeads = random.nextBoolean(); + String result = isHeads ? "앞면 (Heads)" : "뒷면 (Tails)"; + String emoji = isHeads ? "🪙" : "💿"; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("result", isHeads ? "heads" : "tails"); + data.put("type", "coin"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("%s %s님이 동전을 던졌습니다: %s", emoji, nickname, result), data); } - + /** - * /hint - 힌트 제공 (출제자만) + * /random [옵션1] [옵션2] ... - 랜덤 선택 */ - private CommandResult handleHintCommand(String roomId, String userId) { - return gameService.provideHint(roomId, userId); + private CommandResult handleRandomCommand(String roomId, String userId, String args) { + if (args.isBlank()) { + return CommandResult.error("사용법: /random [옵션1] [옵션2] [옵션3] ..."); + } + + String[] options = args.split("\\s+"); + if (options.length < 2) { + return CommandResult.error("최소 2개 이상의 옵션이 필요합니다."); + } + + String selected = options[random.nextInt(options.length)]; + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + Map data = new HashMap<>(); + data.put("userId", userId); + data.put("nickname", nickname); + data.put("options", Arrays.asList(options)); + data.put("selected", selected); + data.put("type", "random"); + + return CommandResult.success(MessageType.SYSTEM_COMMAND, + String.format("🎯 %s님의 랜덤 선택: %s\n(후보: %s)", + nickname, selected, String.join(", ", options)), data); } - + + // ========== 투표 명령어 ========== + /** - * /help - 도움말 + * /poll [질문] | [옵션1] | [옵션2] | ... - 투표 생성 */ - private CommandResult handleHelpCommand() { - String helpMessage = """ - 📖 사용 가능한 명령어: - /member - 현재 접속자 수 - /start - 게임 시작 (2명 이상) - /stop - 게임 중단 - /score - 현재 점수 보기 - /skip - 라운드 스킵 (출제자) - /hint - 힌트 보기 (출제자) - /help - 도움말 - """; - return CommandResult.success(MessageType.SYSTEM_COMMAND, helpMessage); + private CommandResult handlePollCommand(String roomId, String userId, String args) { + // 이미 진행 중인 투표가 있는지 확인 + Optional activePoll = pollRepository.findActiveByRoomId(roomId); + if (activePoll.isPresent()) { + return CommandResult.error("이미 진행 중인 투표가 있습니다. /endpoll로 종료 후 새 투표를 만드세요."); + } + + if (args.isBlank()) { + return CommandResult.error("사용법: /poll [질문] | [옵션1] | [옵션2] | ..."); + } + + String[] parts = args.split("\\|"); + if (parts.length < 3) { + return CommandResult.error("질문과 최소 2개의 옵션이 필요합니다. (구분자: |)"); + } + + String question = parts[0].trim(); + List options = new ArrayList<>(); + for (int i = 1; i < parts.length; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + + if (options.size() < 2) { + return CommandResult.error("최소 2개의 옵션이 필요합니다."); + } + + if (options.size() > 10) { + return CommandResult.error("옵션은 최대 10개까지 가능합니다."); + } + + // 투표 생성 + String pollId = UUID.randomUUID().toString(); + String now = Instant.now().toString(); + long ttl = Instant.now().plusSeconds(24 * 60 * 60).getEpochSecond(); // 24시간 + + Map votes = new HashMap<>(); + for (int i = 0; i < options.size(); i++) { + votes.put(String.valueOf(i), 0); + } + + Poll poll = Poll.builder() + .pk("ROOM#" + roomId) + .sk("POLL#" + pollId) + .pollId(pollId) + .roomId(roomId) + .question(question) + .options(options) + .votes(votes) + .userVotes(new HashMap<>()) + .createdBy(userId) + .createdAt(now) + .isActive(true) + .ttl(ttl) + .build(); + + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("📊 %s님이 투표를 시작했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", question)); + for (int i = 0; i < options.size(); i++) { + sb.append(String.format(" %d. %s\n", i + 1, options.get(i))); + } + sb.append("\n💬 /vote [번호]로 투표하세요!"); + + Map data = new HashMap<>(); + data.put("pollId", pollId); + data.put("question", question); + data.put("options", options); + data.put("createdBy", userId); + data.put("creatorNickname", nickname); + + logger.info("Poll created: pollId={}, roomId={}, question={}", pollId, roomId, question); + + return CommandResult.success(MessageType.POLL_CREATE, sb.toString(), data); + } + + /** + * /vote [번호] - 투표하기 + */ + private CommandResult handleVoteCommand(String roomId, String userId, String args) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (poll.hasVoted(userId)) { + return CommandResult.error("이미 투표하셨습니다."); + } + + int optionIndex; + try { + optionIndex = Integer.parseInt(args.trim()) - 1; // 1-based to 0-based + } catch (NumberFormatException e) { + return CommandResult.error("사용법: /vote [번호] (예: /vote 1)"); + } + + if (optionIndex < 0 || optionIndex >= poll.getOptions().size()) { + return CommandResult.error(String.format("1~%d 사이의 번호를 입력하세요.", poll.getOptions().size())); + } + + // 투표 추가 + poll.addVote(userId, optionIndex); + pollRepository.save(poll); + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + String selectedOption = poll.getOptions().get(optionIndex); + + // 현재 투표 현황 생성 + StringBuilder sb = new StringBuilder(); + sb.append(String.format("✅ %s님이 '%s'에 투표했습니다!\n\n", nickname, selectedOption)); + sb.append(String.format("📊 현재 현황 (총 %d표):\n", poll.getTotalVotes())); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + sb.append(String.format(" %d. %s: %s %d표\n", + i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("voterId", userId); + data.put("voterNickname", nickname); + data.put("selectedOption", optionIndex); + data.put("selectedOptionText", selectedOption); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + + logger.info("Vote recorded: pollId={}, userId={}, option={}", poll.getPollId(), userId, optionIndex); + + return CommandResult.success(MessageType.POLL_VOTE, sb.toString(), data); + } + + /** + * /endpoll - 투표 종료 + */ + private CommandResult handleEndPollCommand(String roomId, String userId) { + Optional optPoll = pollRepository.findActiveByRoomId(roomId); + if (optPoll.isEmpty()) { + return CommandResult.error("진행 중인 투표가 없습니다."); + } + + Poll poll = optPoll.get(); + + if (!poll.getCreatedBy().equals(userId)) { + return CommandResult.error("투표 생성자만 종료할 수 있습니다."); + } + + poll.setIsActive(false); + pollRepository.save(poll); + + // 최종 결과 계산 + int maxVotes = 0; + List winners = new ArrayList<>(); + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + if (voteCount > maxVotes) { + maxVotes = voteCount; + winners.clear(); + winners.add(poll.getOptions().get(i)); + } else if (voteCount == maxVotes && voteCount > 0) { + winners.add(poll.getOptions().get(i)); + } + } + + String nickname = userRepository.findByCognitoSub(userId) + .map(User::getNickname) + .orElse(userId); + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("🏁 %s님이 투표를 종료했습니다!\n\n", nickname)); + sb.append(String.format("❓ %s\n\n", poll.getQuestion())); + sb.append(String.format("📊 최종 결과 (총 %d표):\n", poll.getTotalVotes())); + + for (int i = 0; i < poll.getOptions().size(); i++) { + int voteCount = poll.getVotes().getOrDefault(String.valueOf(i), 0); + String bar = "█".repeat(Math.min(voteCount, 10)); + String medal = (voteCount == maxVotes && maxVotes > 0) ? "🏆 " : " "; + sb.append(String.format("%s%d. %s: %s %d표\n", + medal, i + 1, poll.getOptions().get(i), bar, voteCount)); + } + + if (!winners.isEmpty()) { + sb.append(String.format("\n🎉 우승: %s", String.join(", ", winners))); + } else { + sb.append("\n투표가 없습니다."); + } + + Map data = new HashMap<>(); + data.put("pollId", poll.getPollId()); + data.put("question", poll.getQuestion()); + data.put("options", poll.getOptions()); + data.put("votes", poll.getVotes()); + data.put("totalVotes", poll.getTotalVotes()); + data.put("winners", winners); + + logger.info("Poll ended: pollId={}, totalVotes={}", poll.getPollId(), poll.getTotalVotes()); + + return CommandResult.success(MessageType.POLL_END, sb.toString(), data); } } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy new file mode 100644 index 00000000..cee47562 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/chatting/model/PollSpec.groovy @@ -0,0 +1,148 @@ +package com.mzc.secondproject.serverless.domain.chatting.model + +import spock.lang.Specification + +class PollSpec extends Specification { + + def "addVote: 정상적인 투표 추가"() { + given: + def poll = Poll.builder() + .pollId("poll-123") + .options(["옵션1", "옵션2", "옵션3"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 0) + + then: + result == true + poll.votes["0"] == 1 + poll.userVotes["user1"] == 0 + } + + def "addVote: 이미 투표한 사용자는 재투표 불가"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 1, "1": 0]) + .userVotes(["user1": 0]) + .build() + + when: + def result = poll.addVote("user1", 1) + + then: + result == false + poll.votes["0"] == 1 + poll.votes["1"] == 0 + } + + def "addVote: 유효하지 않은 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", 5) + + then: + result == false + } + + def "addVote: 음수 옵션 인덱스"() { + given: + def poll = Poll.builder() + .options(["옵션1", "옵션2"]) + .votes(["0": 0, "1": 0]) + .userVotes([:]) + .build() + + when: + def result = poll.addVote("user1", -1) + + then: + result == false + } + + def "hasVoted: 투표한 사용자 확인"() { + given: + def poll = Poll.builder() + .userVotes(["user1": 0]) + .build() + + expect: + poll.hasVoted("user1") == true + poll.hasVoted("user2") == false + } + + def "hasVoted: userVotes가 null인 경우"() { + given: + def poll = Poll.builder() + .userVotes(null) + .build() + + expect: + poll.hasVoted("user1") == false + } + + def "getTotalVotes: 총 투표 수 계산"() { + given: + def poll = Poll.builder() + .votes(["0": 3, "1": 2, "2": 5]) + .build() + + expect: + poll.getTotalVotes() == 10 + } + + def "getTotalVotes: 투표가 없는 경우"() { + given: + def poll = Poll.builder() + .votes(["0": 0, "1": 0]) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "getTotalVotes: votes가 null인 경우"() { + given: + def poll = Poll.builder() + .votes(null) + .build() + + expect: + poll.getTotalVotes() == 0 + } + + def "여러 사용자 투표 시나리오"() { + given: + def poll = Poll.builder() + .options(["A", "B", "C"]) + .votes(["0": 0, "1": 0, "2": 0]) + .userVotes([:]) + .build() + + when: + poll.addVote("user1", 0) + poll.addVote("user2", 0) + poll.addVote("user3", 1) + poll.addVote("user4", 2) + + then: + poll.votes["0"] == 2 + poll.votes["1"] == 1 + poll.votes["2"] == 1 + poll.getTotalVotes() == 4 + poll.hasVoted("user1") + poll.hasVoted("user2") + poll.hasVoted("user3") + poll.hasVoted("user4") + !poll.hasVoted("user5") + } +}