diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index 3c89809..4999811 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -463,7 +463,7 @@ jobs: # prod.env 복원 install -d -m 700 /home/ec2-user/configs - printf "%s" "${{ secrets.PROD_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env + printf "%s" "${{ secrets.PROD_TEST_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env chmod 600 /home/ec2-user/configs/prod.env test -s /home/ec2-user/configs/prod.env || { echo "prod.env empty"; exit 1; } 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 fe468f0..1cda1b8 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 @@ -21,6 +21,7 @@ import java.time.LocalDateTime; import com.ai.lawyer.global.util.AuthUtil; +import org.springframework.dao.DataIntegrityViolationException; @Service @Transactional @@ -106,61 +107,63 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { } PollOptions pollOptions = pollOptionsRepository.findById(pollItemsId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표 항목을 찾을 수 없습니다.")); + if (pollOptions.getPoll() == null || !pollOptions.getPoll().getPollId().equals(pollId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목이 해당 투표에 속하지 않습니다."); + } + Member member = AuthUtil.getMemberOrThrow(memberId); - // USER 또는 ADMIN만 투표 가능 if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } 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(); - } + // 1) 기존 투표가 있는지 조회 + var existingOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); + if (existingOpt.isPresent()) { + return handleExistingVote(existingOpt.get(), pollOptions, pollId, pollItemsId, memberId); } - // 기존 투표 내역이 없으면 정상 투표 - PollVote pollVote = PollVote.builder() + // 2) 신규 투표 생성 + PollVote newVote = 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) + PollVote saved = pollVoteRepository.save(newVote); + return buildPollVote(saved, pollId, pollItemsId, memberId, "투표가 완료되었습니다."); + } catch (DataIntegrityViolationException e) { + // 동시성(경합)으로 인해 이미 다른 쓰레드가 투표를 만들어 중복 제약에 걸린 경우 복구 처리 log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e); + var existingAfterOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); + if (existingAfterOpt.isPresent()) { + return handleExistingVote(existingAfterOpt.get(), pollOptions, pollId, pollItemsId, memberId); + } throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다."); } } + private PollVoteDto handleExistingVote(PollVote existing, PollOptions targetOption, + Long pollId, Long pollItemsId, Long memberId) { + // 동일 항목이면 idempotent 응답 + if (existing.getPollOptions().getPollItemsId().equals(pollItemsId)) { + return buildPollVote(existing, pollId, pollItemsId, memberId, "이미 해당 항목에 투표하셨습니다."); + } + existing.setPollOptions(targetOption); + PollVote saved = pollVoteRepository.save(existing); + return buildPollVote(saved, pollId, pollItemsId, memberId, "투표 항목을 변경하였습니다."); + } + + private PollVoteDto buildPollVote(PollVote vote, Long pollId, Long pollItemsId, Long memberId, String message) { + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(vote != null ? vote.getPollVoteId() : null) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message(message) + .build(); + } + @Override public PollVoteDto voteByIndex(Long pollId, int index, Long memberId) { List options = getPollOptions(pollId); diff --git a/infra/main.tf b/infra/main.tf index 8d9fb65..02a5197 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -419,25 +419,30 @@ docker run -d \ -c 'ollama serve & sleep 5 && ollama pull daynice/kure-v1:567m && wait' echo "${var.github_access_token_1}" | docker login ghcr.io -u ${var.github_access_token_1_owner} --password-stdin -# zookeeper 설치 -docker run -d \ - --name zookeeper \ - --network common \ - -p 2181:2181 \ - -e ALLOW_ANONYMOUS_LOGIN=yes \ - bitnami/zookeeper:latest - -# kafka -docker run -d \ - --name kafka \ - --network common \ - -p 9092:9092 \ - -e KAFKA_BROKER_ID=1 \ - -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ - -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ - -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ - -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ - confluentinc/cp-kafka:7.6.0 +# # zookeeper 설치 (카프카용, 필요시 주석 해제) +# docker run -d \ +# --name zookeeper \ +# --restart unless-stopped \ +# --network common \ +# -p 2181:2181 \ +# -e ZOOKEEPER_CLIENT_PORT=2181 \ +# -e ZOOKEEPER_TICK_TIME=2000 \ +# confluentinc/cp-zookeeper:7.8.0 +# +# # kafka +# docker run -d \ +# --name kafka \ +# --restart unless-stopped \ +# --network common \ +# -p 9092:9092 \ +# -v kafka-data:/var/lib/kafka/data \ +# -e KAFKA_BROKER_ID=1 \ +# -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ +# -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ +# -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \ +# -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ +# -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ +# confluentinc/cp-kafka:7.8.0