Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f66feed
feat : 대회 팀 템플릿 수정 controller
myeowon Dec 31, 2025
67da576
feat : 대회 팀 템플릿 엔티티 생성 및 스키마 수정
myeowon Dec 31, 2025
6698343
feat : 대회 생성시 팀 템플릿 자동 생성
myeowon Dec 31, 2025
896ef82
feat : 팀 템플릿 예외 상황
myeowon Dec 31, 2025
03bec63
feat : 팀 템플릿 수정 구현
myeowon Dec 31, 2025
c8000ab
feat : 팀 템플릿 조회 구현
myeowon Dec 31, 2025
d8fd405
chore : 충돌 해결
JJimini Jan 31, 2026
e633084
feat: 기획 변경에 따라 HIDDEN 상태 제거 및 필수/선택(required true/false) 구조로 변경
myeowon Feb 17, 2026
0d14298
feat : ContestTemplate 네이밍으로 통일
myeowon Feb 22, 2026
7f2f0a2
refactor : Controller/Service 구조 통일에 따라 ContestTemplate을 Contest에 통합
myeowon Feb 22, 2026
b424137
Merge branch 'develop' of https://github.com/PNUops/opus-backend into…
myeowon Feb 22, 2026
88381b7
chore : import문 정리
myeowon Feb 22, 2026
6e62609
chore : 충돌 해결
myeowon Feb 22, 2026
080d065
chore : track, youTube 네이밍 통일
myeowon Feb 22, 2026
46d5b40
feat : 대회 템플릿 조회 및 수정 서비스 테스트 작성
myeowon Feb 22, 2026
6421544
feat : 대회 템플릿 조회 및 수정 restDocTest 작성
myeowon Feb 22, 2026
e1cd5b5
feat : 대회 템플릿 조회 및 수정 adoc 작성
myeowon Feb 22, 2026
414cd21
refactor : ContestTemplate 네이밍 통일
myeowon Feb 22, 2026
0c988b8
feat : ContestConvenience에서 ContestTemplateConvenience 분리
myeowon Feb 22, 2026
7863d2a
Merge branch 'develop' of https://github.com/PNUops/opus-backend into…
myeowon Feb 26, 2026
a01e936
fix : 잘못된 호출 메서드 수정
myeowon Feb 26, 2026
4c72377
Update src/main/java/com/opus/opus/modules/contest/application/Contes…
myeowon Feb 26, 2026
f00bd2d
fix : categoryName null 검증 로직 추가
myeowon Feb 26, 2026
358fcb3
fix : 테스트 오타 수정
myeowon Feb 26, 2026
3b04c88
Merge branch 'Feature/#47_대회-팀-상세보기-템플릿-기능-개발' of https://github.com/…
myeowon Feb 26, 2026
ae0a350
Update src/main/java/com/opus/opus/modules/contest/application/Contes…
myeowon Feb 27, 2026
579b0ef
refactor : ContestTemplate이 ContestTemplateRequest를 인자로 받도록 수정
myeowon Feb 27, 2026
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
55 changes: 55 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 @@ -490,3 +490,58 @@ include::{snippets}/get-vote-statistics-fail-not-found/http-request.adoc[]
include::{snippets}/get-vote-statistics-fail-not-found/http-response.adoc[]

====

== 대회 팀 템플릿 설정 관리

=== `GET`: 대회의 템플릿 조회

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

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

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

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

==== ⚠️ 실패 케이스

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

[%collapsible]
====
include::{snippets}/get-contest-template-fail-not-found/path-parameters.adoc[]
include::{snippets}/get-contest-template-fail-not-found/http-request.adoc[]
include::{snippets}/get-contest-template-fail-not-found/http-response.adoc[]
====

=== `PUT`: 대회의 템플릿 수정

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

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

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

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

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

==== ⚠️ 실패 케이스

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

