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
97 changes: 97 additions & 0 deletions src/main/java/com/opus/opus/docs/asciidoc/contest.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,100 @@ include::{snippets}/delete-contest-banner/http-request.adoc[]

.HTTP Response
include::{snippets}/delete-contest-banner/http-response.adoc[]

== 정렬 관리

=== `PUT`: 대회 정렬 설정 변경

.Path Parameters
include::{snippets}/update-contest-sort/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/update-contest-sort/request-headers.adoc[]

.HTTP Request
include::{snippets}/update-contest-sort/http-request.adoc[]

.HTTP Response
include::{snippets}/update-contest-sort/http-response.adoc[]

.Request Fields
include::{snippets}/update-contest-sort/request-fields.adoc[]

=== `GET`: 대회 정렬 방식 조회

.Path Parameters
include::{snippets}/get-contest-sort/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/get-contest-sort/request-headers.adoc[]

.HTTP Request
include::{snippets}/get-contest-sort/http-request.adoc[]

.HTTP Response
include::{snippets}/get-contest-sort/http-response.adoc[]

.Response Fields
include::{snippets}/get-contest-sort/response-fields.adoc[]

=== `PUT`: 대회 수동 정렬 순서 저장

.Path Parameters
include::{snippets}/update-contest-sort-custom/path-parameters.adoc[]

.HTTP Request Headers
include::{snippets}/update-contest-sort-custom/request-headers.adoc[]

.HTTP Request
include::{snippets}/update-contest-sort-custom/http-request.adoc[]

.HTTP Response
include::{snippets}/update-contest-sort-custom/http-response.adoc[]

.Request Fields
include::{snippets}/update-contest-sort-custom/request-fields.adoc[]

==== ⚠️ 실패 케이스

.❌ Case 1: CUSTOM 모드가 아님

[%collapsible]
====
include::{snippets}/update-contest-sort-custom-fail-mode/http-request.adoc[]
include::{snippets}/update-contest-sort-custom-fail-mode/http-response.adoc[]
====

.❌ Case 2: request에 중복된 teamId 존재

[%collapsible]
====
include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/http-request.adoc[]
include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/http-response.adoc[]
include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/request-fields.adoc[]
====

.❌ Case 3: request에 중복된 itemOrder 존재

[%collapsible]
====
include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/http-request.adoc[]
include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/http-response.adoc[]
include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/request-fields.adoc[]
====

.❌ Case 4: 요청 팀 개수와 저장된 팀 개수 다름

[%collapsible]
====
include::{snippets}/update-contest-sort-custom-fail-different-size/http-request.adoc[]
include::{snippets}/update-contest-sort-custom-fail-different-size/http-response.adoc[]
====

.❌ Case 5: itemOrder가 팀 개수를 넘어감

[%collapsible]
====
include::{snippets}/update-contest-sort-custom-fail-over-itemOrder/http-request.adoc[]
include::{snippets}/update-contest-sort-custom-fail-over-itemOrder/http-response.adoc[]
====
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import com.opus.opus.modules.contest.application.ContestQueryService;
import com.opus.opus.modules.contest.application.dto.request.ContestCurrentToggleRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestVotesLimitRequest;
import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestSortResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse;
import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse;
import com.opus.opus.modules.team.application.dto.ImageResponse;
Expand Down Expand Up @@ -134,4 +137,26 @@ public ResponseEntity<ContestVotesLimitResponse> getMaxVotesLimit(@PathVariable
final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contestId);
return ResponseEntity.ok(response);
}

@Secured("ROLE_관리자")
@PutMapping("/{contestId}/sort")
public ResponseEntity<Void> updateContestSort(@PathVariable final Long contestId,
@RequestBody @Valid final ContestSortRequest request) {
contestCommandService.updateContestSort(contestId, request);
return ResponseEntity.noContent().build();
}

@Secured("ROLE_관리자")
@GetMapping("/{contestId}/sort")
public ResponseEntity<ContestSortResponse> getContestSort(@PathVariable final Long contestId) {
return ResponseEntity.ok(contestQueryService.getContestSort(contestId));
}

