diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index 61cd792..957f1cc 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -8,17 +8,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; @Slf4j @Tag(name = "ChatBot API", description = "챗봇 관련 API") -@Controller +@RestController @RequiredArgsConstructor @RequestMapping("/api/chat") public class ChatBotController { @@ -26,21 +21,27 @@ public class ChatBotController { private final ChatBotService chatBotService; @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") - @PostMapping("/message") - public ResponseEntity> postNewMessage(@RequestBody ChatRequest chatRequest) { + @PostMapping(value = "/message", produces = "application/stream+json") + public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { + // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) Long memberId = AuthUtil.getAuthenticatedMemberId(); - log.debug("새로운 채팅 요청: memberId={}", memberId); - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, null)); + log.info("새로운 채팅 요청: memberId={}", memberId); + + // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) + return chatBotService.sendMessage(memberId, chatRequest, null); } @Operation(summary = "02. 기존 채팅", description = "기존 채팅방에 메시지를 보내고 챗봇과 대화를 이어감") - @PostMapping("{roomId}/message") - public ResponseEntity> postMessage( + @PostMapping(value = "{roomId}/message", produces = "application/stream+json") + public Flux postMessage( @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { + // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) Long memberId = AuthUtil.getAuthenticatedMemberId(); - log.debug("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, roomId)); + log.info("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); + + // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) + return chatBotService.sendMessage(memberId, chatRequest, roomId); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java index 4b52b93..558e472 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java @@ -1,11 +1,15 @@ package com.ai.lawyer.domain.poll.entity; -import com.ai.lawyer.domain.member.entity.Member; import jakarta.persistence.*; import lombok.*; @Entity -@Table(name = "poll_vote") +@Table(name = "poll_vote", + uniqueConstraints = @UniqueConstraint( + name = "uk_poll_vote_member_poll", + columnNames = {"member_id", "poll_id"} + ) +) @Data @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 4d46fd2..fe468f0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -111,47 +111,54 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } - // 기존 투표 내역 조회 - var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); - if (existingVoteOpt.isPresent()) { - PollVote existingVote = existingVoteOpt.get(); - if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); - } else { - pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .memberId(memberId) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표 항목을 변경하였습니다.") - .build(); + + try { + // 기존 투표 내역 조회 + var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); + if (existingVoteOpt.isPresent()) { + PollVote existingVote = existingVoteOpt.get(); + if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); + } else { + pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표 항목을 변경하였습니다.") + .build(); + } } + // 기존 투표 내역이 없으면 정상 투표 + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표가 완료되었습니다.") + .build(); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // 동시성 문제로 인한 중복 투표 시도 (unique constraint violation) + log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다."); } - // 기존 투표 내역이 없으면 정상 투표 - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .memberId(memberId) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표가 완료되었습니다.") - .build(); } @Override diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 8c7067a..12a1e05 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -57,6 +57,7 @@ public class SecurityConfig { "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개) "/api/home/**", // 홈 (공개) + "/api/chat/**", // 챗봇 "/h2-console/**", // H2 콘솔 (개발용) "/actuator/health", "/actuator/health/**", "/actuator/info", // Spring Actuator "/api/actuator/health", "/api/actuator/health/**", "/api/actuator/info",