Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/com/opus/opus/docs/asciidoc/opus.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ link:./member.html[회원 API]

== 팀 관련 API
link:./team-comment.html[팀 댓글 API]
link:./team-vote.html[팀 투표 API]

== 대회 관련 API
link:./contest.html[대회 API]
Expand Down
203 changes: 203 additions & 0 deletions src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
ifndef::snippets[]
:snippets: ./build/generated-snippets
endif::[]

= TEAM VOTE API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 3
:sectnums:

== API 목록

link:./opus.html[API 목록으로 돌아가기]

== `PUT`: 팀 투표 토글

해당 팀에 대해 투표 상태를 토글합니다.

* `isVoted: true` → 투표 등록
* `isVoted: false` → 투표 취소

NOTE: 대회 기간 중에는 투표만 보이고, 대회 기간이 아니라면 좋아요가 보입니다.

NOTE: 해당 대회의 최대 투표 개수 이상으로 투표를 등록할 수 없습니다.

.Path Parameters
include::{snippets}/toggle-team-vote/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/toggle-team-vote/request-headers.adoc[]

.Request Fields
include::{snippets}/toggle-team-vote/request-fields.adoc[]

.Response Fields
include::{snippets}/toggle-team-vote/response-fields.adoc[]

=== 시나리오 1: TeamVote 데이터가 없는 경우

특정 멤버가 특정 팀에 대해 투표 API를 처음 호출하면, TeamVote 테이블에 데이터가 새로 생성됩니다.

[cols="1,2,1"]
|===
|Request isVoted |응답 메시지 |HTTP 상태 코드

|true
|투표가 등록되었습니다.
|200 OK

|false
|아직 투표하지 않은 팀입니다.
|400 Bad Request
|===

isVoted: true → 투표 등록

include::{snippets}/toggle-team-vote/http-request.adoc[]

include::{snippets}/toggle-team-vote/http-response.adoc[]

=== 시나리오 2: TeamVote 데이터가 있는 경우

이미 해당 팀에 대한 투표 기록이 있는 경우, 상태에 따라 토글됩니다.

[cols="1,1,2,1"]
|===
|현재 isVoted |Request isVoted |응답 메시지 |HTTP 상태 코드

|true
|true
|이미 투표한 팀입니다.
|400 Bad Request

|true
|false
|투표가 취소되었습니다.
|200 OK

|false
|true
|투표가 등록되었습니다.
|200 OK

|false
|false
|이미 투표를 취소한 팀입니다.
|400 Bad Request
|===

투표 취소 (isVoted: true → false)

include::{snippets}/cancel-team-vote/http-request.adoc[]

include::{snippets}/cancel-team-vote/http-response.adoc[]

=== ⚠️ 실패 케이스

.❌ Case 1: 존재하지 않는 팀

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-not-found/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-not-found/http-response.adoc[]

====

.❌ Case 2: 이미 투표한 팀

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-already-voted/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-already-voted/http-response.adoc[]

====

.❌ Case 3: 이미 투표 취소한 팀

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-already-unvoted/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-already-unvoted/http-response.adoc[]

====

.❌ Case 4: 투표한 적 없는 팀에 취소 요청

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-not-voted-yet/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-not-voted-yet/http-response.adoc[]

====

.❌ Case 5: 최대 투표 수 초과

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-limit-exceeded/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-limit-exceeded/http-response.adoc[]

====

.❌ Case 6: 투표 기간이 아님

[%collapsible]

====

include::{snippets}/toggle-team-vote-fail-not-vote-period/http-request.adoc[]

include::{snippets}/toggle-team-vote-fail-not-vote-period/http-response.adoc[]

====

== `GET`: 사용자 투표 개수 조회

현재 사용자가 특정 대회에서 남은 투표 가능 횟수와 최대 투표 허용 개수를 조회합니다.

.Query Parameters
include::{snippets}/get-member-vote-count/query-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/get-member-vote-count/request-headers.adoc[]

.HTTP Request
include::{snippets}/get-member-vote-count/http-request.adoc[]

.HTTP Response
include::{snippets}/get-member-vote-count/http-response.adoc[]

.Response Fields
include::{snippets}/get-member-vote-count/response-fields.adoc[]

=== ⚠️ 실패 케이스

.❌ Case 1: 존재하지 않는 대회

[%collapsible]

====

include::{snippets}/get-member-vote-count-fail-not-found/http-request.adoc[]

include::{snippets}/get-member-vote-count-fail-not-found/http-response.adoc[]

====
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW;

