diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java index 8e02781a..b90f8525 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -12,4 +13,6 @@ public interface MemberRepository extends JpaRepository { Optional findByLoginId(String loginId); boolean existsByLoginId(String loginId); + + List findByLoginIdIn(List loginIds); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index 6c34b1b4..1bc4f83e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java @@ -164,6 +164,15 @@ public ResponseEntity> voteByIndex(@PathVariable Long p return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } + @Operation(summary = "투표 취소하기") + @DeleteMapping("/vote") + public ResponseEntity> cancelVote(@RequestParam Long pollId) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Long memberId = Long.parseLong(authentication.getName()); + pollService.cancelVote(pollId, memberId); + return ResponseEntity.ok(new ApiResponse<>(200, "투표가 취소되었습니다.", null)); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity> handleResponseStatusException(ResponseStatusException ex) { int code = ex.getStatusCode().value(); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java index a932eed3..e755ab8e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/dto/PollStaticsResponseDto.java @@ -12,4 +12,5 @@ public class PollStaticsResponseDto { private Long pollId; private List optionAgeStatics; private List optionGenderStatics; + private Long totalVoteCount; } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index f86b32bf..1f07ca49 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -1,6 +1,8 @@ package com.ai.lawyer.domain.poll.repository; import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.poll.entity.Poll; +import com.ai.lawyer.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -12,7 +14,7 @@ public interface PollVoteRepository extends JpaRepository, PollVoteRepositoryCustom { Optional findByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); - Optional findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); + List findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); List findByMember_MemberId(Long memberId); /** @@ -22,4 +24,9 @@ public interface PollVoteRepository extends JpaRepository, PollV @Modifying @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); + + boolean existsByPollAndMember(Poll poll, Member member); + + @Query("SELECT v.member.memberId FROM PollVote v WHERE v.poll = :poll") + List findMemberIdsByPoll(@Param("poll") Poll poll); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index ebe9f030..63e7853a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -29,6 +29,9 @@ public interface PollService { // ===== 투표 관련 ===== PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + // ===== 투표 취소 관련 ===== + void cancelVote(Long pollId, Long memberId); + // ===== 생성/수정/삭제 관련 ===== PollDto createPoll(PollCreateDto request, Long memberId); PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId); 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 2f338000..4c9edf4b 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 @@ -15,8 +15,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; -import java.util.List; -import java.util.ArrayList; +import java.util.*; import com.ai.lawyer.domain.poll.dto.PollCreateDto; import com.ai.lawyer.domain.poll.dto.PollForPostDto; import com.ai.lawyer.domain.poll.dto.PollOptionCreateDto; @@ -227,11 +226,13 @@ public PollStaticsResponseDto getPollStatics(Long pollId) { .genderCounts(genderGroupMap.getOrDefault(opt.getPollItemsId(), java.util.Collections.emptyList())) .build()); } + Long totalVoteCount = pollVoteRepository.countByPollId(pollId); return PollStaticsResponseDto.builder() .postId(postId) .pollId(pollId) .optionAgeStatics(optionAgeStatics) .optionGenderStatics(optionGenderStatics) + .totalVoteCount(totalVoteCount) .build(); } @@ -450,7 +451,7 @@ private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); boolean voted = false; if (memberId != null) { - voted = pollVoteRepository.findByMember_MemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isPresent(); + voted = !pollVoteRepository.findByMember_MemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); } List statics = null; if (withStatistics && poll.getStatus() == Poll.PollStatus.CLOSED) { @@ -485,6 +486,7 @@ private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { .createdAt(poll.getCreatedAt()) .closedAt(poll.getClosedAt()) .expectedCloseAt(expectedCloseAt) + .pollOptions(optionDtos) .totalVoteCount(totalVoteCount) .build(); } @@ -554,4 +556,10 @@ public void validatePollCreate(PollForPostDto dto) { public void validatePollCreate(PollCreateDto dto) { validatePollCommon(dto.getVoteTitle(), dto.getPollOptions(), dto.getReservedCloseAt()); } + + @Override + public void cancelVote(Long pollId, Long memberId) { + pollVoteRepository.findByMember_MemberIdAndPoll_PollId(memberId, pollId) + .ifPresent(pollVoteRepository::delete); + } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java new file mode 100644 index 00000000..76be3fda --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java @@ -0,0 +1,41 @@ +package com.ai.lawyer.domain.post.controller; + +import com.ai.lawyer.domain.post.service.PostDummyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; + +@Tag(name = "더미생성") +@RestController +@RequestMapping("/api/dummy") +public class PostDummyController { + private final PostDummyService dummyService; + + @Autowired + public PostDummyController(PostDummyService dummyService) { + this.dummyService = dummyService; + } + + @Operation(summary = "더미 멤버 추가") + @PostMapping("/members") + public ResponseEntity createDummyMembers(@RequestParam(defaultValue = "100") int count) { + int created = dummyService.createDummyMembers(count); + return ResponseEntity.ok("더미 멤버 " + created + "명 생성 완료"); + } + + @Operation(summary = "더미 멤버 투표") + @PostMapping("/vote") + public ResponseEntity dummyVote(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } + + @Operation(summary = "더미 멤버 삭제") + @DeleteMapping("/members") + public ResponseEntity deleteDummyMembers() { + int deleted = dummyService.deleteDummyMembers(); + return ResponseEntity.ok("더미 멤버 " + deleted + "명 삭제 완료"); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index 4c29374b..db085a25 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java @@ -29,4 +29,6 @@ public interface PostRepository extends JpaRepository { Page findByPoll_Status(PollStatus status, Pageable pageable); Page findByPoll_StatusAndPoll_PollIdIn(PollStatus status, List pollIds, Pageable pageable); Page findByPoll_PollIdIn(List pollIds, Pageable pageable); + boolean existsByPostName(String postName); + List findByPostName(String postName); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java new file mode 100644 index 00000000..c0348ec5 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java @@ -0,0 +1,131 @@ +package com.ai.lawyer.domain.post.service; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.Member.Gender; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.poll.entity.PollOptions; +import com.ai.lawyer.domain.poll.entity.PollVote; +import com.ai.lawyer.domain.poll.repository.PollOptionsRepository; +import com.ai.lawyer.domain.poll.repository.PollVoteRepository; +import com.ai.lawyer.domain.post.entity.Post; +import com.ai.lawyer.domain.post.repository.PostRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +public class PostDummyService { + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final PollOptionsRepository pollOptionsRepository; + private final PollVoteRepository pollVoteRepository; + + @Autowired + public PostDummyService(MemberRepository memberRepository, + PostRepository postRepository, + PollOptionsRepository pollOptionsRepository, + PollVoteRepository pollVoteRepository) { + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.pollOptionsRepository = pollOptionsRepository; + this.pollVoteRepository = pollVoteRepository; + } + + @Transactional + public int createDummyMembers(int count) { + List allMembers = memberRepository.findAll(); + int maxDummyNumber = allMembers.stream() + .map(Member::getLoginId) + .filter(id -> id.startsWith("dummy") && id.endsWith("@test.com")) + .map(id -> { + try { + String numStr = id.substring(5, id.indexOf("@")); + return Integer.parseInt(numStr); + } catch (Exception e) { + return 0; + } + }) + .max(Integer::compareTo) + .orElse(0); + int start = maxDummyNumber + 1; + int end = start + count - 1; + List newLoginIds = new ArrayList<>(); + for (int i = start; i <= end; i++) { + newLoginIds.add("dummy" + i + "@test.com"); + } + List existingMembers = memberRepository.findByLoginIdIn(newLoginIds); + Set existingLoginIds = new HashSet<>(); + for (Member m : existingMembers) { + existingLoginIds.add(m.getLoginId()); + } + List membersToSave = new ArrayList<>(); + Random random = new Random(); + for (int i = start; i <= end; i++) { + String loginId = "dummy" + i + "@test.com"; + if (!existingLoginIds.contains(loginId)) { + int age = 14 + random.nextInt(67); + Gender gender = (i % 2 == 0) ? Gender.MALE : Gender.FEMALE; + Member member = Member.builder() + .loginId(loginId) + .password("password") + .age(age) + .gender(gender) + .name("투표자" + i) + .build(); + membersToSave.add(member); + } + } + if (!membersToSave.isEmpty()) { + memberRepository.saveAll(membersToSave); + } + return membersToSave.size(); + } + + @Transactional + public int dummyVote(Long postId) { + Optional postOpt = postRepository.findById(postId); + if (postOpt.isEmpty()) return 0; + Post post = postOpt.get(); + if (post.getPoll() == null) return 0; + List pollOptionsList = pollOptionsRepository.findByPoll_PollId(post.getPoll().getPollId()); + if (pollOptionsList.isEmpty()) return 0; + // 모든 멤버 조회 후 더미 멤버만 필터링 + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + List votedMemberIds = pollVoteRepository.findMemberIdsByPoll(post.getPoll()); + Set votedMemberIdSet = new HashSet<>(votedMemberIds); + int voteCount = 0; + Random random = new Random(); + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollOptions selectedOption = pollOptionsList.get(random.nextInt(pollOptionsList.size())); + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .member(member) + .pollOptions(selectedOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + + @Transactional + public int deleteDummyMembers() { + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + int count = dummyMembers.size(); + if (count > 0) { + for (Member member : dummyMembers) { + pollVoteRepository.deleteByMemberIdValue(member.getMemberId()); + } + memberRepository.deleteAll(dummyMembers); + } + return count; + } +} 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 13792696..e9650a13 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 @@ -52,6 +52,7 @@ public class SecurityConfig { "/swagger-ui/**", // Swagger UI "/swagger-ui.html", // Swagger UI HTML "/api/posts/**", // 게시글 (공개) + "/api/polls/{pollId}/statics", // 투표 통계 (공개) "/api/precedent/**", // 판례 (공개) "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개)