@Secured("ROLE_관리자")
@PutMapping("/{contestId}/sort/custom")
public ResponseEntity<Void> updateContestSortCustom(@PathVariable final Long contestId,
@RequestBody final List<@Valid ContestSortCustomRequest> requests) {
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateContestSortCustom의 request body는 List 타입인데 파라미터에 @Valid가 없어서(현재는 List<@Valid ...>만 사용) Bean Validation이 실행되지 않을 수 있습니다. Spring MVC에서 @RequestBody 검증을 보장하려면 파라미터 자체에 @Valid(또는 @Validated)를 추가해 요소의 @NotNull 등이 실제로 검증되도록 해주세요.

Suggested change
@RequestBody final List<@Valid ContestSortCustomRequest> requests) {
@Valid @RequestBody final List<ContestSortCustomRequest> requests) {

Copilot uses AI. Check for mistakes.
contestCommandService.updateContestSortCustom(contestId, requests);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
package com.opus.opus.modules.contest.application;


import static com.opus.opus.modules.contest.exception.ContestExceptionType.*;
import static com.opus.opus.modules.contest.domain.SortType.CUSTOM;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_TEAM_ID_IN_SORT_REQUEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.INVALID_CONTEST_SORT_CUSTOM_REQUEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_EXIST_TEAM_IN_CONTEST;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.ONLY_CUSTOM_MODE_CAN_CHANGE;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.VOTE_END_PRECEDE_VOTE_START;
import static com.opus.opus.modules.file.domain.FileImageType.BANNER;
import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST;
import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_WEBP_CONVERTED;
import static com.opus.opus.modules.team.exception.TeamExceptionType.INVALID_ITEM_ORDER;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;

import com.opus.opus.global.util.FileStorageUtil;
import com.opus.opus.modules.contest.application.convenience.ContestCategoryConvenience;
import com.opus.opus.modules.contest.application.convenience.ContestConvenience;
import com.opus.opus.modules.contest.application.convenience.ContestSortConvenience;
import com.opus.opus.modules.contest.application.dto.request.ContestRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest;
import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest;
import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestResponse;
import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.contest.domain.ContestCategory;
import com.opus.opus.modules.contest.domain.ContestSort;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.contest.domain.dao.ContestSortRepository;
import com.opus.opus.modules.contest.exception.ContestException;
import com.opus.opus.modules.contest.exception.ContestExceptionType;
import com.opus.opus.modules.file.domain.File;
import com.opus.opus.modules.file.domain.dao.FileRepository;
import com.opus.opus.modules.file.exception.FileException;
import com.opus.opus.modules.team.application.convenience.TeamConvenience;
import com.opus.opus.modules.team.domain.Team;
import com.opus.opus.modules.team.exception.TeamException;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -40,10 +56,12 @@ public class ContestCommandService {
private static final int MAX_CURRENT_CONTEST_COUNT = 2;

private final ContestRepository contestRepository;
private final ContestSortRepository contestSortRepository;
private final FileRepository fileRepository;

private final ContestConvenience contestConvenience;
private final ContestCategoryConvenience contestCategoryConvenience;
private final ContestSortConvenience contestSortConvenience;
private final TeamConvenience teamConvenience;

private final FileStorageUtil fileStorageUtil;
Expand Down Expand Up @@ -76,6 +94,10 @@ public ContestResponse createContest(final ContestRequest request) {
.build();
contestRepository.save(contest);

contestSortRepository.save(ContestSort.builder()
.contest(contest)
.build());

return ContestResponse.from(contest, contestCategory.getCategoryName());
}

Expand Down Expand Up @@ -127,6 +149,28 @@ public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimi
contest.updateMaxVotesLimit(maxVotesLimit);
}

public void updateContestSort(final Long contestId, final ContestSortRequest request) {
contestConvenience.validateExistContest(contestId);
final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contestId);

contestSort.updateMode(request.mode());
}

//todo: 팀 생성 시 itemOrder 추가
public void updateContestSortCustom(final Long contestId, final List<ContestSortCustomRequest> requests) {
final Contest contest = contestConvenience.getValidateExistContestForUpdate(contestId);
final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contest.getId());
checkCustomSort(contestSort);
validateDuplicateTeamIds(requests);

final List<Team> teams = teamConvenience.getTeamsOfContest(contestId);
validateRequestSizeMatchesTeams(requests, teams);
validateItemOrderRange(requests, teams.size());
validateDuplicateItemOrders(requests);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 DB 조회 전에 검증 가능할 거 같은데 validateDupliacteTeamIds아래로 올리면 어떨까요?


applyCustomSortToTeams(requests, teams);
}