[%collapsible]
====
include::{snippets}/update-contest-template-fail-not-found/path-parameters.adoc[]
include::{snippets}/update-contest-template-fail-not-found/http-request.adoc[]
include::{snippets}/update-contest-template-fail-not-found/http-response.adoc[]
====
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,23 @@
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.ContestTemplateRequest;
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.ContestRankingResponse;
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.ContestVoteLogResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestSubmissionResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse;
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.member.domain.Member;
import com.opus.opus.modules.team.application.dto.ImageResponse;
import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.PositiveOrZero;
import java.util.List;
Expand Down Expand Up @@ -84,8 +86,8 @@ public ResponseEntity<List<ContestResponse>> getAllContests() {
return ResponseEntity.ok(responses);
}

@PostMapping
@Secured("ROLE_관리자")
@PostMapping
public ResponseEntity<ContestResponse> createContest(@Valid @RequestBody final ContestRequest request) {
ContestResponse response = contestCommandService.createContest(request);
return ResponseEntity.ok(response);
Expand Down Expand Up @@ -205,4 +207,19 @@ public ResponseEntity<List<ContestSubmissionResponse>> getTeamSubmissions(@PathV
final List<ContestSubmissionResponse> responses = contestQueryService.getTeamSubmissions(contestId);
return ResponseEntity.ok(responses);
}

@GetMapping("/{contestId}/template")
public ResponseEntity<ContestTemplateResponse> getContestTemplate(@PathVariable final Long contestId) {
final ContestTemplateResponse response = contestQueryService.getContestTemplate(contestId);
return ResponseEntity.ok(response);
}


@Secured("ROLE_관리자")
@PutMapping("/{contestId}/template")
public ResponseEntity<Void> updateContestTemplate(@PathVariable final Long contestId,
@Valid @RequestBody final ContestTemplateRequest request) {
contestCommandService.updateContestTemplate(contestId, request);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,30 @@
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.convenience.ContestTemplateConvenience;
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.ContestTemplateRequest;
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.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.ContestTemplate;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.contest.domain.dao.ContestSortRepository;
import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository;
import com.opus.opus.modules.contest.exception.ContestException;
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 java.util.Optional;
import com.opus.opus.modules.team.domain.Team;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -58,19 +62,21 @@ public class ContestCommandService {
private final ContestRepository contestRepository;
private final ContestSortRepository contestSortRepository;
private final FileRepository fileRepository;
private final ContestTemplateRepository contestTemplateRepository;

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

private final FileStorageUtil fileStorageUtil;


public void saveBannerImage(final Long contestId, final MultipartFile image) {
contestConvenience.getValidateExistContest(contestId);

final Optional<File> existingFile = fileRepository.findByReferenceIdAndReferenceTypeAndImageType(contestId, CONTEST, BANNER);
final Optional<File> existingFile = fileRepository.findByReferenceIdAndReferenceTypeAndImageType(contestId,
CONTEST, BANNER);
existingFile.ifPresent(this::checkWebpConverted);

fileStorageUtil.storeFile(image, contestId, CONTEST, BANNER);
Expand Down Expand Up @@ -99,6 +105,9 @@ public ContestResponse createContest(final ContestRequest request) {
.contest(contest)
.build());

// 템플릿 자동 생성
createTemplate(contest, contestCategory.getCategoryName());

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

Expand Down Expand Up @@ -242,4 +251,61 @@ private void applyCustomSortToTeams(final List<ContestSortCustomRequest> request
team.updateItemOrder(r.itemOrder());
}
}

public void createTemplate(final Contest contest, final String categoryName) {
final ContestTemplateRequest request = getDefaultTemplateRequest(categoryName);

final ContestTemplate template = ContestTemplate.builder()
.contest(contest)
.request(request)
.build();

contestTemplateRepository.save(template);
}

public void updateContestTemplate(final Long contestId, final ContestTemplateRequest request) {
contestConvenience.validateExistContest(contestId);
final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId);
template.updateTemplate(request);
}

private ContestTemplateRequest getDefaultTemplateRequest(final String categoryName) {
if (categoryName != null && categoryName.contains("창의융합")) {
return new ContestTemplateRequest(
true,
true,
true,
true,
true,
false,
true,
false,
false,
true,
true,
true
);
}

if (categoryName != null && categoryName.contains("캡스톤")) {
return new ContestTemplateRequest(
true,
true,
true,
true,
true,
true,
true,
true,
false,
true,
false,
true
);
}

return new ContestTemplateRequest(
false, false, false, false, false, false, false, false, false, false, false, false
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@
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.convenience.ContestTemplateConvenience;
import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse;
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.ContestVoteLogResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestSubmissionResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestVoteLogResponse;
import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse;
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.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.ContestTemplate;
import com.opus.opus.modules.contest.domain.ContestTrack;
import com.opus.opus.modules.contest.domain.dao.ContestRepository;
import com.opus.opus.modules.contest.domain.dao.ContestTrackRepository;
Expand All @@ -33,19 +36,17 @@
import com.opus.opus.modules.team.application.convenience.TeamConvenience;
import com.opus.opus.modules.team.application.convenience.TeamVoteConvenience;
import com.opus.opus.modules.team.application.dto.ImageResponse;
import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse;
import com.opus.opus.modules.team.domain.Team;
import com.opus.opus.modules.team.domain.TeamVote;
import java.util.List;
import java.util.Map;
import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse;
import com.opus.opus.modules.team.domain.dao.TeamRankingResult;
import com.opus.opus.modules.team.domain.dao.TeamRepository;
import com.opus.opus.modules.team.domain.dao.TeamVoteRepository;
import com.opus.opus.modules.team.domain.dao.VoteStatisticsResult;
import java.util.ArrayList;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import lombok.RequiredArgsConstructor;
import org.antlr.v4.runtime.misc.Pair;
import org.springframework.core.io.Resource;
Expand All @@ -71,11 +72,31 @@ public class ContestQueryService {
private final ContestCategoryConvenience contestCategoryConvenience;
private final ContestConvenience contestConvenience;
private final ContestSortConvenience contestSortConvenience;
private final ContestTemplateConvenience contestTemplateConvenience;
private final TeamConvenience teamConvenience;
private final TeamVoteConvenience teamVoteConvenience;
private final MemberConvenience memberConvenience;
private final FileConvenience fileConvenience;

private static List<ContestRankingResponse> applyDenseRanking(List<TeamRankingResult> votesPerTeam) {
List<ContestRankingResponse> responseList = new ArrayList<>();
int curRank = 0; // 현재 순위
long prevCount = -1; // 이전 팀 투표 수
for (TeamRankingResult result : votesPerTeam) {
// 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지
if (prevCount != result.voteCount()) {
curRank++;
}
prevCount = result.voteCount();

responseList.add(
new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(),
result.trackName(), result.voteCount()));
}

return responseList;
}

public ImageResponse getContestBanner(final Long contestId) {
contestConvenience.getValidateExistContest(contestId);
final File findBanner = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(contestId, CONTEST,
Expand Down Expand Up @@ -200,25 +221,16 @@ public List<ContestSubmissionResponse> getTeamSubmissions(Long contestId) {
.toList();
}

private static List<ContestRankingResponse> applyDenseRanking(List<TeamRankingResult> votesPerTeam) {
List<ContestRankingResponse> responseList = new ArrayList<>();
int curRank = 0; // 현재 순위
long prevCount = -1; // 이전 팀 투표 수
for (TeamRankingResult result : votesPerTeam) {
// 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지
if (prevCount != result.voteCount()) curRank++;
prevCount = result.voteCount();

responseList.add(new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(), result.trackName(), result.voteCount()));
}

return responseList;
}

private void checkImageConverted(final File findFile) {
if (!findFile.getIsWebpConverted()) {
throw new FileException(NOT_WEBP_CONVERTED);
}
}


public ContestTemplateResponse getContestTemplate(final Long contestId) {
contestConvenience.getValidateExistContest(contestId);
final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId);
return ContestTemplateResponse.from(template);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.opus.opus.modules.contest.application.convenience;


import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD;
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_ALLOWED_DURING_VOTING_PERIOD;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST;
import static org.springframework.transaction.annotation.Propagation.MANDATORY;
import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW;
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 @@ -50,7 +50,7 @@ public long countCurrentContests() {
public List<Contest> getCurrentContests() {
return contestRepository.findAllByIsCurrentTrue();
}

@Transactional(propagation = MANDATORY)
public Contest getValidateExistContestForUpdate(final Long contestId) {
return contestRepository.findByIdForUpdate(contestId)
Expand Down
Loading