import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
Expand Down Expand Up @@ -47,4 +48,10 @@ public long countCurrentContests() {
public List<Contest> getCurrentContests() {
return contestRepository.findAllByIsCurrentTrue();
}

public void validateVotingPeriod(final Contest contest) {
if (!contest.isVotingPeriod()) {
throw new ContestException(NOT_VOTE_PERIOD_NOW);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum ContestExceptionType implements BaseExceptionType {
CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."),
CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."),
VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."),
CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.")
CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다."),
NOT_VOTE_PERIOD_NOW(HttpStatus.BAD_REQUEST, "지금은 투표 기간이 아닙니다."),
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.opus.opus.modules.team.api;

import com.opus.opus.global.security.annotation.LoginMember;
import com.opus.opus.modules.member.domain.Member;
import com.opus.opus.modules.team.application.TeamVoteCommandService;
import com.opus.opus.modules.team.application.dto.request.TeamVoteToggleRequest;
import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse;
import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/teams")
@Secured({"ROLE_회원", "ROLE_관리자"})
public class TeamVoteController {

private final TeamVoteCommandService teamVoteCommandService;

@PutMapping("/{teamId}/votes")
public ResponseEntity<TeamVoteToggleResponse> toggleVote(@PathVariable Long teamId,
@RequestBody @Valid TeamVoteToggleRequest request,
@LoginMember Member member) {
TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), teamId, request.isVoted());
return ResponseEntity.ok(response);
}

@GetMapping("/votes")
public ResponseEntity<MemberVoteCountResponse> getMemberVoteCount(@RequestParam Long contestId,
@LoginMember Member member) {
MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contestId);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package com.opus.opus.modules.team.application;

import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED;
import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED;
import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.DUPLICATE_VOTE_REQUEST;
import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.NOT_VOTED_YET;
import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.VOTE_LIMIT_EXCEEDED;

import com.opus.opus.modules.contest.application.convenience.ContestConvenience;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.team.application.convenience.TeamConvenience;
import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse;
import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse;
import com.opus.opus.modules.team.domain.Team;
import com.opus.opus.modules.team.domain.TeamVote;
import com.opus.opus.modules.team.domain.dao.TeamVoteRepository;
import com.opus.opus.modules.team.exception.TeamVoteException;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional
public class TeamVoteCommandService {

private final TeamConvenience teamConvenience;
private final ContestConvenience contestConvenience;

private final TeamVoteRepository teamVoteRepository;

public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isVoted) {
Team team = teamConvenience.getValidateExistTeam(teamId);
Contest contest = contestConvenience.getValidateExistContest(team.getContestId());

contestConvenience.validateVotingPeriod(contest);

Optional<TeamVote> teamVoteOptional = teamVoteRepository.findByMemberIdAndTeam(memberId, team);

return teamVoteOptional.map(teamVote -> handleExistingVote(teamVote, isVoted, memberId, contest))
.orElseGet(() -> handleFirstTimeVote(memberId, team, isVoted, contest));
}

private TeamVoteToggleResponse handleFirstTimeVote(Long memberId, Team team, Boolean isVoted, Contest contest) {
if (!isVoted) {
throw new TeamVoteException(NOT_VOTED_YET);
}

long currentVoteCount = countCurrentMemberVotes(memberId, team.getContestId());
int maxVotesLimit = contest.getMaxVotesLimit();

validateVoteLimit(currentVoteCount, maxVotesLimit);
saveTeamVote(memberId, team, true);

return TeamVoteToggleResponse.of(team.getId(), true, "투표가 등록되었습니다.", currentVoteCount + 1, maxVotesLimit);
}

private TeamVoteToggleResponse handleExistingVote(final TeamVote teamVote, final Boolean isVoted, final Long memberId, final Contest contest) {
if (Objects.equals(teamVote.getIsVoted(), isVoted)) {
throw new TeamVoteException(isVoted ? ALREADY_VOTED : ALREADY_UNVOTED);
}

final long currentVoteCount = countCurrentMemberVotes(memberId, contest.getId());
final int maxVotesLimit = contest.getMaxVotesLimit();

if (isVoted) {
validateVoteLimit(currentVoteCount, maxVotesLimit);
}

final long updatedVoteCount = currentVoteCount + (isVoted ? 1 : -1);
teamVote.updateIsVoted(isVoted);

return TeamVoteToggleResponse.of(teamVote.getTeam().getId(), isVoted, isVoted ? "투표가 등록되었습니다." : "투표가 취소되었습니다.", updatedVoteCount, maxVotesLimit);
}

private long countCurrentMemberVotes(Long memberId, Long contestId) {
return teamVoteRepository.countMemberVotesInContest(memberId, contestId);
}

private void validateVoteLimit(long currentVoteCount, int maxVotesLimit) {
if (currentVoteCount >= maxVotesLimit) {
String message = String.format(VOTE_LIMIT_EXCEEDED.errorMessage(), maxVotesLimit);
throw new TeamVoteException(VOTE_LIMIT_EXCEEDED, message);
}
}

private void saveTeamVote(Long memberId, Team team, Boolean isVoted) {
try {
teamVoteRepository.save(TeamVote.builder()
.memberId(memberId)
.team(team)
.isVoted(isVoted)
.build());
teamVoteRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new TeamVoteException(DUPLICATE_VOTE_REQUEST);
}
}

@Transactional(readOnly = true)
public MemberVoteCountResponse getMemberVoteCount(Long memberId, Long contestId) {
Contest contest = contestConvenience.getValidateExistContest(contestId);
long currentVoteCount = teamVoteRepository.countMemberVotesInContest(memberId, contestId);
long remainingVotesCount = contest.getMaxVotesLimit() - currentVoteCount;
return new MemberVoteCountResponse(remainingVotesCount, (long) contest.getMaxVotesLimit());
}
}
Loading