private void validateNotInVotingPeriod(final Contest contest) {
if (contest.isVotingPeriod()) {
throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD);
Expand All @@ -140,7 +184,7 @@ private void checkWebpConverted(File existingFile) {
}

private void validateSameCurrentRequest(final Boolean currentValue, final Boolean requestValue) {
if (currentValue == requestValue) {
if (currentValue.equals(requestValue)) {
throw new ContestException(currentValue ? ALREADY_CURRENT_CONTEST : ALREADY_NOT_CURRENT_CONTEST);
}
}
Expand All @@ -150,4 +194,51 @@ private void validateCurrentContestLimit(final long currentCount) {
throw new ContestException(CURRENT_CONTEST_LIMIT_EXCEEDED);
}
}

private void validateItemOrderRange(final List<ContestSortCustomRequest> requests, final int teamCount) {
for (ContestSortCustomRequest r : requests) {
final int order = r.itemOrder();
if (order < 1 || order > teamCount) {
throw new TeamException(INVALID_ITEM_ORDER);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contest 관련 로직에서 TeamException을 던지는게 조금 어색한 거 같습니다!

}
}
}

private void checkCustomSort(final ContestSort contestSort) {
if (contestSort.getMode() != CUSTOM) {
throw new ContestException(ONLY_CUSTOM_MODE_CAN_CHANGE);
}
}

private void validateRequestSizeMatchesTeams(final List<ContestSortCustomRequest> requests,
final List<Team> teams) {
if (requests.size() != teams.size()) {
throw new ContestException(INVALID_CONTEST_SORT_CUSTOM_REQUEST);
}
}

private void validateDuplicateTeamIds(final List<ContestSortCustomRequest> requests) {
if (requests.stream().map(ContestSortCustomRequest::teamId).distinct().count() != requests.size()) {
throw new ContestException(DUPLICATE_TEAM_ID_IN_SORT_REQUEST);
}
}

private void validateDuplicateItemOrders(final List<ContestSortCustomRequest> requests) {
if (requests.stream().map(ContestSortCustomRequest::itemOrder).distinct().count() != requests.size()) {
throw new ContestException(DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST);
}
}

private void applyCustomSortToTeams(final List<ContestSortCustomRequest> requests, final List<Team> teams) {
final Map<Long, Team> teamMap = teams.stream()
.collect(toMap(Team::getId, identity()));

for (ContestSortCustomRequest r : requests) {
final Team team = teamMap.get(r.teamId());
if (team == null) {
throw new ContestException(NOT_EXIST_TEAM_IN_CONTEST);
}
team.updateItemOrder(r.itemOrder());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
import com.opus.opus.global.util.FileStorageUtil;
import com.opus.opus.modules.contest.application.convenience.ContestCategoryConvenience;
import com.opus.opus.modules.contest.application.convenience.ContestConvenience;
import com.opus.opus.modules.contest.application.convenience.ContestSortConvenience;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestSortResponse;
import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse;
import com.opus.opus.modules.contest.domain.Contest;
import com.opus.opus.modules.contest.domain.ContestCategory;
import com.opus.opus.modules.contest.domain.ContestSort;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.file.application.convenience.FileConvenience;
import com.opus.opus.modules.file.domain.File;
Expand All @@ -37,6 +40,7 @@ public class ContestQueryService {

private final ContestCategoryConvenience contestCategoryConvenience;
private final ContestConvenience contestConvenience;
private final ContestSortConvenience contestSortConvenience;
private final FileConvenience fileConvenience;

public ImageResponse getContestBanner(final Long contestId) {
Expand Down Expand Up @@ -84,6 +88,13 @@ public ContestVotesLimitResponse getMaxVotesLimit(final Long contestId) {
return ContestVotesLimitResponse.from(contest.getMaxVotesLimit());
}

public ContestSortResponse getContestSort(final Long contestId) {
contestConvenience.validateExistContest(contestId);
final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contestId);

return new ContestSortResponse(contestSort.getMode());
}

private void checkImageConverted(final File findFile) {
if (!findFile.getIsWebpConverted()) {
throw new FileException(NOT_WEBP_CONVERTED);
Expand Down
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 org.springframework.transaction.annotation.Propagation.MANDATORY;

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();
}

@Transactional(propagation = MANDATORY)
public Contest getValidateExistContestForUpdate(final Long contestId) {
return contestRepository.findByIdForUpdate(contestId)
.orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.opus.opus.modules.contest.application.convenience;

import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST_SORT;

import com.opus.opus.modules.contest.domain.ContestSort;
import com.opus.opus.modules.contest.domain.dao.ContestSortRepository;
import com.opus.opus.modules.contest.exception.ContestException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContestSortConvenience {

private final ContestSortRepository contestSortRepository;

public ContestSort getValidateExistContestSort(final Long contestId) {
return contestSortRepository.findByContestId(contestId)
.orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST_SORT));
}
}
Loading
Loading