From 91d1346f6f75012e39b9382413d30f0863a16eb7 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:31:26 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9D=98=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EC=B5=9C=EB=8C=80=20=EC=A7=80=EB=A7=9D=20=EC=88=98?= =?UTF-8?q?=EB=A5=BC=20=EB=8F=99=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20(#746)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: application_choice 테이블 추가 및 지망 수 컬럼 추가하는 스크립트 작성 * feat: ApplicationChoice 클래스 추가, 최대 지망 수 필드 추가 * chore: n지망 컬럼 제약 조건 변경 - 컬럼 복수 유지에 대한 임시 조치 * feat: DTO 및 검증 로직 변경 * feat: JPQL 쿼리 변경 * feat: 서비스 메서드 변경 * feat: 어드민 국내 대학 삽입 시 최대 지망 수 관련 DTO에 제약조건 추가 * feat: 대학 검색 응답 필드 변경 * feat: 최대 지망 수 포함하도록 서비스 메서드 변경 * feat: QueryDSL 조인 -> 동적 선택으로 변경 * test: 테스트 픽스처 및 테스트 변경 * refactor: 컨벤션에 맞게 로직 변경 - private 메서드 위치 - Wrapper 타입 - 메서드 레퍼런스 방식 * refactor: 지망 리스트에서 null인 경우 검증 추가 * refactor: 초기 상태에서도 maxChoiceCount만큼의 리스트 크기가 생성되도록 * refactor: 불필요한 검증 제거 * refactor: BatchSize로 N+1 문제 해결 * test: 불필요한 Nested 제거 * chore: mock데이터 수정 * refactor: 유효한 id 검증을 서비스에 추가 - 저장 전에 검증하여 잘못된 id가 DB에 삽입되는 경우 방지 - 중복 조회 제거 * chore: CHECK 제약조건 추가 - 최대 1지망 지원 가능 * chore: FK, 인덱스 추가 * test: containsExactlyInAnyOrder로 변경 - 응답의 개수를 정확히 판별하기 위해 * refactor: 상수 도메인에서 중앙 관리하도록 * test: 최대 지망 대학임을 바로 알 수 있도록 메서드명 변경 --- .../admin/dto/UnivApplyInfoResponse.java | 8 +- .../dto/AdminHomeUniversityCreateRequest.java | 6 +- .../dto/AdminHomeUniversityResponse.java | 6 +- .../dto/AdminHomeUniversityUpdateRequest.java | 6 +- .../service/AdminHomeUniversityService.java | 4 +- .../application/domain/Application.java | 64 ++-- .../application/domain/ApplicationChoice.java | 24 ++ .../application/dto/ApplicationsResponse.java | 5 +- .../dto/UnivApplyInfoChoiceRequest.java | 11 +- .../dto/UnivApplyInfoResponse.java | 39 +-- .../repository/ApplicationRepository.java | 15 +- .../service/ApplicationQueryService.java | 94 +++--- .../service/ApplicationSubmissionService.java | 77 +++-- .../common/exception/ErrorCode.java | 2 + .../custom/SiteUserFilterRepositoryImpl.java | 60 ++-- .../university/domain/HomeUniversity.java | 8 +- .../ValidUnivApplyInfoChoiceValidator.java | 37 +-- src/main/resources/data.sql | 4 +- .../migration/V50__dynamic_choice_count.sql | 20 ++ .../admin/service/AdminUserServiceTest.java | 5 +- .../AdminHomeUniversityServiceTest.java | 25 +- .../fixture/ApplicationFixture.java | 9 +- .../fixture/ApplicationFixtureBuilder.java | 29 +- .../service/ApplicationQueryServiceTest.java | 288 +++++------------- .../ApplicationSubmissionServiceTest.java | 104 ++++--- ...ValidUnivApplyInfoChoiceValidatorTest.java | 26 +- .../fixture/HomeUniversityFixture.java | 9 + .../fixture/HomeUniversityFixtureBuilder.java | 8 +- 28 files changed, 463 insertions(+), 530 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/application/domain/ApplicationChoice.java create mode 100644 src/main/resources/db/migration/V50__dynamic_choice_count.sql diff --git a/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java b/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java index b56b49f53..d451ae057 100644 --- a/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java +++ b/src/main/java/com/example/solidconnection/admin/dto/UnivApplyInfoResponse.java @@ -1,9 +1,7 @@ package com.example.solidconnection.admin.dto; -public record UnivApplyInfoResponse( - String firstChoiceUnivName, - String secondChoiceUnivName, - String thirdChoiceUnivName -) { +import java.util.List; + +public record UnivApplyInfoResponse(List choices) { } diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityCreateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityCreateRequest.java index 14df833e5..9451e8100 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityCreateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityCreateRequest.java @@ -1,12 +1,16 @@ package com.example.solidconnection.admin.university.dto; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record AdminHomeUniversityCreateRequest( @NotBlank(message = "협정 대학명은 필수입니다") @Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다") - String name + String name, + + @Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다") + int maxChoiceCount ) { } diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityResponse.java index 719185202..6842b4909 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityResponse.java @@ -4,13 +4,15 @@ public record AdminHomeUniversityResponse( long id, - String name + String name, + int maxChoiceCount ) { public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) { return new AdminHomeUniversityResponse( homeUniversity.getId(), - homeUniversity.getName() + homeUniversity.getName(), + homeUniversity.getMaxChoiceCount() ); } } diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityUpdateRequest.java index e22473099..464192bf3 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHomeUniversityUpdateRequest.java @@ -1,12 +1,16 @@ package com.example.solidconnection.admin.university.dto; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record AdminHomeUniversityUpdateRequest( @NotBlank(message = "협정 대학명은 필수입니다") @Size(max = 100, message = "협정 대학명은 100자 이하여야 합니다") - String name + String name, + + @Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다") + int maxChoiceCount ) { } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityService.java index bcf1a8edf..af3a10f35 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityService.java @@ -48,7 +48,7 @@ public AdminHomeUniversityResponse getHomeUniversity(Long id) { ) public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) { validateNameNotExists(request.name()); - HomeUniversity homeUniversity = new HomeUniversity(null, request.name()); + HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.maxChoiceCount()); return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity)); } @@ -69,7 +69,7 @@ public AdminHomeUniversityResponse updateHomeUniversity(Long id, AdminHomeUniver HomeUniversity homeUniversity = homeUniversityRepository.findById(id) .orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND)); validateNameNotDuplicated(request.name(), id); - homeUniversity.update(request.name()); + homeUniversity.update(request.name(), request.maxChoiceCount()); return AdminHomeUniversityResponse.from(homeUniversity); } diff --git a/src/main/java/com/example/solidconnection/application/domain/Application.java b/src/main/java/com/example/solidconnection/application/domain/Application.java index 14c138868..e168eeced 100644 --- a/src/main/java/com/example/solidconnection/application/domain/Application.java +++ b/src/main/java/com/example/solidconnection/application/domain/Application.java @@ -5,19 +5,27 @@ import com.example.solidconnection.common.BaseEntity; import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OrderBy; import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.BatchSize; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -29,13 +37,7 @@ @Entity @Table(indexes = { @Index(name = "idx_app_user_term_delete", - columnList = "site_user_id, term_id, is_delete"), - @Index(name = "idx_app_first_choice_search", - columnList = "verify_status, term_id, is_delete, first_choice_university_info_for_apply_id"), - @Index(name = "idx_app_second_choice_search", - columnList = "verify_status, term_id, is_delete, second_choice_university_info_for_apply_id"), - @Index(name = "idx_app_third_choice_search", - columnList = "verify_status, term_id, is_delete, third_choice_university_info_for_apply_id") + columnList = "site_user_id, term_id, is_delete") }) public class Application extends BaseEntity { @@ -70,18 +72,19 @@ public class Application extends BaseEntity { @Column(name = "is_delete", nullable = false) private boolean isDelete = false; - @Column(nullable = false, name = "first_choice_university_info_for_apply_id") - private long firstChoiceUnivApplyInfoId; - - @Column(name = "second_choice_university_info_for_apply_id") - private Long secondChoiceUnivApplyInfoId; - - @Column(name = "third_choice_university_info_for_apply_id") - private Long thirdChoiceUnivApplyInfoId; - @Column(name = "site_user_id", nullable = false) private long siteUserId; + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "application_choice", + joinColumns = @JoinColumn(name = "application_id"), + indexes = @Index(name = "idx_app_choice_univ_apply_info_id", columnList = "univ_apply_info_id") + ) + @OrderBy("choiceOrder ASC") + @BatchSize(size = 100) + private List choices = new ArrayList<>(); + public Application( SiteUser siteUser, Gpa gpa, @@ -101,39 +104,14 @@ public Application( LanguageTest languageTest, long termId, Integer updateCount, - long firstChoiceUnivApplyInfoId, - Long secondChoiceUnivApplyInfoId, - Long thirdChoiceUnivApplyInfoId, + List choices, String nicknameForApply) { this.siteUserId = siteUser.getId(); this.gpa = gpa; this.languageTest = languageTest; this.termId = termId; this.updateCount = updateCount; - this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; - this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; - this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; - this.nicknameForApply = nicknameForApply; - this.verifyStatus = PENDING; - } - - public Application( - SiteUser siteUser, - Gpa gpa, - LanguageTest languageTest, - long termId, - long firstChoiceUnivApplyInfoId, - Long secondChoiceUnivApplyInfoId, - Long thirdChoiceUnivApplyInfoId, - String nicknameForApply) { - this.siteUserId = siteUser.getId(); - this.gpa = gpa; - this.languageTest = languageTest; - this.termId = termId; - this.updateCount = 1; - this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; - this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; - this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; + this.choices = new ArrayList<>(choices); this.nicknameForApply = nicknameForApply; this.verifyStatus = PENDING; } diff --git a/src/main/java/com/example/solidconnection/application/domain/ApplicationChoice.java b/src/main/java/com/example/solidconnection/application/domain/ApplicationChoice.java new file mode 100644 index 000000000..dfab460c2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/application/domain/ApplicationChoice.java @@ -0,0 +1,24 @@ +package com.example.solidconnection.application.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +@Embeddable +@Getter +public class ApplicationChoice { + + @Column(name = "choice_order", nullable = false) + private int choiceOrder; + + @Column(name = "univ_apply_info_id", nullable = false) + private long univApplyInfoId; + + protected ApplicationChoice() { + } + + public ApplicationChoice(int choiceOrder, long univApplyInfoId) { + this.choiceOrder = choiceOrder; + this.univApplyInfoId = univApplyInfoId; + } +} diff --git a/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java index 657c7c2c3..793a26d6d 100644 --- a/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/ApplicationsResponse.java @@ -2,9 +2,6 @@ import java.util.List; -public record ApplicationsResponse( - List firstChoice, - List secondChoice, - List thirdChoice) { +public record ApplicationsResponse(List> choices) { } diff --git a/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java index 449b1ca2c..0de9eb85c 100644 --- a/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java +++ b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoChoiceRequest.java @@ -2,17 +2,12 @@ import com.example.solidconnection.university.dto.validation.ValidUnivApplyInfoChoice; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; @ValidUnivApplyInfoChoice public record UnivApplyInfoChoiceRequest( - @JsonProperty("firstChoiceUniversityId") - Long firstChoiceUnivApplyInfoId, - - @JsonProperty("secondChoiceUniversityId") - Long secondChoiceUnivApplyInfoId, - - @JsonProperty("thirdChoiceUniversityId") - Long thirdChoiceUnivApplyInfoId) { + @JsonProperty("choices") + List univApplyInfoIds) { } diff --git a/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoResponse.java b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoResponse.java index 7755eea55..d7c6a1b14 100644 --- a/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoResponse.java +++ b/src/main/java/com/example/solidconnection/application/dto/UnivApplyInfoResponse.java @@ -1,41 +1,24 @@ package com.example.solidconnection.application.dto; -import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; - import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.university.domain.UnivApplyInfo; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -public record UnivApplyInfoResponse( - - @JsonProperty("firstChoiceUniversity") - String firstChoiceUnivApplyInfo, - - @JsonProperty("secondChoiceUniversity") - @JsonInclude(NON_NULL) - String secondChoiceUnivApplyInfo, - - @JsonProperty("thirdChoiceUniversity") - @JsonInclude(NON_NULL) - String thirdChoiceUnivApplyInfo) { +public record UnivApplyInfoResponse(List choices) { public static UnivApplyInfoResponse of(Application application, List univApplyInfos) { - Map univApplyInfoMap = univApplyInfos.stream() - .collect(Collectors.toMap( - UnivApplyInfo::getId, - UnivApplyInfo::getKoreanName - )); + Map nameById = univApplyInfos.stream() + .collect(Collectors.toMap(UnivApplyInfo::getId, UnivApplyInfo::getKoreanName)); + + List choiceNames = application.getChoices().stream() + .sorted(Comparator.comparingInt(ApplicationChoice::getChoiceOrder)) + .map(choice -> nameById.get(choice.getUnivApplyInfoId())) + .toList(); - return new UnivApplyInfoResponse( - univApplyInfoMap.get(application.getFirstChoiceUnivApplyInfoId()), - application.getSecondChoiceUnivApplyInfoId() != null - ? univApplyInfoMap.get(application.getSecondChoiceUnivApplyInfoId()) : null, - application.getThirdChoiceUnivApplyInfoId() != null - ? univApplyInfoMap.get(application.getThirdChoiceUnivApplyInfoId()) : null - ); + return new UnivApplyInfoResponse(choiceNames); } } diff --git a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java index f9dda3096..0324cb46d 100644 --- a/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java +++ b/src/main/java/com/example/solidconnection/application/repository/ApplicationRepository.java @@ -16,20 +16,19 @@ public interface ApplicationRepository extends JpaRepository boolean existsByNicknameForApply(String nicknameForApply); @Query(""" - SELECT a + SELECT DISTINCT a FROM Application a - WHERE (a.firstChoiceUnivApplyInfoId IN :univApplyInfoIds - OR a.secondChoiceUnivApplyInfoId IN :univApplyInfoIds - OR a.thirdChoiceUnivApplyInfoId IN :univApplyInfoIds) + JOIN a.choices c + WHERE c.univApplyInfoId IN :univApplyInfoIds AND a.verifyStatus = :status AND a.termId = :termId AND a.isDelete = false """) - List findAllByUnivApplyInfoIds(@Param("univApplyInfoIds") List univApplyInfoIds, @Param("status") VerifyStatus status, @Param("termId") long termId); + List findAllByUnivApplyInfoIds( + @Param("univApplyInfoIds") List univApplyInfoIds, + @Param("status") VerifyStatus status, + @Param("termId") long termId); - // TODO: 근본 해결 필요 - // 지원서 유일성은 DB 제약으로 강제하고 - // 이 조회는 임시 회피 로직을 제거하는 방향으로 수정 필요. Optional findTopBySiteUserIdAndTermIdAndIsDeleteFalseOrderByIdDesc(long siteUserId, long termId); default Application getApplicationBySiteUserIdAndTermId(long siteUserId, long termId) { diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java index 60a495714..d69d59395 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -5,6 +5,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.application.dto.ApplicantsResponse; import com.example.solidconnection.application.dto.ApplicationsResponse; import com.example.solidconnection.application.repository.ApplicationRepository; @@ -14,7 +15,9 @@ import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.repository.TermRepository; +import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.HomeUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import com.example.solidconnection.university.repository.custom.UnivApplyInfoFilterRepositoryImpl; import io.micrometer.common.util.StringUtils; @@ -22,10 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.function.Function; import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,11 +39,10 @@ public class ApplicationQueryService { private final UnivApplyInfoFilterRepositoryImpl universityFilterRepository; private final SiteUserRepository siteUserRepository; private final TermRepository termRepository; + private final HomeUniversityRepository homeUniversityRepository; - // todo: 캐싱 정책 변경 시 수정 필요 @Transactional(readOnly = true) public ApplicationsResponse getApplicants(long siteUserId, String regionCode, String keyword) { - // 1. 대학 지원 정보 필터링 (regionCode, keyword) SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); List keywords = StringUtils.isNotBlank(keyword) ? List.of(keyword) : List.of(); @@ -51,18 +50,20 @@ public ApplicationsResponse getApplicants(long siteUserId, String regionCode, St Term term = termRepository.findByIsCurrentTrue() .orElseThrow(() -> new CustomException(CURRENT_TERM_NOT_FOUND)); - List univApplyInfos = universityFilterRepository.findAllByRegionCodeAndKeywordsAndTermId(regionCode, keywords, term.getId()); + List univApplyInfos = universityFilterRepository + .findAllByRegionCodeAndKeywordsAndTermId(regionCode, keywords, term.getId()); if (univApplyInfos.isEmpty()) { - return new ApplicationsResponse(List.of(), List.of(), List.of()); + return new ApplicationsResponse(List.of()); } - // 2. 조건에 맞는 모든 Application 한 번에 조회 List univApplyInfoIds = univApplyInfos.stream() .map(UnivApplyInfo::getId) .toList(); - List applications = applicationRepository.findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term.getId()); - // 3. 지원서 분류 및 DTO 변환 - return classifyApplicationsByChoice(univApplyInfos, applications, siteUser); + List applications = applicationRepository + .findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term.getId()); + + int maxChoiceCount = resolveMaxChoiceCount(siteUser); + return classifyApplicationsByChoice(univApplyInfos, applications, siteUser, maxChoiceCount); } @Transactional(readOnly = true) @@ -73,57 +74,59 @@ public ApplicationsResponse getApplicantsByUserApplications(long siteUserId) { Term term = termRepository.findByIsCurrentTrue() .orElseThrow(() -> new CustomException(CURRENT_TERM_NOT_FOUND)); - Application userLatestApplication = applicationRepository.getApplicationBySiteUserIdAndTermId(siteUser.getId(), term.getId()); + Application userLatestApplication = applicationRepository + .getApplicationBySiteUserIdAndTermId(siteUser.getId(), term.getId()); - List univApplyInfoIds = Stream.of( - userLatestApplication.getFirstChoiceUnivApplyInfoId(), - userLatestApplication.getSecondChoiceUnivApplyInfoId(), - userLatestApplication.getThirdChoiceUnivApplyInfoId() - ) - .filter(Objects::nonNull) + List univApplyInfoIds = userLatestApplication.getChoices().stream() + .map(ApplicationChoice::getUnivApplyInfoId) .collect(Collectors.toList()); if (univApplyInfoIds.isEmpty()) { - return new ApplicationsResponse(List.of(), List.of(), List.of()); + return new ApplicationsResponse(List.of()); } - List applications = applicationRepository.findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term.getId()); + List applications = applicationRepository + .findAllByUnivApplyInfoIds(univApplyInfoIds, VerifyStatus.APPROVED, term.getId()); List univApplyInfos = univApplyInfoRepository.findAllByIds(univApplyInfoIds); - return classifyApplicationsByChoice(univApplyInfos, applications, siteUser); + int maxChoiceCount = resolveMaxChoiceCount(siteUser); + return classifyApplicationsByChoice(univApplyInfos, applications, siteUser, maxChoiceCount); } private ApplicationsResponse classifyApplicationsByChoice( List univApplyInfos, List applications, - SiteUser siteUser) { - Map> firstChoiceMap = createChoiceMap(applications, Application::getFirstChoiceUnivApplyInfoId); - Map> secondChoiceMap = createChoiceMap(applications, Application::getSecondChoiceUnivApplyInfoId); - Map> thirdChoiceMap = createChoiceMap(applications, Application::getThirdChoiceUnivApplyInfoId); - - List firstChoiceApplicants = - createUniversityApplicantsResponses(univApplyInfos, firstChoiceMap, siteUser); - List secondChoiceApplicants = - createUniversityApplicantsResponses(univApplyInfos, secondChoiceMap, siteUser); - List thirdChoiceApplicants = - createUniversityApplicantsResponses(univApplyInfos, thirdChoiceMap, siteUser); - - return new ApplicationsResponse(firstChoiceApplicants, secondChoiceApplicants, thirdChoiceApplicants); + SiteUser siteUser, + int maxChoiceCount) { + List> allChoices = new ArrayList<>(); + for (int order = 1; order <= maxChoiceCount; order++) { + final int choiceOrder = order; + Map> choiceMap = buildChoiceMapForOrder(applications, choiceOrder); + allChoices.add(createUniversityApplicantsResponses(univApplyInfos, choiceMap, siteUser)); + } + return new ApplicationsResponse(allChoices); } - private Map> createChoiceMap( - List applications, - Function choiceIdExtractor) { - Map> choiceMap = new HashMap<>(); + private int resolveMaxChoiceCount(SiteUser siteUser) { + if (siteUser.getHomeUniversityId() == null) { + return HomeUniversity.DEFAULT_MAX_CHOICE_COUNT; + } + return homeUniversityRepository.findById(siteUser.getHomeUniversityId()) + .map(HomeUniversity::getMaxChoiceCount) + .orElse(HomeUniversity.DEFAULT_MAX_CHOICE_COUNT); + } + private Map> buildChoiceMapForOrder(List applications, int order) { + Map> map = new HashMap<>(); for (Application application : applications) { - Long choiceId = choiceIdExtractor.apply(application); - if (choiceId != null) { - choiceMap.computeIfAbsent(choiceId, k -> new ArrayList<>()).add(application); - } + application.getChoices().stream() + .filter(c -> c.getChoiceOrder() == order) + .findFirst() + .ifPresent(choice -> map + .computeIfAbsent(choice.getUnivApplyInfoId(), k -> new ArrayList<>()) + .add(application)); } - - return choiceMap; + return map; } private List createUniversityApplicantsResponses( @@ -143,7 +146,8 @@ public void validateSiteUserCanViewApplicants(long siteUserId) { Term term = termRepository.findByIsCurrentTrue() .orElseThrow(() -> new CustomException(CURRENT_TERM_NOT_FOUND)); - VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserIdAndTermId(siteUser.getId(), term.getId()).getVerifyStatus(); + VerifyStatus verifyStatus = applicationRepository + .getApplicationBySiteUserIdAndTermId(siteUser.getId(), term.getId()).getVerifyStatus(); if (verifyStatus != VerifyStatus.APPROVED) { throw new CustomException(APPLICATION_NOT_APPROVED); } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index e8c1144b4..569b4f5b5 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -1,14 +1,17 @@ package com.example.solidconnection.application.service; import static com.example.solidconnection.common.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.common.exception.ErrorCode.CHOICE_COUNT_EXCEEDS_LIMIT; import static com.example.solidconnection.common.exception.ErrorCode.CURRENT_TERM_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.application.dto.ApplicationSubmissionResponse; import com.example.solidconnection.application.dto.ApplyRequest; import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; @@ -23,12 +26,13 @@ import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.repository.TermRepository; +import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.HomeUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.List; -import java.util.Objects; import java.util.Optional; -import java.util.stream.Stream; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,29 +42,29 @@ public class ApplicationSubmissionService { public static final int APPLICATION_UPDATE_COUNT_LIMIT = 3; - private final ApplicationRepository applicationRepository; private final GpaScoreRepository gpaScoreRepository; private final LanguageTestScoreRepository languageTestScoreRepository; private final SiteUserRepository siteUserRepository; private final TermRepository termRepository; private final UnivApplyInfoRepository univApplyInfoRepository; + private final HomeUniversityRepository homeUniversityRepository; - // 학점 및 어학성적이 모두 유효한 경우에만 지원서 등록이 가능하다. - // 기존에 있던 status field 우선 APRROVED로 입력시킨다. @Transactional public ApplicationSubmissionResponse apply(long siteUserId, ApplyRequest applyRequest) { SiteUser siteUser = siteUserRepository.findById(siteUserId) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = applyRequest.univApplyInfoChoiceRequest(); + UnivApplyInfoChoiceRequest choiceRequest = applyRequest.univApplyInfoChoiceRequest(); GpaScore gpaScore = getValidGpaScore(siteUser, applyRequest.gpaScoreId()); LanguageTestScore languageTestScore = getValidLanguageTestScore(siteUser, applyRequest.languageTestScoreId()); Term term = termRepository.findByIsCurrentTrue() .orElseThrow(() -> new CustomException(CURRENT_TERM_NOT_FOUND)); - Long firstChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.firstChoiceUnivApplyInfoId(); - Long secondChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.secondChoiceUnivApplyInfoId(); - Long thirdChoiceUnivApplyInfoId = univApplyInfoChoiceRequest.thirdChoiceUnivApplyInfoId(); + int maxChoiceCount = resolveMaxChoiceCount(siteUser); + validateChoiceCount(choiceRequest, maxChoiceCount); + + List univApplyInfos = getValidUnivApplyInfos(choiceRequest.univApplyInfoIds()); + List choices = buildChoices(choiceRequest.univApplyInfoIds()); Optional existingApplication = applicationRepository.findTopBySiteUserIdAndTermIdAndIsDeleteFalseOrderByIdDesc(siteUser.getId(), term.getId()); @@ -78,26 +82,14 @@ public ApplicationSubmissionResponse apply(long siteUserId, ApplyRequest applyRe languageTestScore.getLanguageTest(), term.getId(), updateCount, - firstChoiceUnivApplyInfoId, - secondChoiceUnivApplyInfoId, - thirdChoiceUnivApplyInfoId, + choices, getRandomNickname() ); newApplication.setVerifyStatus(VerifyStatus.APPROVED); applicationRepository.save(newApplication); - List univApplyInfoIds = Stream.of( - firstChoiceUnivApplyInfoId, - secondChoiceUnivApplyInfoId, - thirdChoiceUnivApplyInfoId - ) - .filter(Objects::nonNull) - .toList(); - - List uniApplyInfos = univApplyInfoRepository.findAllByIds(univApplyInfoIds); - - return ApplicationSubmissionResponse.of(APPLICATION_UPDATE_COUNT_LIMIT, newApplication, uniApplyInfos); + return ApplicationSubmissionResponse.of(APPLICATION_UPDATE_COUNT_LIMIT, newApplication, univApplyInfos); } private GpaScore getValidGpaScore(SiteUser siteUser, Long gpaScoreId) { @@ -119,12 +111,33 @@ private LanguageTestScore getValidLanguageTestScore(SiteUser siteUser, Long lang return languageTestScore; } - private String getRandomNickname() { - String randomNickname = NicknameCreator.createRandomNickname(); - while (applicationRepository.existsByNicknameForApply(randomNickname)) { - randomNickname = NicknameCreator.createRandomNickname(); + private int resolveMaxChoiceCount(SiteUser siteUser) { + if (siteUser.getHomeUniversityId() == null) { + return HomeUniversity.DEFAULT_MAX_CHOICE_COUNT; } - return randomNickname; + return homeUniversityRepository.findById(siteUser.getHomeUniversityId()) + .map(HomeUniversity::getMaxChoiceCount) + .orElse(HomeUniversity.DEFAULT_MAX_CHOICE_COUNT); + } + + private List getValidUnivApplyInfos(List ids) { + List univApplyInfos = univApplyInfoRepository.findAllByIds(ids); + if (univApplyInfos.size() != ids.size()) { + throw new CustomException(UNIV_APPLY_INFO_NOT_FOUND); + } + return univApplyInfos; + } + + private void validateChoiceCount(UnivApplyInfoChoiceRequest request, int maxChoiceCount) { + if (request.univApplyInfoIds().size() > maxChoiceCount) { + throw new CustomException(CHOICE_COUNT_EXCEEDS_LIMIT); + } + } + + private List buildChoices(List univApplyInfoIds) { + return IntStream.range(0, univApplyInfoIds.size()) + .mapToObj(i -> new ApplicationChoice(i + 1, univApplyInfoIds.get(i))) + .toList(); } private void validateUpdateLimitNotExceed(Application application) { @@ -132,4 +145,12 @@ private void validateUpdateLimitNotExceed(Application application) { throw new CustomException(APPLY_UPDATE_LIMIT_EXCEED); } } + + private String getRandomNickname() { + String randomNickname = NicknameCreator.createRandomNickname(); + while (applicationRepository.existsByNicknameForApply(randomNickname)) { + randomNickname = NicknameCreator.createRandomNickname(); + } + return randomNickname; + } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 8dc4ea70e..7e3c5e6c5 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -98,6 +98,8 @@ public enum ErrorCode { FIRST_CHOICE_REQUIRED(HttpStatus.BAD_REQUEST.value(), "1지망 대학교를 입력해주세요."), THIRD_CHOICE_REQUIRES_SECOND(HttpStatus.BAD_REQUEST.value(), "2지망 없이 3지망을 선택할 수 없습니다."), DUPLICATE_UNIV_APPLY_INFO_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), + INVALID_UNIV_APPLY_INFO_CHOICE(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 지망 대학교가 포함되어 있습니다."), + CHOICE_COUNT_EXCEEDS_LIMIT(HttpStatus.BAD_REQUEST.value(), "지망 수가 최대 지망 수를 초과했습니다."), // community INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index caca1f013..dd840b0a3 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -1,6 +1,7 @@ package com.example.solidconnection.siteuser.repository.custom; import static com.example.solidconnection.application.domain.QApplication.application; +import static com.example.solidconnection.university.domain.QUnivApplyInfo.univApplyInfo; import static com.example.solidconnection.mentor.domain.QMentor.mentor; import static com.example.solidconnection.mentor.domain.QMentorApplication.mentorApplication; import static com.example.solidconnection.mentor.domain.QMentoring.mentoring; @@ -26,9 +27,11 @@ import com.example.solidconnection.admin.dto.UserSearchCondition; import com.example.solidconnection.admin.dto.UserSearchResponse; import com.example.solidconnection.siteuser.domain.Role; +import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.domain.UserStatus; -import com.example.solidconnection.university.domain.QUnivApplyInfo; +import com.querydsl.core.Tuple; import com.querydsl.core.types.ConstructorExpression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; @@ -38,7 +41,10 @@ import jakarta.persistence.EntityManager; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -112,17 +118,6 @@ public class SiteUserFilterRepositoryImpl implements SiteUserFilterRepository { userBan.createdAt ); - private static final QUnivApplyInfo firstChoiceUnivApplyInfo = new QUnivApplyInfo("firstChoiceUnivApplyInfo"); - private static final QUnivApplyInfo secondChoiceUnivApplyInfo = new QUnivApplyInfo("secondChoiceUnivApplyInfo"); - private static final QUnivApplyInfo thirdChoiceUnivApplyInfo = new QUnivApplyInfo("thirdChoiceUnivApplyInfo"); - - private static final ConstructorExpression UNIV_APPLY_INFO_RESPONSE_PROJECTION = - Projections.constructor( - UnivApplyInfoResponse.class, - firstChoiceUnivApplyInfo.koreanName, - secondChoiceUnivApplyInfo.koreanName, - thirdChoiceUnivApplyInfo.koreanName - ); private final JPAQueryFactory queryFactory; @@ -327,21 +322,42 @@ private MenteeInfoResponse fetchMenteeInfo(long userId) { } private UnivApplyInfoResponse fetchUnivApplyInfo(long userId) { - UnivApplyInfoResponse result = queryFactory - .select(UNIV_APPLY_INFO_RESPONSE_PROJECTION) - .from(application) - .leftJoin(firstChoiceUnivApplyInfo).on(firstChoiceUnivApplyInfo.id.eq(application.firstChoiceUnivApplyInfoId)) - .leftJoin(secondChoiceUnivApplyInfo).on(secondChoiceUnivApplyInfo.id.eq(application.secondChoiceUnivApplyInfoId)) - .leftJoin(thirdChoiceUnivApplyInfo).on(thirdChoiceUnivApplyInfo.id.eq(application.thirdChoiceUnivApplyInfoId)) - .where(application.siteUserId.eq(userId)) + Application latestApplication = queryFactory + .selectFrom(application) + .where(application.siteUserId.eq(userId), application.isDelete.isFalse()) .orderBy(application.createdAt.desc()) .fetchFirst(); - if (result == null) { - return new UnivApplyInfoResponse(null, null, null); + if (latestApplication == null) { + return new UnivApplyInfoResponse(List.of()); + } + + List univApplyInfoIds = latestApplication.getChoices().stream() + .sorted(Comparator.comparingInt(ApplicationChoice::getChoiceOrder)) + .map(ApplicationChoice::getUnivApplyInfoId) + .toList(); + + if (univApplyInfoIds.isEmpty()) { + return new UnivApplyInfoResponse(List.of()); } - return result; + List tuples = queryFactory + .select(univApplyInfo.id, univApplyInfo.koreanName) + .from(univApplyInfo) + .where(univApplyInfo.id.in(univApplyInfoIds)) + .fetch(); + + Map nameById = tuples.stream() + .collect(Collectors.toMap( + t -> t.get(univApplyInfo.id), + t -> t.get(univApplyInfo.koreanName) + )); + + List choiceNames = univApplyInfoIds.stream() + .map(nameById::get) + .toList(); + + return new UnivApplyInfoResponse(choiceNames); } diff --git a/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java b/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java index 506491c0f..ec26d9924 100644 --- a/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java +++ b/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java @@ -17,6 +17,8 @@ @Getter public class HomeUniversity extends BaseEntity { + public static final int DEFAULT_MAX_CHOICE_COUNT = 3; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") @@ -25,7 +27,11 @@ public class HomeUniversity extends BaseEntity { @Column(name = "name", nullable = false, unique = true, length = 100) private String name; - public void update(String name) { + @Column(name = "max_choice_count", nullable = false) + private int maxChoiceCount; + + public void update(String name, int maxChoiceCount) { this.name = name; + this.maxChoiceCount = maxChoiceCount; } } diff --git a/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java index 500695646..c2ce41344 100644 --- a/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java +++ b/src/main/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidator.java @@ -2,35 +2,38 @@ import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_UNIV_APPLY_INFO_CHOICE; import static com.example.solidconnection.common.exception.ErrorCode.FIRST_CHOICE_REQUIRED; -import static com.example.solidconnection.common.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_UNIV_APPLY_INFO_CHOICE; import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.Set; -import java.util.stream.Stream; -public class ValidUnivApplyInfoChoiceValidator implements ConstraintValidator { +public class ValidUnivApplyInfoChoiceValidator + implements ConstraintValidator { @Override public boolean isValid(UnivApplyInfoChoiceRequest request, ConstraintValidatorContext context) { context.disableDefaultConstraintViolation(); - if (isFirstChoiceNotSelected(request)) { + List ids = request.univApplyInfoIds(); + + if (ids == null || ids.isEmpty()) { context.buildConstraintViolationWithTemplate(FIRST_CHOICE_REQUIRED.getMessage()) .addConstraintViolation(); return false; } - if (isThirdChoiceWithoutSecond(request)) { - context.buildConstraintViolationWithTemplate(THIRD_CHOICE_REQUIRES_SECOND.getMessage()) + if (ids.stream().anyMatch(Objects::isNull)) { + context.buildConstraintViolationWithTemplate(INVALID_UNIV_APPLY_INFO_CHOICE.getMessage()) .addConstraintViolation(); return false; } - if (isDuplicate(request)) { + if (hasDuplicate(ids)) { context.buildConstraintViolationWithTemplate(DUPLICATE_UNIV_APPLY_INFO_CHOICE.getMessage()) .addConstraintViolation(); return false; @@ -39,22 +42,8 @@ public boolean isValid(UnivApplyInfoChoiceRequest request, ConstraintValidatorCo return true; } - private boolean isFirstChoiceNotSelected(UnivApplyInfoChoiceRequest request) { - return request.firstChoiceUnivApplyInfoId() == null; - } - - private boolean isThirdChoiceWithoutSecond(UnivApplyInfoChoiceRequest request) { - return request.thirdChoiceUnivApplyInfoId() != null && request.secondChoiceUnivApplyInfoId() == null; - } - - private boolean isDuplicate(UnivApplyInfoChoiceRequest request) { - Set uniqueIds = new HashSet<>(); - return Stream.of( - request.firstChoiceUnivApplyInfoId(), - request.secondChoiceUnivApplyInfoId(), - request.thirdChoiceUnivApplyInfoId() - ) - .filter(Objects::nonNull) - .anyMatch(id -> !uniqueIds.add(id)); + private boolean hasDuplicate(List ids) { + Set unique = new HashSet<>(); + return ids.stream().anyMatch(id -> !unique.add(id)); } } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 58eefa5bf..c8d040bf4 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -49,8 +49,8 @@ VALUES ('test@test.email', 'yonso', 'https://github.com/nayonsoso.png', 'CONSIDERING', 'MENTEE', '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234 -INSERT INTO home_university (id, name) -VALUES (1, '인하대학교'); +INSERT INTO home_university (id, name, max_choice_count) +VALUES (1, '인하대학교', 3); INSERT INTO host_university(id, country_code, region_code, english_name, format_name, korean_name, accommodation_url, english_course_url, homepage_url, diff --git a/src/main/resources/db/migration/V50__dynamic_choice_count.sql b/src/main/resources/db/migration/V50__dynamic_choice_count.sql new file mode 100644 index 000000000..a3cd0e68e --- /dev/null +++ b/src/main/resources/db/migration/V50__dynamic_choice_count.sql @@ -0,0 +1,20 @@ +ALTER TABLE home_university + ADD COLUMN max_choice_count INT NOT NULL DEFAULT 3, + ADD CONSTRAINT chk_max_choice_count CHECK (max_choice_count >= 1); + +CREATE TABLE application_choice +( + application_id BIGINT NOT NULL, + choice_order INT NOT NULL, + univ_apply_info_id BIGINT NOT NULL, + PRIMARY KEY (application_id, choice_order), + CONSTRAINT fk_app_choice_application + FOREIGN KEY (application_id) REFERENCES application (id), + CONSTRAINT fk_app_choice_univ_apply_info + FOREIGN KEY (univ_apply_info_id) REFERENCES university_info_for_apply (id) +); + +CREATE INDEX idx_app_choice_univ_apply_info_id ON application_choice (univ_apply_info_id); + +ALTER TABLE application + MODIFY COLUMN first_choice_university_info_for_apply_id BIGINT NULL; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java index 8c1d5a5f1..63231c5bd 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminUserServiceTest.java @@ -38,6 +38,7 @@ import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; import com.example.solidconnection.university.fixture.UniversityFixture; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -191,9 +192,7 @@ class 유저_상세_정보_조회 { termId, new Gpa(4.0, 4.5, "http://gpa-report.com/test.pdf"), new LanguageTest(LanguageTestType.TOEIC, "900", "http://language-test.com/test.pdf"), - firstChoice.getId(), - secondChoice.getId(), - null + List.of(firstChoice.getId(), secondChoice.getId()) ); // when diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityServiceTest.java index 425475b01..3977f2f1a 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminHomeUniversityServiceTest.java @@ -84,7 +84,8 @@ class 협정대학_단건_조회 { // then assertAll( () -> assertThat(response.id()).isEqualTo(homeUniversity.getId()), - () -> assertThat(response.name()).isEqualTo(homeUniversity.getName()) + () -> assertThat(response.name()).isEqualTo(homeUniversity.getName()), + () -> assertThat(response.maxChoiceCount()).isEqualTo(homeUniversity.getMaxChoiceCount()) ); } @@ -103,7 +104,7 @@ class 협정대학_생성 { @Test void 유효한_요청으로_협정대학을_생성하면_성공한다() { // given - AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교"); + AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3); // when AdminHomeUniversityResponse response = adminHomeUniversityService.createHomeUniversity(request); @@ -112,7 +113,9 @@ class 협정대학_생성 { HomeUniversity saved = homeUniversityRepository.findById(response.id()).orElseThrow(); assertAll( () -> assertThat(response.name()).isEqualTo("인하대학교"), - () -> assertThat(saved.getName()).isEqualTo("인하대학교") + () -> assertThat(response.maxChoiceCount()).isEqualTo(3), + () -> assertThat(saved.getName()).isEqualTo("인하대학교"), + () -> assertThat(saved.getMaxChoiceCount()).isEqualTo(3) ); } @@ -120,7 +123,7 @@ class 협정대학_생성 { void 이미_존재하는_이름으로_생성하면_예외가_발생한다() { // given homeUniversityFixture.인하대학교(); - AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교"); + AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3); // when & then assertThatCode(() -> adminHomeUniversityService.createHomeUniversity(request)) @@ -136,7 +139,7 @@ class 협정대학_수정 { void 유효한_요청으로_협정대학을_수정하면_성공한다() { // given HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교"); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 5); // when AdminHomeUniversityResponse response = adminHomeUniversityService.updateHomeUniversity(homeUniversity.getId(), request); @@ -145,14 +148,16 @@ class 협정대학_수정 { HomeUniversity updated = homeUniversityRepository.findById(homeUniversity.getId()).orElseThrow(); assertAll( () -> assertThat(response.name()).isEqualTo("연세대학교"), - () -> assertThat(updated.getName()).isEqualTo("연세대학교") + () -> assertThat(response.maxChoiceCount()).isEqualTo(5), + () -> assertThat(updated.getName()).isEqualTo("연세대학교"), + () -> assertThat(updated.getMaxChoiceCount()).isEqualTo(5) ); } @Test void 존재하지_않는_협정대학을_수정하면_예외가_발생한다() { // given - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교"); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 3); // when & then assertThatCode(() -> adminHomeUniversityService.updateHomeUniversity(999L, request)) @@ -164,8 +169,8 @@ class 협정대학_수정 { void 다른_협정대학의_이름으로_수정하면_예외가_발생한다() { // given homeUniversityFixture.인하대학교(); - HomeUniversity other = homeUniversityRepository.save(new HomeUniversity(null, "연세대학교")); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교"); + HomeUniversity other = homeUniversityRepository.save(new HomeUniversity(null, "연세대학교", 3)); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3); // when & then assertThatCode(() -> adminHomeUniversityService.updateHomeUniversity(other.getId(), request)) @@ -177,7 +182,7 @@ class 협정대학_수정 { void 같은_이름으로_수정하면_성공한다() { // given HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교"); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3); // when AdminHomeUniversityResponse response = adminHomeUniversityService.updateHomeUniversity(homeUniversity.getId(), request); diff --git a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java index 683f794aa..850f79282 100644 --- a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java +++ b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixture.java @@ -4,6 +4,7 @@ import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -19,9 +20,7 @@ public class ApplicationFixture { long termId, Gpa gpa, LanguageTest languageTest, - Long firstChoiceUnivApplyInfoId, - Long secondChoiceUnivApplyInfoId, - Long thirdChoiceUnivApplyInfoId + List univApplyInfoIds ) { return applicationFixtureBuilder.application() .siteUser(siteUser) @@ -29,9 +28,7 @@ public class ApplicationFixture { .languageTest(languageTest) .nicknameForApply(nicknameForApply) .termId(termId) - .firstChoiceUnivApplyInfoId(firstChoiceUnivApplyInfoId) - .secondChoiceUnivApplyInfoId(secondChoiceUnivApplyInfoId) - .thirdChoiceUnivApplyInfoId(thirdChoiceUnivApplyInfoId) + .univApplyInfoIds(univApplyInfoIds) .create(); } } diff --git a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java index d8c7fb7e9..bd1431727 100644 --- a/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/application/fixture/ApplicationFixtureBuilder.java @@ -1,11 +1,14 @@ package com.example.solidconnection.application.fixture; import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.application.domain.Gpa; import com.example.solidconnection.application.domain.LanguageTest; import com.example.solidconnection.application.repository.ApplicationRepository; import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.siteuser.domain.SiteUser; +import java.util.List; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.boot.test.context.TestComponent; @@ -17,9 +20,7 @@ public class ApplicationFixtureBuilder { private Gpa gpa; private LanguageTest languageTest; - private Long firstChoiceUnivApplyInfoId; - private Long secondChoiceUnivApplyInfoId; - private Long thirdChoiceUnivApplyInfoId; + private List univApplyInfoIds = List.of(); private SiteUser siteUser; private String nicknameForApply; private long termId; @@ -38,18 +39,8 @@ public ApplicationFixtureBuilder languageTest(LanguageTest languageTest) { return this; } - public ApplicationFixtureBuilder firstChoiceUnivApplyInfoId(Long firstChoiceUnivApplyInfoId) { - this.firstChoiceUnivApplyInfoId = firstChoiceUnivApplyInfoId; - return this; - } - - public ApplicationFixtureBuilder secondChoiceUnivApplyInfoId(Long secondChoiceUnivApplyInfoId) { - this.secondChoiceUnivApplyInfoId = secondChoiceUnivApplyInfoId; - return this; - } - - public ApplicationFixtureBuilder thirdChoiceUnivApplyInfoId(Long thirdChoiceUnivApplyInfoId) { - this.thirdChoiceUnivApplyInfoId = thirdChoiceUnivApplyInfoId; + public ApplicationFixtureBuilder univApplyInfoIds(List univApplyInfoIds) { + this.univApplyInfoIds = univApplyInfoIds; return this; } @@ -69,14 +60,16 @@ public ApplicationFixtureBuilder termId(long termId) { } public Application create() { + List choices = IntStream.range(0, univApplyInfoIds.size()) + .mapToObj(i -> new ApplicationChoice(i + 1, univApplyInfoIds.get(i))) + .toList(); Application application = new Application( siteUser, gpa, languageTest, termId, - firstChoiceUnivApplyInfoId, - secondChoiceUnivApplyInfoId, - thirdChoiceUnivApplyInfoId, + 1, + choices, nicknameForApply ); application.setVerifyStatus(VerifyStatus.APPROVED); diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java index 42b4d1524..a897284cb 100644 --- a/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationQueryServiceTest.java @@ -105,151 +105,88 @@ class 지원자_목록_조회_테스트 { void 이번_학기_전체_지원자를_조회한다() { // given Application application1 = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); Application application2 = applicationFixture.지원서( - user2, - "nickname2", - term.getId(), - gpaScore2.getGpa(), - languageTestScore2.getLanguageTest(), - 버지니아공과대학_지원_정보.getId(), - null, - null + user2, "nickname2", term.getId(), + gpaScore2.getGpa(), languageTestScore2.getLanguageTest(), + List.of(버지니아공과대학_지원_정보.getId()) ); Application application3 = applicationFixture.지원서( - user3, - "nickname3", - term.getId(), - gpaScore3.getGpa(), - languageTestScore3.getLanguageTest(), - 서던덴마크대학교_지원_정보.getId(), - null, - null + user3, "nickname3", term.getId(), + gpaScore3.getGpa(), languageTestScore3.getLanguageTest(), + List.of(서던덴마크대학교_지원_정보.getId()) ); // when - ApplicationsResponse response = applicationQueryService.getApplicants( - user1.getId(), - "", - "" - ); + ApplicationsResponse response = applicationQueryService.getApplicants(user1.getId(), "", ""); // then - assertThat(response.firstChoice()).containsAll(List.of( - ApplicantsResponse.of(괌대학_A_지원_정보, - List.of(application1), user1), - ApplicantsResponse.of(버지니아공과대학_지원_정보, - List.of(application2), user1), - ApplicantsResponse.of(서던덴마크대학교_지원_정보, - List.of(application3), user1) - )); + assertThat(response.choices().get(0)).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, List.of(application1), user1), + ApplicantsResponse.of(버지니아공과대학_지원_정보, List.of(application2), user1), + ApplicantsResponse.of(서던덴마크대학교_지원_정보, List.of(application3), user1) + ); } @Test void 이번_학기_특정_지역_지원자를_조회한다() { - //given + // given Application application1 = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); Application application2 = applicationFixture.지원서( - user2, - "nickname2", - term.getId(), - gpaScore2.getGpa(), - languageTestScore2.getLanguageTest(), - 버지니아공과대학_지원_정보.getId(), - null, - null + user2, "nickname2", term.getId(), + gpaScore2.getGpa(), languageTestScore2.getLanguageTest(), + List.of(버지니아공과대학_지원_정보.getId()) ); applicationFixture.지원서( - user3, - "nickname3", - term.getId(), - gpaScore3.getGpa(), - languageTestScore3.getLanguageTest(), - 서던덴마크대학교_지원_정보.getId(), - null, - null + user3, "nickname3", term.getId(), + gpaScore3.getGpa(), languageTestScore3.getLanguageTest(), + List.of(서던덴마크대학교_지원_정보.getId()) ); // when ApplicationsResponse response = applicationQueryService.getApplicants( - user1.getId(), - regionFixture.영미권().getCode(), - "" - ); + user1.getId(), regionFixture.영미권().getCode(), ""); // then - assertThat(response.firstChoice()).containsExactlyInAnyOrder( - ApplicantsResponse.of(괌대학_A_지원_정보, - List.of(application1), user1), - ApplicantsResponse.of(버지니아공과대학_지원_정보, - List.of(application2), user1) + assertThat(response.choices().get(0)).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, List.of(application1), user1), + ApplicantsResponse.of(버지니아공과대학_지원_정보, List.of(application2), user1) ); } @Test void 이번_학기_지원자를_대학_국문_이름으로_필터링해서_조회한다() { - //given + // given Application application1 = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); Application application2 = applicationFixture.지원서( - user2, - "nickname2", - term.getId(), - gpaScore2.getGpa(), - languageTestScore2.getLanguageTest(), - 버지니아공과대학_지원_정보.getId(), - null, - null + user2, "nickname2", term.getId(), + gpaScore2.getGpa(), languageTestScore2.getLanguageTest(), + List.of(버지니아공과대학_지원_정보.getId()) ); applicationFixture.지원서( - user3, - "nickname3", - term.getId(), - gpaScore3.getGpa(), - languageTestScore3.getLanguageTest(), - 서던덴마크대학교_지원_정보.getId(), - null, - null + user3, "nickname3", term.getId(), + gpaScore3.getGpa(), languageTestScore3.getLanguageTest(), + List.of(서던덴마크대학교_지원_정보.getId()) ); // when - ApplicationsResponse response = applicationQueryService.getApplicants( - user1.getId(), - null, - "미국" - ); + ApplicationsResponse response = applicationQueryService.getApplicants(user1.getId(), null, "미국"); // then - assertThat(response.firstChoice()).containsExactlyInAnyOrder( - ApplicantsResponse.of(괌대학_A_지원_정보, - List.of(application1), user1), - ApplicantsResponse.of(버지니아공과대학_지원_정보, - List.of(application2), user1) + assertThat(response.choices().get(0)).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, List.of(application1), user1), + ApplicantsResponse.of(버지니아공과대학_지원_정보, List.of(application2), user1) ); } @@ -257,37 +194,22 @@ class 지원자_목록_조회_테스트 { void 현재_학기_지원서만_조회되고_이전_학기_지원서는_제외된다() { // given Term previousTerm = termFixture.이전_학기("2024-2"); - Application application = applicationFixture.지원서( - user1, - "nickname1_past", - previousTerm.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + applicationFixture.지원서( + user1, "nickname1_past", previousTerm.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); - Application currentApplication = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); // when - ApplicationsResponse response = applicationQueryService.getApplicants( - user1.getId(), - "", - "" - ); + ApplicationsResponse response = applicationQueryService.getApplicants(user1.getId(), "", ""); // then - assertThat(response.firstChoice()).containsExactlyInAnyOrder( + assertThat(response.choices().get(0)).containsExactlyInAnyOrder( ApplicantsResponse.of(괌대학_A_지원_정보, List.of(currentApplication), user1), ApplicantsResponse.of(버지니아공과대학_지원_정보, List.of(), user1), ApplicantsResponse.of(서던덴마크대학교_지원_정보, List.of(), user1) @@ -298,37 +220,23 @@ class 지원자_목록_조회_테스트 { void 동일_유저의_여러_지원서_중_최신_지원서만_조회된다() { // given Application firstApplication = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); firstApplication.setIsDeleteTrue(); applicationRepository.save(firstApplication); Application secondApplication = applicationFixture.지원서( - user1, - "nickname2", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 버지니아공과대학_지원_정보.getId(), - null, - null + user1, "nickname2", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(버지니아공과대학_지원_정보.getId()) ); // when - ApplicationsResponse response = applicationQueryService.getApplicants( - user1.getId(), - "", - "" - ); + ApplicationsResponse response = applicationQueryService.getApplicants(user1.getId(), "", ""); // then - assertThat(response.firstChoice().stream() + assertThat(response.choices().get(0).stream() .flatMap(univ -> univ.applicants().stream()) .filter(ApplicantResponse::isMine)) .containsExactly(ApplicantResponse.of(secondApplication, true)); @@ -342,42 +250,27 @@ class 경쟁자_목록_조회_테스트 { void 이번_학기_지원한_대학의_경쟁자_목록을_조회한다() { // given Application application1 = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); Application application2 = applicationFixture.지원서( - user2, - "nickname2", - term.getId(), - gpaScore2.getGpa(), - languageTestScore2.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user2, "nickname2", term.getId(), + gpaScore2.getGpa(), languageTestScore2.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); applicationFixture.지원서( - user3, - "nickname3", - term.getId(), - gpaScore3.getGpa(), - languageTestScore3.getLanguageTest(), - 서던덴마크대학교_지원_정보.getId(), - null, - null + user3, "nickname3", term.getId(), + gpaScore3.getGpa(), languageTestScore3.getLanguageTest(), + List.of(서던덴마크대학교_지원_정보.getId()) ); + // when ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications(user1.getId()); // then - assertThat(response.firstChoice()).containsExactlyInAnyOrder( - ApplicantsResponse.of(괌대학_A_지원_정보, - List.of(application1, application2), user1) + assertThat(response.choices().get(0)).containsExactlyInAnyOrder( + ApplicantsResponse.of(괌대학_A_지원_정보, List.of(application1, application2), user1) ); } @@ -385,41 +278,26 @@ class 경쟁자_목록_조회_테스트 { void 이번_학기_지원한_대학_중_미선택이_있을_때_경쟁자_목록을_조회한다() { // given Application application1 = applicationFixture.지원서( - user1, - "nickname1", - term.getId(), - gpaScore1.getGpa(), - languageTestScore1.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - null, - null + user1, "nickname1", term.getId(), + gpaScore1.getGpa(), languageTestScore1.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId()) ); Application application2 = applicationFixture.지원서( - user2, - "nickname2", - term.getId(), - gpaScore2.getGpa(), - languageTestScore2.getLanguageTest(), - 괌대학_A_지원_정보.getId(), - 버지니아공과대학_지원_정보.getId(), - 서던덴마크대학교_지원_정보.getId() + user2, "nickname2", term.getId(), + gpaScore2.getGpa(), languageTestScore2.getLanguageTest(), + List.of(괌대학_A_지원_정보.getId(), 버지니아공과대학_지원_정보.getId(), 서던덴마크대학교_지원_정보.getId()) ); - Application application3 = applicationFixture.지원서( - user3, - "nickname3", - term.getId(), - gpaScore3.getGpa(), - languageTestScore3.getLanguageTest(), - 서던덴마크대학교_지원_정보.getId(), - null, - null + applicationFixture.지원서( + user3, "nickname3", term.getId(), + gpaScore3.getGpa(), languageTestScore3.getLanguageTest(), + List.of(서던덴마크대학교_지원_정보.getId()) ); // when ApplicationsResponse response = applicationQueryService.getApplicantsByUserApplications(user1.getId()); // then - assertThat(response.firstChoice()) + assertThat(response.choices().get(0)) .hasSize(1) .allSatisfy(uar -> { assertThat(uar.koreanName()).isEqualTo(괌대학_A_지원_정보.getKoreanName()); diff --git a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java index 4b3442536..0aebeb8b0 100644 --- a/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java +++ b/src/test/java/com/example/solidconnection/application/service/ApplicationSubmissionServiceTest.java @@ -2,6 +2,7 @@ import static com.example.solidconnection.application.service.ApplicationSubmissionService.APPLICATION_UPDATE_COUNT_LIMIT; import static com.example.solidconnection.common.exception.ErrorCode.APPLY_UPDATE_LIMIT_EXCEED; +import static com.example.solidconnection.common.exception.ErrorCode.CHOICE_COUNT_EXCEEDS_LIMIT; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; import static org.assertj.core.api.Assertions.assertThat; @@ -9,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import com.example.solidconnection.application.domain.Application; +import com.example.solidconnection.application.domain.ApplicationChoice; import com.example.solidconnection.application.dto.ApplicationSubmissionResponse; import com.example.solidconnection.application.dto.ApplyRequest; import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; @@ -24,8 +26,11 @@ import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.term.domain.Term; import com.example.solidconnection.term.fixture.TermFixture; +import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.fixture.HomeUniversityFixture; import com.example.solidconnection.university.fixture.UnivApplyInfoFixture; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -56,17 +61,18 @@ class ApplicationSubmissionServiceTest { @Autowired private TermFixture termFixture; + @Autowired + private HomeUniversityFixture homeUniversityFixture; + private SiteUser user; private UnivApplyInfo 괌대학_A_지원_정보; private UnivApplyInfo 버지니아공과대학_지원_정보; private UnivApplyInfo 서던덴마크대학교_지원_정보; - private Term term; @BeforeEach void setUp() { term = termFixture.현재_학기("2025-2"); - user = siteUserFixture.사용자(); 괌대학_A_지원_정보 = univApplyInfoFixture.괌대학_A_지원_정보(term.getId()); 버지니아공과대학_지원_정보 = univApplyInfoFixture.버지니아공과대학_지원_정보(term.getId()); @@ -79,9 +85,11 @@ void setUp() { GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( - 괌대학_A_지원_정보.getId(), - 버지니아공과대학_지원_정보.getId(), - 서던덴마크대학교_지원_정보.getId() + List.of( + 괌대학_A_지원_정보.getId(), + 버지니아공과대학_지원_정보.getId(), + 서던덴마크대학교_지원_정보.getId() + ) ); ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); @@ -92,28 +100,50 @@ void setUp() { Application savedApplication = applicationRepository .findTopBySiteUserIdAndTermIdAndIsDeleteFalseOrderByIdDesc(user.getId(), term.getId()) .orElseThrow(); + List savedChoices = savedApplication.getChoices(); assertAll( - () -> assertThat(response.totalApplyCount()) - .isEqualTo(APPLICATION_UPDATE_COUNT_LIMIT), - () -> assertThat(response.applyCount()) - .isEqualTo(savedApplication.getUpdateCount()), - () -> assertThat(response.appliedUniversities().firstChoiceUnivApplyInfo()) - .isEqualTo(괌대학_A_지원_정보.getKoreanName()), - () -> assertThat(response.appliedUniversities().secondChoiceUnivApplyInfo()) - .isEqualTo(버지니아공과대학_지원_정보.getKoreanName()), - () -> assertThat(response.appliedUniversities().thirdChoiceUnivApplyInfo()) - .isEqualTo(서던덴마크대학교_지원_정보.getKoreanName()), - () -> assertThat(savedApplication.getVerifyStatus()) - .isEqualTo(VerifyStatus.APPROVED), - () -> assertThat(savedApplication.isDelete()) - .isFalse(), - () -> assertThat(savedApplication.getFirstChoiceUnivApplyInfoId()) - .isEqualTo(괌대학_A_지원_정보.getId()), - () -> assertThat(savedApplication.getSecondChoiceUnivApplyInfoId()) - .isEqualTo(버지니아공과대학_지원_정보.getId()), - () -> assertThat(savedApplication.getThirdChoiceUnivApplyInfoId()) - .isEqualTo(서던덴마크대학교_지원_정보.getId()) + () -> assertThat(response.totalApplyCount()).isEqualTo(APPLICATION_UPDATE_COUNT_LIMIT), + () -> assertThat(response.applyCount()).isEqualTo(savedApplication.getUpdateCount()), + () -> assertThat(response.appliedUniversities().choices()).containsExactly( + 괌대학_A_지원_정보.getKoreanName(), + 버지니아공과대학_지원_정보.getKoreanName(), + 서던덴마크대학교_지원_정보.getKoreanName() + ), + () -> assertThat(savedApplication.getVerifyStatus()).isEqualTo(VerifyStatus.APPROVED), + () -> assertThat(savedApplication.isDelete()).isFalse(), + () -> assertThat(savedChoices).extracting(ApplicationChoice::getUnivApplyInfoId) + .containsExactly( + 괌대학_A_지원_정보.getId(), + 버지니아공과대학_지원_정보.getId(), + 서던덴마크대학교_지원_정보.getId() + ) + ); + } + + @Test + void 출신대학_최대_지망수를_초과하면_예외가_발생한다() { + // given + HomeUniversity homeUniversity = homeUniversityFixture.최대_2지망_협정대학교(); + SiteUser userWithHomeUniv = siteUserFixture.국내_대학_정보_소지_사용자(homeUniversity.getId()); + GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, userWithHomeUniv); + LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, userWithHomeUniv); + UnivApplyInfoChoiceRequest choiceRequest = new UnivApplyInfoChoiceRequest( + List.of( + 괌대학_A_지원_정보.getId(), + 버지니아공과대학_지원_정보.getId(), + 서던덴마크대학교_지원_정보.getId() + ) ); + + // when & then + assertThatCode(() -> + applicationSubmissionService.apply( + userWithHomeUniv.getId(), + new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), choiceRequest) + ) + ) + .isInstanceOf(CustomException.class) + .hasMessage(CHOICE_COUNT_EXCEEDS_LIMIT.getMessage()); } @Test @@ -122,16 +152,12 @@ void setUp() { GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.PENDING, user); LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( - 괌대학_A_지원_정보.getId(), - null, - null + List.of(괌대학_A_지원_정보.getId()) ); ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); // when & then - assertThatCode(() -> - applicationSubmissionService.apply(user.getId(), request) - ) + assertThatCode(() -> applicationSubmissionService.apply(user.getId(), request)) .isInstanceOf(CustomException.class) .hasMessage(INVALID_GPA_SCORE_STATUS.getMessage()); } @@ -142,16 +168,12 @@ void setUp() { GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.PENDING, user); UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( - 괌대학_A_지원_정보.getId(), - null, - null + List.of(괌대학_A_지원_정보.getId()) ); ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); // when & then - assertThatCode(() -> - applicationSubmissionService.apply(user.getId(), request) - ) + assertThatCode(() -> applicationSubmissionService.apply(user.getId(), request)) .isInstanceOf(CustomException.class) .hasMessage(INVALID_LANGUAGE_TEST_SCORE_STATUS.getMessage()); } @@ -162,9 +184,7 @@ void setUp() { GpaScore gpaScore = gpaScoreFixture.GPA_점수(VerifyStatus.APPROVED, user); LanguageTestScore languageTestScore = languageTestScoreFixture.어학_점수(VerifyStatus.APPROVED, user); UnivApplyInfoChoiceRequest univApplyInfoChoiceRequest = new UnivApplyInfoChoiceRequest( - 괌대학_A_지원_정보.getId(), - null, - null + List.of(괌대학_A_지원_정보.getId()) ); ApplyRequest request = new ApplyRequest(gpaScore.getId(), languageTestScore.getId(), univApplyInfoChoiceRequest); @@ -173,9 +193,7 @@ void setUp() { } // when & then - assertThatCode(() -> - applicationSubmissionService.apply(user.getId(), request) - ) + assertThatCode(() -> applicationSubmissionService.apply(user.getId(), request)) .isInstanceOf(CustomException.class) .hasMessage(APPLY_UPDATE_LIMIT_EXCEED.getMessage()); } diff --git a/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java b/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java index 94b7eb7fc..93445e899 100644 --- a/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java +++ b/src/test/java/com/example/solidconnection/university/dto/validation/ValidUnivApplyInfoChoiceValidatorTest.java @@ -2,7 +2,6 @@ import static com.example.solidconnection.common.exception.ErrorCode.DUPLICATE_UNIV_APPLY_INFO_CHOICE; import static com.example.solidconnection.common.exception.ErrorCode.FIRST_CHOICE_REQUIRED; -import static com.example.solidconnection.common.exception.ErrorCode.THIRD_CHOICE_REQUIRES_SECOND; import static org.assertj.core.api.Assertions.assertThat; import com.example.solidconnection.application.dto.UnivApplyInfoChoiceRequest; @@ -10,6 +9,7 @@ import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; +import java.util.List; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -31,7 +31,7 @@ void setUp() { @Test void 정상적인_지망_선택은_유효하다() { // given - UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, 2L, 3L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(List.of(1L, 2L, 3L)); // when Set> violations = validator.validate(request); @@ -43,7 +43,7 @@ void setUp() { @Test void 첫_번째_지망만_선택하는_것은_유효하다() { // given - UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, null, null); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(List.of(1L)); // when Set> violations = validator.validate(request); @@ -53,23 +53,9 @@ void setUp() { } @Test - void 두_번째_지망_없이_세_번째_지망을_선택하면_예외가_발생한다() { + void 지망을_선택하지_않으면_예외가_발생한다() { // given - UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, null, 3L); - - // when - Set> violations = validator.validate(request); - - // then - assertThat(violations) - .extracting(MESSAGE) - .contains(THIRD_CHOICE_REQUIRES_SECOND.getMessage()); - } - - @Test - void 첫_번째_지망을_선택하지_않으면_예외가_발생한다() { - // given - UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(null, 2L, 3L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(List.of()); // when Set> violations = validator.validate(request); @@ -84,7 +70,7 @@ void setUp() { @Test void 대학을_중복_선택하면_예외가_발생한다() { // given - UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(1L, 1L, 2L); + UnivApplyInfoChoiceRequest request = new UnivApplyInfoChoiceRequest(List.of(1L, 1L, 2L)); // when Set> violations = validator.validate(request); diff --git a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java index 38ae070e3..f3bdd0e11 100644 --- a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java +++ b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java @@ -13,12 +13,21 @@ public class HomeUniversityFixture { public HomeUniversity 인하대학교() { return homeUniversityFixtureBuilder.homeUniversity() .name("인하대학교") + .maxChoiceCount(3) + .create(); + } + + public HomeUniversity 최대_2지망_협정대학교() { + return homeUniversityFixtureBuilder.homeUniversity() + .name("테스트협정대학교_최대2지망") + .maxChoiceCount(2) .create(); } public HomeUniversity 인천대학교() { return homeUniversityFixtureBuilder.homeUniversity() .name("인천대학교") + .maxChoiceCount(3) .create(); } } diff --git a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java index 092b2a0c2..5287123ed 100644 --- a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java @@ -12,6 +12,7 @@ public class HomeUniversityFixtureBuilder { private final HomeUniversityRepository homeUniversityRepository; private String name; + private int maxChoiceCount = 3; public HomeUniversityFixtureBuilder homeUniversity() { return new HomeUniversityFixtureBuilder(homeUniversityRepository); @@ -22,8 +23,13 @@ public HomeUniversityFixtureBuilder name(String name) { return this; } + public HomeUniversityFixtureBuilder maxChoiceCount(int maxChoiceCount) { + this.maxChoiceCount = maxChoiceCount; + return this; + } + public HomeUniversity create() { return homeUniversityRepository.findByName(name) - .orElseGet(() -> homeUniversityRepository.save(new HomeUniversity(null, name))); + .orElseGet(() -> homeUniversityRepository.save(new HomeUniversity(null, name, maxChoiceCount))); } } From 2fb7476f32077c3d15f72378c23ecf75eb5cec5a Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 10:27:19 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat:=20=EC=BB=AC=EB=9F=BC=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20alias=20enum=20class=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/UnivApplyInfoColumn.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java new file mode 100644 index 000000000..feb24e76d --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.university.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UnivApplyInfoColumn { + + UNIVERSITY_KOREAN_NAME("universityKoreanName", "대학명", "학교명", "대학교명"), + STUDENT_CAPACITY("studentCapacity", "인원", "모집인원", "정원", "모집정원"), + TUITION_FEE_TYPE("tuitionFeeType", "등록금유형", "수업료유형"), + SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch", "파견가능학기", "파견학기"), + SEMESTER_REQUIREMENT("semesterRequirement", "학기요건", "재학학기"), + DETAILS_FOR_LANGUAGE("detailsForLanguage", "어학사항", "어학요건상세"), + GPA_REQUIREMENT("gpaRequirement", "성적요건", "학점요건", "최소학점"), + GPA_REQUIREMENT_CRITERIA("gpaRequirementCriteria", "학점기준", "성적기준"), + DETAILS_FOR_APPLY("detailsForApply", "지원사항", "지원안내"), + DETAILS_FOR_MAJOR("detailsForMajor", "전공사항", "전공안내"), + DETAILS_FOR_ACCOMMODATION("detailsForAccommodation", "숙소사항", "기숙사안내"), + DETAILS_FOR_ENGLISH_COURSE("detailsForEnglishCourse", "영어강좌", "영어강의"), + DETAILS("details", "기타사항", "비고"), + ; + + private final String fieldName; + private final List aliases; + + UnivApplyInfoColumn(String fieldName, String... aliases) { + this.fieldName = fieldName; + this.aliases = List.of(aliases); + } + + public static Optional findByAlias(String header) { + return Arrays.stream(values()) + .filter(col -> col.aliases.contains(header)) + .findFirst(); + } +} From 8c11ed2eba54aa5de7e30960978a7d020652014a Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 10:28:47 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat:=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=ED=91=9C=20=ED=8C=8C=EC=84=9C=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/ErrorCode.java | 3 + .../common/util/MarkdownTableParser.java | 48 ++++++++++ .../common/util/MarkdownTableParserTest.java | 92 +++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java create mode 100644 src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 7e3c5e6c5..957b6f45a 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -171,6 +171,9 @@ public enum ErrorCode { // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), + // import + INVALID_MARKDOWN_FORMAT(HttpStatus.BAD_REQUEST.value(), "올바른 마크다운 표 형식이 아닙니다."), + // general JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java new file mode 100644 index 000000000..c81c9ba93 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java @@ -0,0 +1,48 @@ +package com.example.solidconnection.common.util; + +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_MARKDOWN_FORMAT; + +import com.example.solidconnection.common.exception.CustomException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class MarkdownTableParser { + + public List> parse(String markdown) { + String[] lines = markdown.trim().split("\n"); + validate(lines); + List headers = parseRow(lines[0]); + return Arrays.stream(lines) + .skip(2) + .map(line -> buildRowMap(headers, parseRow(line))) + .collect(Collectors.toList()); + } + + private void validate(String[] lines) { + if (lines.length < 3 || !lines[1].contains("---")) { + throw new CustomException(INVALID_MARKDOWN_FORMAT); + } + } + + private List parseRow(String line) { + return Arrays.stream(line.split("\\|")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private Map buildRowMap(List headers, List cells) { + Map row = new LinkedHashMap<>(); + for (int i = 0; i < headers.size() && i < cells.size(); i++) { + if (!cells.get(i).isBlank()) { + row.put(headers.get(i), cells.get(i)); + } + } + return row; + } +} diff --git a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java new file mode 100644 index 000000000..38057f319 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java @@ -0,0 +1,92 @@ +package com.example.solidconnection.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.solidconnection.common.exception.CustomException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("마크다운 표 파서 테스트") +class MarkdownTableParserTest { + + private final MarkdownTableParser parser = new MarkdownTableParser(); + + @Nested + class 정상_파싱 { + + @Test + void 헤더와_데이터_행을_올바르게_파싱한다() { + String markdown = """ + | 대학명 | 인원 | TOEIC | + |--------|------|-------| + | MIT | 2 | 800 | + | 하버드 | 3 | | + """; + + List> rows = parser.parse(markdown); + + assertThat(rows).hasSize(2); + assertThat(rows.get(0)) + .containsEntry("대학명", "MIT") + .containsEntry("인원", "2") + .containsEntry("TOEIC", "800"); + assertThat(rows.get(1)) + .containsEntry("대학명", "하버드") + .containsEntry("인원", "3") + .doesNotContainKey("TOEIC"); + } + + @Test + void 빈_셀은_결과_맵에_포함되지_않는다() { + String markdown = """ + | 대학명 | 인원 | + |--------|------| + | MIT | | + """; + + List> rows = parser.parse(markdown); + + assertThat(rows.get(0)) + .containsKey("대학명") + .doesNotContainKey("인원"); + } + } + + @Nested + class 구조_검증 { + + @Test + void 구분자_행이_없으면_예외를_던진다() { + String markdown = """ + | 대학명 | 인원 | + | MIT | 2 | + """; + + assertThatThrownBy(() -> parser.parse(markdown)) + .isInstanceOf(CustomException.class); + } + + @Test + void 데이터_행이_없으면_예외를_던진다() { + String markdown = """ + | 대학명 | 인원 | + |--------|------| + """; + + assertThatThrownBy(() -> parser.parse(markdown)) + .isInstanceOf(CustomException.class); + } + + @Test + void 헤더와_구분자만_있으면_예외를_던진다() { + String markdown = "| 대학명 |"; + + assertThatThrownBy(() -> parser.parse(markdown)) + .isInstanceOf(CustomException.class); + } + } +} From f4a9c94fd9629f8c171de4160fd93596682851a3 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 10:34:50 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat:=20DTO=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/UnivApplyInfoFieldResponse.java | 28 +++++++++++++++++++ .../dto/UnivApplyInfoImportRequest.java | 20 +++++++++++++ .../dto/UnivApplyInfoImportResponse.java | 15 ++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java new file mode 100644 index 000000000..c5743ebba --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java @@ -0,0 +1,28 @@ +package com.example.solidconnection.admin.university.dto; + +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.UnivApplyInfoColumn; +import java.util.Arrays; +import java.util.List; + +public record UnivApplyInfoFieldResponse( + List structuredFields, + List languageTestTypes +) { + + public record FieldInfo( + String field, + List aliases + ) { + } + + public static UnivApplyInfoFieldResponse of() { + List fields = Arrays.stream(UnivApplyInfoColumn.values()) + .map(col -> new FieldInfo(col.getFieldName(), col.getAliases())) + .toList(); + List testTypes = Arrays.stream(LanguageTestType.values()) + .map(Enum::name) + .toList(); + return new UnivApplyInfoFieldResponse(fields, testTypes); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java new file mode 100644 index 000000000..0cde5eb80 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.admin.university.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.Map; + +public record UnivApplyInfoImportRequest( + @NotNull(message = "학기는 필수입니다") + Long termId, + + @NotNull(message = "대학은 필수입니다") + Long homeUniversityId, + + @NotBlank(message = "마크다운 텍스트는 필수입니다") + String markdown, + + @NotNull(message = "컬럼은 필수입니다") + Map columnMappings +) { +} diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java new file mode 100644 index 000000000..9edba0d7f --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.admin.university.dto; + +import java.util.List; + +public record UnivApplyInfoImportResponse( + int successCount, + List failedRows +) { + + public record FailedRow( + int rowNumber, + String reason + ) { + } +} From fb515bdd195bdcf51d3180e58f832e1c63465e62 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 11:04:40 +0900 Subject: [PATCH 05/34] =?UTF-8?q?refactor:=20deprecated=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/domain/UnivApplyInfoColumn.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java index feb24e76d..eb8cd3151 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -12,17 +12,12 @@ public enum UnivApplyInfoColumn { UNIVERSITY_KOREAN_NAME("universityKoreanName", "대학명", "학교명", "대학교명"), STUDENT_CAPACITY("studentCapacity", "인원", "모집인원", "정원", "모집정원"), - TUITION_FEE_TYPE("tuitionFeeType", "등록금유형", "수업료유형"), SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch", "파견가능학기", "파견학기"), SEMESTER_REQUIREMENT("semesterRequirement", "학기요건", "재학학기"), DETAILS_FOR_LANGUAGE("detailsForLanguage", "어학사항", "어학요건상세"), GPA_REQUIREMENT("gpaRequirement", "성적요건", "학점요건", "최소학점"), GPA_REQUIREMENT_CRITERIA("gpaRequirementCriteria", "학점기준", "성적기준"), - DETAILS_FOR_APPLY("detailsForApply", "지원사항", "지원안내"), - DETAILS_FOR_MAJOR("detailsForMajor", "전공사항", "전공안내"), DETAILS_FOR_ACCOMMODATION("detailsForAccommodation", "숙소사항", "기숙사안내"), - DETAILS_FOR_ENGLISH_COURSE("detailsForEnglishCourse", "영어강좌", "영어강의"), - DETAILS("details", "기타사항", "비고"), ; private final String fieldName; From 5c4e888943c96585bdd43be7c8af3b8f26200b2e Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 11:07:54 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoRowSaver.java | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java new file mode 100644 index 000000000..4125644fb --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -0,0 +1,136 @@ +package com.example.solidconnection.admin.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.domain.HostUniversity; +import com.example.solidconnection.university.domain.LanguageRequirement; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.SemesterAvailableForDispatch; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.repository.HostUniversityRepository; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminUnivApplyInfoRowSaver { + + private final HostUniversityRepository hostUniversityRepository; + private final UnivApplyInfoRepository univApplyInfoRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save( + Map rowData, + Map columnMappings, + HomeUniversity homeUniversity, + long termId + ) { + ImportData data = buildImportData(rowData, columnMappings); + + if (data.universityKoreanName == null || data.universityKoreanName.isBlank()) { + throw new IllegalArgumentException("대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다"); + } + + HostUniversity hostUniversity = hostUniversityRepository + .findByKoreanName(data.universityKoreanName) + .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + + UnivApplyInfo univApplyInfo = new UnivApplyInfo( + null, + termId, + homeUniversity, + data.universityKoreanName, + data.studentCapacity, + null, + data.semesterAvailableForDispatch, + data.semesterRequirement, + data.detailsForLanguage, + data.gpaRequirement, + data.gpaRequirementCriteria, + null, + null, + data.detailsForAccommodation, + null, + null, + data.extraInfo.isEmpty() ? null : data.extraInfo, + new HashSet<>(), + hostUniversity + ); + + UnivApplyInfo saved = univApplyInfoRepository.save(univApplyInfo); + + data.languageRequirements.forEach((testType, minScore) -> { + LanguageRequirement lr = new LanguageRequirement(null, testType, minScore, saved); + saved.addLanguageRequirements(lr); + }); + } + + private ImportData buildImportData(Map rowData, Map columnMappings) { + ImportData data = new ImportData(); + rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); + return data; + } + + private void applyField(ImportData data, String header, String value, Map columnMappings) { + String targetField = columnMappings.getOrDefault(header, "extraInfo"); + + if ("extraInfo".equals(targetField)) { + data.extraInfo.put(header, value); + return; + } + + try { + LanguageTestType testType = LanguageTestType.valueOf(targetField); + if (!value.isBlank()) { + data.languageRequirements.put(testType, value); + } + return; + } catch (IllegalArgumentException ignored) { + } + + if (!tryApplyStructuredField(data, targetField, value)) { + data.extraInfo.put(header, value); + } + } + + private boolean tryApplyStructuredField(ImportData data, String fieldName, String value) { + try { + switch (fieldName) { + case "universityKoreanName" -> data.universityKoreanName = value; + case "studentCapacity" -> data.studentCapacity = Integer.parseInt(value); + case "semesterAvailableForDispatch" -> data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); + case "semesterRequirement" -> data.semesterRequirement = value; + case "detailsForLanguage" -> data.detailsForLanguage = value; + case "gpaRequirement" -> data.gpaRequirement = value; + case "gpaRequirementCriteria" -> data.gpaRequirementCriteria = value; + case "detailsForAccommodation" -> data.detailsForAccommodation = value; + default -> { return false; } + } + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private static class ImportData { + + String universityKoreanName; + Integer studentCapacity; + SemesterAvailableForDispatch semesterAvailableForDispatch; + String semesterRequirement; + String detailsForLanguage; + String gpaRequirement; + String gpaRequirementCriteria; + String detailsForAccommodation; + Map extraInfo = new HashMap<>(); + Map languageRequirements = new HashMap<>(); + } +} From c03954a7e1ea497c34c3e19ad5268932c476d29a Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 11:24:49 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=99=20=EC=A0=95=EB=B3=B4=20=EC=82=BD=EC=9E=85=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoService.java | 66 ++++ .../AdminUnivApplyInfoServiceTest.java | 294 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java create mode 100644 src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java new file mode 100644 index 000000000..bdab46bec --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -0,0 +1,66 @@ +package com.example.solidconnection.admin.university.service; + +import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; + +import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.FailedRow; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.util.MarkdownTableParser; +import com.example.solidconnection.term.repository.TermRepository; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.repository.HomeUniversityRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminUnivApplyInfoService { + + private final TermRepository termRepository; + private final HomeUniversityRepository homeUniversityRepository; + private final MarkdownTableParser markdownTableParser; + private final AdminUnivApplyInfoRowSaver rowSaver; + + public UnivApplyInfoFieldResponse getFields() { + return UnivApplyInfoFieldResponse.of(); + } + + @Transactional + public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportRequest request) { + validateTermExists(request.termId()); + HomeUniversity homeUniversity = findHomeUniversity(request.homeUniversityId()); + + List> rows = markdownTableParser.parse(request.markdown()); + + int successCount = 0; + List failedRows = new ArrayList<>(); + + for (int i = 0; i < rows.size(); i++) { + try { + rowSaver.save(rows.get(i), request.columnMappings(), homeUniversity, request.termId()); + successCount++; + } catch (Exception e) { + failedRows.add(new FailedRow(i + 1, e.getMessage())); + } + } + + return new UnivApplyInfoImportResponse(successCount, failedRows); + } + + private void validateTermExists(Long termId) { + termRepository.findById(termId) + .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); + } + + private HomeUniversity findHomeUniversity(Long homeUniversityId) { + return homeUniversityRepository.findById(homeUniversityId) + .orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND)); + } +} diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java new file mode 100644 index 000000000..915cb0f6f --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -0,0 +1,294 @@ +package com.example.solidconnection.admin.university.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.term.domain.Term; +import com.example.solidconnection.term.fixture.TermFixture; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.domain.LanguageTestType; +import com.example.solidconnection.university.domain.UnivApplyInfo; +import com.example.solidconnection.university.domain.UnivApplyInfoColumn; +import com.example.solidconnection.university.fixture.HomeUniversityFixture; +import com.example.solidconnection.university.fixture.UniversityFixture; +import com.example.solidconnection.university.repository.LanguageRequirementRepository; +import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.Arrays; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@TestContainerSpringBootTest +@DisplayName("UnivApplyInfo 임포트 서비스 테스트") +class AdminUnivApplyInfoServiceTest { + + @Autowired + private AdminUnivApplyInfoService adminUnivApplyInfoService; + + @Autowired + private UnivApplyInfoRepository univApplyInfoRepository; + + @Autowired + private LanguageRequirementRepository languageRequirementRepository; + + @Autowired + private TermFixture termFixture; + + @Autowired + private HomeUniversityFixture homeUniversityFixture; + + @Autowired + private UniversityFixture universityFixture; + + private Term term; + private HomeUniversity homeUniversity; + + private static final String 괌_대학_한국명 = "괌 대학"; + private static final String 버지니아_대학_한국명 = "버지니아 공과 대학"; + private static final long invalidId = 999L; + + @BeforeEach + void setUp() { + term = termFixture.현재_학기("2025-2"); + homeUniversity = homeUniversityFixture.인하대학교(); + universityFixture.괌_대학(); + universityFixture.버지니아_공과_대학(); + } + + @Nested + class 필드_목록을_조회한다 { + + @Test + void 구조화_필드와_어학시험_타입을_반환한다() { + // when + UnivApplyInfoFieldResponse response = adminUnivApplyInfoService.getFields(); + + // then + assertAll( + () -> assertThat(response.structuredFields()) + .hasSize(UnivApplyInfoColumn.values().length), + () -> assertThat(response.languageTestTypes()) + .containsExactlyInAnyOrderElementsOf( + Arrays.stream(LanguageTestType.values()).map(Enum::name).toList() + ) + ); + } + + @Test + void studentCapacity_필드에_인원_alias가_포함된다() { + // when + UnivApplyInfoFieldResponse response = adminUnivApplyInfoService.getFields(); + + // then + UnivApplyInfoFieldResponse.FieldInfo field = response.structuredFields().stream() + .filter(f -> "studentCapacity".equals(f.field())) + .findFirst() + .orElseThrow(); + assertThat(field.aliases()).contains("인원", "모집인원", "정원"); + } + } + + @Nested + class UnivApplyInfo를_임포트한다 { + + @Test + void 모든_행이_정상_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | 인원 | + |--------|------| + | %s | 2 | + | %s | 3 | + """, 괌_대학_한국명, 버지니아_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isEqualTo(2), + () -> assertThat(response.failedRows()).isEmpty(), + () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) + ); + } + + @Test + void enum_변환_실패시_해당_컬럼은_extraInfo에_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | 파견가능학기 | + |--------|------------| + | %s | 알수없음 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "파견가능학기", "semesterAvailableForDispatch") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); + assertAll( + () -> assertThat(response.successCount()).isEqualTo(1), + () -> assertThat(response.failedRows()).isEmpty(), + () -> assertThat(saved.getSemesterAvailableForDispatch()).isNull(), + () -> assertThat(saved.getExtraInfo()).containsEntry("파견가능학기", "알수없음") + ); + } + + @Test + void 어학시험_컬럼은_LanguageRequirement로_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | TOEIC | + |--------|-------| + | %s | 800 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "TOEIC", "TOEIC") + ); + + // when + adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertThat(languageRequirementRepository.findAll()) + .anyMatch(lr -> lr.getLanguageTestType() == LanguageTestType.TOEIC + && "800".equals(lr.getMinScore())); + } + + @Test + void extraInfo_매핑_컬럼은_extraInfo에_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | 특이사항 | + |--------|----------| + | %s | 주의 필요 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "특이사항", "extraInfo") + ); + + // when + adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); + assertThat(saved.getExtraInfo()).containsEntry("특이사항", "주의 필요"); + } + + @Test + void columnMappings에_없는_컬럼은_extraInfo에_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | 미매핑컬럼 | + |--------|------------| + | %s | 어떤값 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName") + ); + + // when + adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); + assertThat(saved.getExtraInfo()).containsEntry("미매핑컬럼", "어떤값"); + } + + @Test + void 존재하지_않는_대학명_행만_실패하고_나머지는_저장된다() { + // given + String markdown = String.format(""" + | 대학명 | 인원 | + |--------|------| + | %s | 2 | + | 존재하지않는대학교 | 1 | + | %s | 3 | + """, 괌_대학_한국명, 버지니아_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isEqualTo(2), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).rowNumber()).isEqualTo(2), + () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) + ); + } + + @Test + void 구분자_없는_마크다운이면_예외_응답을_반환한다() { + // given + String invalidMarkdown = "| 대학명 |\n| MIT |"; + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), invalidMarkdown, Map.of() + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + } + + @Test + void 존재하지_않는_termId이면_예외_응답을_반환한다() { + // given + String markdown = String.format(""" + | 대학명 | + |--------| + | %s | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + invalidId, homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName") + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + } + + @Test + void 존재하지_않는_homeUniversityId이면_예외_응답을_반환한다() { + // given + String markdown = String.format(""" + | 대학명 | + |--------| + | %s | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), invalidId, markdown, + Map.of("대학명", "universityKoreanName") + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + } + } +} From 0411646d51eead2471fb464c31973af6083d8890 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 11:37:18 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminUnivApplyInfoController.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java diff --git a/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java b/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java new file mode 100644 index 000000000..3b1b0d18f --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java @@ -0,0 +1,34 @@ +package com.example.solidconnection.admin.university.controller; + +import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; +import com.example.solidconnection.admin.university.service.AdminUnivApplyInfoService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/admin/univ-apply-infos") +@RestController +public class AdminUnivApplyInfoController { + + private final AdminUnivApplyInfoService adminUnivApplyInfoService; + + @GetMapping("/fields") + public ResponseEntity getFields() { + return ResponseEntity.ok(adminUnivApplyInfoService.getFields()); + } + + @PostMapping + public ResponseEntity importUnivApplyInfos( + @Valid @RequestBody UnivApplyInfoImportRequest request + ) { + return ResponseEntity.ok(adminUnivApplyInfoService.importUnivApplyInfos(request)); + } +} From 19d5e4a1ef732d532223b6dd10abcfcc8b9e554c Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 12:45:41 +0900 Subject: [PATCH 09/34] =?UTF-8?q?fix:=20=EC=A4=91=EA=B0=84=20=EB=B9=88=20?= =?UTF-8?q?=EC=85=80=EC=9D=B4=20=EC=9E=88=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=EC=A0=95=EC=83=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9D=B4=20=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/MarkdownTableParser.java | 6 ++++-- .../common/util/MarkdownTableParserTest.java | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java index c81c9ba93..82892d29a 100644 --- a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java +++ b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java @@ -30,9 +30,11 @@ private void validate(String[] lines) { } private List parseRow(String line) { - return Arrays.stream(line.split("\\|")) + String stripped = line.trim(); + if (stripped.startsWith("|")) stripped = stripped.substring(1); + if (stripped.endsWith("|")) stripped = stripped.substring(0, stripped.length() - 1); + return Arrays.stream(stripped.split("\\|")) .map(String::trim) - .filter(s -> !s.isEmpty()) .collect(Collectors.toList()); } diff --git a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java index 38057f319..52d20b42b 100644 --- a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java +++ b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java @@ -40,6 +40,22 @@ class 정상_파싱 { .doesNotContainKey("TOEIC"); } + @Test + void 중간_빈_셀이_있어도_이후_컬럼이_올바르게_매핑된다() { + String markdown = """ + | 대학명 | 인원 | TOEIC | + |--------|------|-------| + | MIT | | 800 | + """; + + List> rows = parser.parse(markdown); + + assertThat(rows.get(0)) + .containsEntry("대학명", "MIT") + .doesNotContainKey("인원") + .containsEntry("TOEIC", "800"); + } + @Test void 빈_셀은_결과_맵에_포함되지_않는다() { String markdown = """ From 06038b44870d794829486330a1d36420071d7748 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 16:44:29 +0900 Subject: [PATCH 10/34] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B3=84=EC=A0=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin@test.email - Admin@1234 - 로컬 어드민 웹에 대한 접근 허용 --- src/main/resources/config/application-variable.yml | 1 + src/main/resources/data.sql | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/config/application-variable.yml b/src/main/resources/config/application-variable.yml index 629beb305..433eecb17 100644 --- a/src/main/resources/config/application-variable.yml +++ b/src/main/resources/config/application-variable.yml @@ -147,6 +147,7 @@ cors: allowed-origins: - "http://localhost:8080" - "http://localhost:3000" + - "http://localhost:4000" - "http://localhost:5173" - "https://localhost:3000" diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index c8d040bf4..babb07cb6 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -47,7 +47,10 @@ VALUES ('2024-1', true); INSERT INTO site_user (email, nickname, profile_image_url, exchange_status, role, password, auth_type) VALUES ('test@test.email', 'yonso', 'https://github.com/nayonsoso.png', 'CONSIDERING', 'MENTEE', - '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234 + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 + ('admin@test.email', 'admin', 'https://github.com/nayonsoso.png', + 'CONSIDERING', 'ADMIN', + '$2a$10$etoPG1B6Ua9Lj2VwruWKGurpMdToxl06g2WGHVk1mFKGfXyKgA5Pm', 'EMAIL'); -- Admin@1234 INSERT INTO home_university (id, name, max_choice_count) VALUES (1, '인하대학교', 3); From c86d4522843565fbdc794068f76e5f297560ca76 Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 17:08:24 +0900 Subject: [PATCH 11/34] =?UTF-8?q?feat:=20=ED=95=99=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=A1=B0=ED=9A=8C,=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=ED=95=99=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../term/controller/AdminTermController.java | 47 ++++++++++++++++++ .../term/dto/AdminTermCreateRequest.java | 11 +++++ .../admin/term/dto/AdminTermResponse.java | 18 +++++++ .../admin/term/service/AdminTermService.java | 49 +++++++++++++++++++ .../common/exception/ErrorCode.java | 1 + .../solidconnection/term/domain/Term.java | 8 +++ 6 files changed, 134 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java create mode 100644 src/main/java/com/example/solidconnection/admin/term/dto/AdminTermCreateRequest.java create mode 100644 src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java create mode 100644 src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java diff --git a/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java b/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java new file mode 100644 index 000000000..01dd6b2f3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java @@ -0,0 +1,47 @@ +package com.example.solidconnection.admin.term.controller; + +import com.example.solidconnection.admin.term.dto.AdminTermCreateRequest; +import com.example.solidconnection.admin.term.dto.AdminTermResponse; +import com.example.solidconnection.admin.term.service.AdminTermService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/admin/terms") +@RestController +public class AdminTermController { + + private final AdminTermService adminTermService; + + @GetMapping + public ResponseEntity> getAllTerms() { + List responses = adminTermService.getAllTerms(); + return ResponseEntity.ok(responses); + } + + @PostMapping + public ResponseEntity createTerm( + @Valid @RequestBody AdminTermCreateRequest request + ) { + AdminTermResponse response = adminTermService.createTerm(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PatchMapping("/{id}/activate") + public ResponseEntity activateTerm( + @PathVariable Long id + ) { + AdminTermResponse response = adminTermService.activateTerm(id); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermCreateRequest.java b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermCreateRequest.java new file mode 100644 index 000000000..0f4e40448 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermCreateRequest.java @@ -0,0 +1,11 @@ +package com.example.solidconnection.admin.term.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record AdminTermCreateRequest( + @NotBlank + @Pattern(regexp = "^\\d{4}-\\d$", message = "학기 이름은 'YYYY-N' 형태여야 합니다. (예: 2026-1)") + String name +) { +} diff --git a/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java new file mode 100644 index 000000000..7349a98df --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.admin.term.dto; + +import com.example.solidconnection.term.domain.Term; + +public record AdminTermResponse( + Long id, + String label, + boolean isCurrent +) { + + public static AdminTermResponse from(Term term) { + return new AdminTermResponse( + term.getId(), + term.getName(), + Boolean.TRUE.equals(term.getIsCurrent()) + ); + } +} diff --git a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java new file mode 100644 index 000000000..fee6d8b61 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -0,0 +1,49 @@ +package com.example.solidconnection.admin.term.service; + +import static com.example.solidconnection.common.exception.ErrorCode.TERM_ALREADY_EXISTS; +import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; + +import com.example.solidconnection.admin.term.dto.AdminTermCreateRequest; +import com.example.solidconnection.admin.term.dto.AdminTermResponse; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.term.domain.Term; +import com.example.solidconnection.term.repository.TermRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminTermService { + + private final TermRepository termRepository; + + @Transactional(readOnly = true) + public List getAllTerms() { + return termRepository.findAll() + .stream() + .map(AdminTermResponse::from) + .toList(); + } + + @Transactional + public AdminTermResponse createTerm(AdminTermCreateRequest request) { + termRepository.findByName(request.name()) + .ifPresent(t -> { + throw new CustomException(TERM_ALREADY_EXISTS); + }); + Term saved = termRepository.save(new Term(request.name(), false)); + return AdminTermResponse.from(saved); + } + + @Transactional + public AdminTermResponse activateTerm(Long id) { + Term target = termRepository.findById(id) + .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); + termRepository.findByIsCurrentTrue() + .ifPresent(Term::deactivate); + target.activate(); + return AdminTermResponse.from(target); + } +} diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 957b6f45a..3032c3efd 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -60,6 +60,7 @@ public enum ErrorCode { BLOCK_USER_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "차단 대상 사용자를 찾을 수 없습니다."), TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 학기입니다."), CURRENT_TERM_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "현재 학기를 찾을 수 없습니다."), + TERM_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 학기입니다."), MENTOR_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "멘토 지원서가 존재하지 않습니다."), REPORT_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "신고 내역이 존재하지 않습니다."), diff --git a/src/main/java/com/example/solidconnection/term/domain/Term.java b/src/main/java/com/example/solidconnection/term/domain/Term.java index 544c73db1..3c209d6e5 100644 --- a/src/main/java/com/example/solidconnection/term/domain/Term.java +++ b/src/main/java/com/example/solidconnection/term/domain/Term.java @@ -39,4 +39,12 @@ public Term(String name, boolean isCurrent) { this.name = name; this.isCurrent = isCurrent ? true : null; } + + public void activate() { + this.isCurrent = true; + } + + public void deactivate() { + this.isCurrent = null; + } } From 8eabacf032d18ba620359ef0cf5ae3a3dfefc50b Mon Sep 17 00:00:00 2001 From: whqtker Date: Mon, 15 Jun 2026 18:22:57 +0900 Subject: [PATCH 12/34] =?UTF-8?q?refactor:=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=ED=95=99=EA=B8=B0=20=EC=84=A4=EC=A0=95=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=AC=B4=EA=B2=B0=EC=84=B1=20=EC=A0=9C=EC=95=BD=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=9C=84=EB=B0=98=ED=95=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20Modifying=20=EC=BF=BC=EB=A6=AC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/term/service/AdminTermService.java | 3 +-- .../solidconnection/term/repository/TermRepository.java | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java index fee6d8b61..e0051f80e 100644 --- a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -41,8 +41,7 @@ public AdminTermResponse createTerm(AdminTermCreateRequest request) { public AdminTermResponse activateTerm(Long id) { Term target = termRepository.findById(id) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); - termRepository.findByIsCurrentTrue() - .ifPresent(Term::deactivate); + termRepository.deactivateCurrentTerm(); target.activate(); return AdminTermResponse.from(target); } diff --git a/src/main/java/com/example/solidconnection/term/repository/TermRepository.java b/src/main/java/com/example/solidconnection/term/repository/TermRepository.java index 763137dc9..06c993635 100644 --- a/src/main/java/com/example/solidconnection/term/repository/TermRepository.java +++ b/src/main/java/com/example/solidconnection/term/repository/TermRepository.java @@ -3,10 +3,16 @@ import com.example.solidconnection.term.domain.Term; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; public interface TermRepository extends JpaRepository { Optional findByIsCurrentTrue(); Optional findByName(String name); + + @Modifying + @Query("UPDATE Term t SET t.isCurrent = null WHERE t.isCurrent = true") + void deactivateCurrentTerm(); } From 275be81f4d0780ee57a2363e17361fa0d61be537 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 16 Jun 2026 09:38:09 +0900 Subject: [PATCH 13/34] =?UTF-8?q?refactor:=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=9E=84=EC=8B=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추후 제거 예정 --- .../service/AdminUnivApplyInfoRowSaver.java | 78 ++++++++++++++++--- .../domain/UnivApplyInfoColumn.java | 12 +++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 4125644fb..59199f491 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -1,13 +1,18 @@ package com.example.solidconnection.admin.university.service; -import static com.example.solidconnection.common.exception.ErrorCode.UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.COUNTRY_NOT_FOUND; import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.location.country.domain.Country; +import com.example.solidconnection.location.country.repository.CountryRepository; +import com.example.solidconnection.location.region.domain.Region; +import com.example.solidconnection.location.region.repository.RegionRepository; import com.example.solidconnection.university.domain.HomeUniversity; import com.example.solidconnection.university.domain.HostUniversity; import com.example.solidconnection.university.domain.LanguageRequirement; import com.example.solidconnection.university.domain.LanguageTestType; import com.example.solidconnection.university.domain.SemesterAvailableForDispatch; +import com.example.solidconnection.university.domain.TuitionFeeType; import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; @@ -25,6 +30,8 @@ public class AdminUnivApplyInfoRowSaver { private final HostUniversityRepository hostUniversityRepository; private final UnivApplyInfoRepository univApplyInfoRepository; + private final CountryRepository countryRepository; + private final RegionRepository regionRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public void save( @@ -39,9 +46,7 @@ public void save( throw new IllegalArgumentException("대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다"); } - HostUniversity hostUniversity = hostUniversityRepository - .findByKoreanName(data.universityKoreanName) - .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); + HostUniversity hostUniversity = findOrCreateHostUniversity(data); UnivApplyInfo univApplyInfo = new UnivApplyInfo( null, @@ -49,17 +54,17 @@ public void save( homeUniversity, data.universityKoreanName, data.studentCapacity, - null, + data.tuitionFeeType, data.semesterAvailableForDispatch, data.semesterRequirement, data.detailsForLanguage, data.gpaRequirement, data.gpaRequirementCriteria, - null, - null, + data.detailsForApply, + data.detailsForMajor, data.detailsForAccommodation, - null, - null, + data.detailsForEnglishCourse, + data.details, data.extraInfo.isEmpty() ? null : data.extraInfo, new HashSet<>(), hostUniversity @@ -73,6 +78,37 @@ public void save( }); } + private HostUniversity findOrCreateHostUniversity(ImportData data) { + return hostUniversityRepository.findByKoreanName(data.universityKoreanName) + .orElseGet(() -> createHostUniversity(data)); + } + + private HostUniversity createHostUniversity(ImportData data) { + if (data.countryCode == null || data.countryCode.isBlank()) { + throw new IllegalArgumentException( + "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); + } + + Country country = countryRepository.findByCode(data.countryCode) + .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); + Region region = regionRepository.findById(country.getRegionCode()).orElse(null); + + return hostUniversityRepository.save(new HostUniversity( + null, + data.universityKoreanName, + data.englishName != null ? data.englishName : "", + data.formatName != null ? data.formatName : "", + data.homepageUrl, + data.englishCourseUrl, + data.accommodationUrl, + "", + "", + data.detailsForLocal, + country, + region + )); + } + private ImportData buildImportData(Map rowData, Map columnMappings) { ImportData data = new ImportData(); rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); @@ -105,13 +141,25 @@ private boolean tryApplyStructuredField(ImportData data, String fieldName, Strin try { switch (fieldName) { case "universityKoreanName" -> data.universityKoreanName = value; + case "englishName" -> data.englishName = value; + case "formatName" -> data.formatName = value; + case "countryCode" -> data.countryCode = value; + case "homepageUrl" -> data.homepageUrl = value; + case "englishCourseUrl" -> data.englishCourseUrl = value; + case "accommodationUrl" -> data.accommodationUrl = value; + case "detailsForLocal" -> data.detailsForLocal = value; case "studentCapacity" -> data.studentCapacity = Integer.parseInt(value); + case "tuitionFeeType" -> data.tuitionFeeType = TuitionFeeType.valueOf(value); case "semesterAvailableForDispatch" -> data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); case "semesterRequirement" -> data.semesterRequirement = value; case "detailsForLanguage" -> data.detailsForLanguage = value; case "gpaRequirement" -> data.gpaRequirement = value; case "gpaRequirementCriteria" -> data.gpaRequirementCriteria = value; + case "detailsForApply" -> data.detailsForApply = value; + case "detailsForMajor" -> data.detailsForMajor = value; case "detailsForAccommodation" -> data.detailsForAccommodation = value; + case "detailsForEnglishCourse" -> data.detailsForEnglishCourse = value; + case "details" -> data.details = value; default -> { return false; } } return true; @@ -123,13 +171,25 @@ private boolean tryApplyStructuredField(ImportData data, String fieldName, Strin private static class ImportData { String universityKoreanName; + String englishName; + String formatName; + String countryCode; + String homepageUrl; + String englishCourseUrl; + String accommodationUrl; + String detailsForLocal; Integer studentCapacity; + TuitionFeeType tuitionFeeType; SemesterAvailableForDispatch semesterAvailableForDispatch; String semesterRequirement; String detailsForLanguage; String gpaRequirement; String gpaRequirementCriteria; + String detailsForApply; + String detailsForMajor; String detailsForAccommodation; + String detailsForEnglishCourse; + String details; Map extraInfo = new HashMap<>(); Map languageRequirements = new HashMap<>(); } diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java index eb8cd3151..b7aa8a6ff 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -11,13 +11,25 @@ public enum UnivApplyInfoColumn { UNIVERSITY_KOREAN_NAME("universityKoreanName", "대학명", "학교명", "대학교명"), + ENGLISH_NAME("englishName", "영문대학명", "영문명"), + FORMAT_NAME("formatName", "표시대학명", "포맷명"), + COUNTRY_CODE("countryCode", "국가코드"), + HOMEPAGE_URL("homepageUrl", "홈페이지URL", "홈페이지"), + ENGLISH_COURSE_URL("englishCourseUrl", "영어강좌URL"), + ACCOMMODATION_URL("accommodationUrl", "숙소URL"), + DETAILS_FOR_LOCAL("detailsForLocal", "현지정보"), STUDENT_CAPACITY("studentCapacity", "인원", "모집인원", "정원", "모집정원"), + TUITION_FEE_TYPE("tuitionFeeType", "등록금유형", "수업료유형"), SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch", "파견가능학기", "파견학기"), SEMESTER_REQUIREMENT("semesterRequirement", "학기요건", "재학학기"), DETAILS_FOR_LANGUAGE("detailsForLanguage", "어학사항", "어학요건상세"), GPA_REQUIREMENT("gpaRequirement", "성적요건", "학점요건", "최소학점"), GPA_REQUIREMENT_CRITERIA("gpaRequirementCriteria", "학점기준", "성적기준"), + DETAILS_FOR_APPLY("detailsForApply", "지원사항", "지원요건"), + DETAILS_FOR_MAJOR("detailsForMajor", "전공사항", "전공요건"), DETAILS_FOR_ACCOMMODATION("detailsForAccommodation", "숙소사항", "기숙사안내"), + DETAILS_FOR_ENGLISH_COURSE("detailsForEnglishCourse", "영어강의사항", "영어강의"), + DETAILS("details", "기타사항", "비고"), ; private final String fieldName; From 0ab661f4f50f47016bdf37744f743701a07aa7c8 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 16 Jun 2026 16:29:23 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat:=20host=5Funiversity=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=BB=AC=EB=9F=BC=20=EB=98=90=ED=95=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EB=B0=9B=EB=8F=84?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - host_university가 없는 경우 추가해줘야 한다. --- .../dto/UnivApplyInfoFieldResponse.java | 12 +--- .../dto/UnivApplyInfoImportResponse.java | 3 +- .../service/AdminUnivApplyInfoRowSaver.java | 6 +- .../service/AdminUnivApplyInfoService.java | 8 ++- .../domain/UnivApplyInfoColumn.java | 55 +++++++------------ 5 files changed, 36 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java index c5743ebba..8e2de76c8 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java @@ -6,19 +6,13 @@ import java.util.List; public record UnivApplyInfoFieldResponse( - List structuredFields, + List structuredFields, List languageTestTypes ) { - public record FieldInfo( - String field, - List aliases - ) { - } - public static UnivApplyInfoFieldResponse of() { - List fields = Arrays.stream(UnivApplyInfoColumn.values()) - .map(col -> new FieldInfo(col.getFieldName(), col.getAliases())) + List fields = Arrays.stream(UnivApplyInfoColumn.values()) + .map(UnivApplyInfoColumn::getFieldName) .toList(); List testTypes = Arrays.stream(LanguageTestType.values()) .map(Enum::name) diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java index 9edba0d7f..27b6abd36 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java @@ -4,7 +4,8 @@ public record UnivApplyInfoImportResponse( int successCount, - List failedRows + List failedRows, + List createdUniversities ) { public record FailedRow( diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 59199f491..ee2b2f7f3 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -34,7 +34,7 @@ public class AdminUnivApplyInfoRowSaver { private final RegionRepository regionRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) - public void save( + public String save( Map rowData, Map columnMappings, HomeUniversity homeUniversity, @@ -46,7 +46,9 @@ public void save( throw new IllegalArgumentException("대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다"); } + boolean existed = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); HostUniversity hostUniversity = findOrCreateHostUniversity(data); + String createdUniversityName = existed ? null : hostUniversity.getKoreanName(); UnivApplyInfo univApplyInfo = new UnivApplyInfo( null, @@ -76,6 +78,8 @@ public void save( LanguageRequirement lr = new LanguageRequirement(null, testType, minScore, saved); saved.addLanguageRequirements(lr); }); + + return createdUniversityName; } private HostUniversity findOrCreateHostUniversity(ImportData data) { diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index bdab46bec..c7b2261ce 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -41,17 +41,21 @@ public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportReque int successCount = 0; List failedRows = new ArrayList<>(); + List createdUniversities = new ArrayList<>(); for (int i = 0; i < rows.size(); i++) { try { - rowSaver.save(rows.get(i), request.columnMappings(), homeUniversity, request.termId()); + String createdName = rowSaver.save(rows.get(i), request.columnMappings(), homeUniversity, request.termId()); successCount++; + if (createdName != null) { + createdUniversities.add(createdName); + } } catch (Exception e) { failedRows.add(new FailedRow(i + 1, e.getMessage())); } } - return new UnivApplyInfoImportResponse(successCount, failedRows); + return new UnivApplyInfoImportResponse(successCount, failedRows, createdUniversities); } private void validateTermExists(Long termId) { diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java index b7aa8a6ff..033607dd0 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -1,8 +1,5 @@ package com.example.solidconnection.university.domain; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; import lombok.AllArgsConstructor; import lombok.Getter; @@ -10,39 +7,27 @@ @AllArgsConstructor public enum UnivApplyInfoColumn { - UNIVERSITY_KOREAN_NAME("universityKoreanName", "대학명", "학교명", "대학교명"), - ENGLISH_NAME("englishName", "영문대학명", "영문명"), - FORMAT_NAME("formatName", "표시대학명", "포맷명"), - COUNTRY_CODE("countryCode", "국가코드"), - HOMEPAGE_URL("homepageUrl", "홈페이지URL", "홈페이지"), - ENGLISH_COURSE_URL("englishCourseUrl", "영어강좌URL"), - ACCOMMODATION_URL("accommodationUrl", "숙소URL"), - DETAILS_FOR_LOCAL("detailsForLocal", "현지정보"), - STUDENT_CAPACITY("studentCapacity", "인원", "모집인원", "정원", "모집정원"), - TUITION_FEE_TYPE("tuitionFeeType", "등록금유형", "수업료유형"), - SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch", "파견가능학기", "파견학기"), - SEMESTER_REQUIREMENT("semesterRequirement", "학기요건", "재학학기"), - DETAILS_FOR_LANGUAGE("detailsForLanguage", "어학사항", "어학요건상세"), - GPA_REQUIREMENT("gpaRequirement", "성적요건", "학점요건", "최소학점"), - GPA_REQUIREMENT_CRITERIA("gpaRequirementCriteria", "학점기준", "성적기준"), - DETAILS_FOR_APPLY("detailsForApply", "지원사항", "지원요건"), - DETAILS_FOR_MAJOR("detailsForMajor", "전공사항", "전공요건"), - DETAILS_FOR_ACCOMMODATION("detailsForAccommodation", "숙소사항", "기숙사안내"), - DETAILS_FOR_ENGLISH_COURSE("detailsForEnglishCourse", "영어강의사항", "영어강의"), - DETAILS("details", "기타사항", "비고"), + UNIVERSITY_KOREAN_NAME("universityKoreanName"), + ENGLISH_NAME("englishName"), + FORMAT_NAME("formatName"), + COUNTRY_CODE("countryCode"), + HOMEPAGE_URL("homepageUrl"), + ENGLISH_COURSE_URL("englishCourseUrl"), + ACCOMMODATION_URL("accommodationUrl"), + DETAILS_FOR_LOCAL("detailsForLocal"), + STUDENT_CAPACITY("studentCapacity"), + TUITION_FEE_TYPE("tuitionFeeType"), + SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch"), + SEMESTER_REQUIREMENT("semesterRequirement"), + DETAILS_FOR_LANGUAGE("detailsForLanguage"), + GPA_REQUIREMENT("gpaRequirement"), + GPA_REQUIREMENT_CRITERIA("gpaRequirementCriteria"), + DETAILS_FOR_APPLY("detailsForApply"), + DETAILS_FOR_MAJOR("detailsForMajor"), + DETAILS_FOR_ACCOMMODATION("detailsForAccommodation"), + DETAILS_FOR_ENGLISH_COURSE("detailsForEnglishCourse"), + DETAILS("details"), ; private final String fieldName; - private final List aliases; - - UnivApplyInfoColumn(String fieldName, String... aliases) { - this.fieldName = fieldName; - this.aliases = List.of(aliases); - } - - public static Optional findByAlias(String header) { - return Arrays.stream(values()) - .filter(col -> col.aliases.contains(header)) - .findFirst(); - } } From 1e19e86164027dd59b25854c2f0a6af5d44cde30 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 00:31:04 +0900 Subject: [PATCH 15/34] =?UTF-8?q?fix:=20=EC=BB=AC=EB=9F=BC=EB=AA=85=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoRowSaver.java | 14 +++++++------- .../university/domain/UnivApplyInfoColumn.java | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index ee2b2f7f3..1930c6ed0 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -145,13 +145,13 @@ private boolean tryApplyStructuredField(ImportData data, String fieldName, Strin try { switch (fieldName) { case "universityKoreanName" -> data.universityKoreanName = value; - case "englishName" -> data.englishName = value; - case "formatName" -> data.formatName = value; - case "countryCode" -> data.countryCode = value; - case "homepageUrl" -> data.homepageUrl = value; - case "englishCourseUrl" -> data.englishCourseUrl = value; - case "accommodationUrl" -> data.accommodationUrl = value; - case "detailsForLocal" -> data.detailsForLocal = value; + case "universityEnglishName" -> data.englishName = value; + case "universityFormatName" -> data.formatName = value; + case "universityCountryCode" -> data.countryCode = value; + case "universityHomepageUrl" -> data.homepageUrl = value; + case "universityEnglishCourseUrl" -> data.englishCourseUrl = value; + case "universityAccommodationUrl" -> data.accommodationUrl = value; + case "universityDetailsForLocal" -> data.detailsForLocal = value; case "studentCapacity" -> data.studentCapacity = Integer.parseInt(value); case "tuitionFeeType" -> data.tuitionFeeType = TuitionFeeType.valueOf(value); case "semesterAvailableForDispatch" -> data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java index 033607dd0..85cb19aac 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -8,13 +8,13 @@ public enum UnivApplyInfoColumn { UNIVERSITY_KOREAN_NAME("universityKoreanName"), - ENGLISH_NAME("englishName"), - FORMAT_NAME("formatName"), - COUNTRY_CODE("countryCode"), - HOMEPAGE_URL("homepageUrl"), - ENGLISH_COURSE_URL("englishCourseUrl"), - ACCOMMODATION_URL("accommodationUrl"), - DETAILS_FOR_LOCAL("detailsForLocal"), + UNIVERSITY_ENGLISH_NAME("universityEnglishName"), + UNIVERSITY_FORMAT_NAME("universityFormatName"), + UNIVERSITY_COUNTRY_CODE("universityCountryCode"), + UNIVERSITY_HOMEPAGE_URL("universityHomepageUrl"), + UNIVERSITY_ENGLISH_COURSE_URL("universityEnglishCourseUrl"), + UNIVERSITY_ACCOMMODATION_URL("universityAccommodationUrl"), + UNIVERSITY_DETAILS_FOR_LOCAL("universityDetailsForLocal"), STUDENT_CAPACITY("studentCapacity"), TUITION_FEE_TYPE("tuitionFeeType"), SEMESTER_AVAILABLE_FOR_DISPATCH("semesterAvailableForDispatch"), From 22dec8cc0f21e2733a3dd7af4b55abf8719e3a55 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 11:52:40 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat:=20=EC=84=B8=EB=B6=80=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=BD=EC=9E=85=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=9D=B4=EC=9C=A0=EA=B9=8C=EC=A7=80=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/UnivApplyInfoImportResponse.java | 16 +- .../service/AdminUnivApplyInfoRowSaver.java | 140 ++++++++++++++++-- .../service/AdminUnivApplyInfoService.java | 2 + .../UnivApplyInfoImportFailureException.java | 22 +++ .../AdminUnivApplyInfoServiceTest.java | 80 ++++++++-- 5 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java index 27b6abd36..b51e83b19 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java @@ -10,7 +10,21 @@ public record UnivApplyInfoImportResponse( public record FailedRow( int rowNumber, - String reason + String reason, + List errors + ) { + + public FailedRow(int rowNumber, String reason) { + this(rowNumber, reason, List.of()); + } + } + + public record CellError( + String header, + String field, + String value, + String code, + String message ) { } } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 1930c6ed0..2c34e533b 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -2,7 +2,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.COUNTRY_NOT_FOUND; -import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.CellError; import com.example.solidconnection.location.country.domain.Country; import com.example.solidconnection.location.country.repository.CountryRepository; import com.example.solidconnection.location.region.domain.Region; @@ -16,8 +16,10 @@ import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -41,13 +43,10 @@ public String save( long termId ) { ImportData data = buildImportData(rowData, columnMappings); - - if (data.universityKoreanName == null || data.universityKoreanName.isBlank()) { - throw new IllegalArgumentException("대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다"); - } + validateImportData(data, rowData, columnMappings); boolean existed = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); - HostUniversity hostUniversity = findOrCreateHostUniversity(data); + HostUniversity hostUniversity = findOrCreateHostUniversity(data, rowData, columnMappings); String createdUniversityName = existed ? null : hostUniversity.getKoreanName(); UnivApplyInfo univApplyInfo = new UnivApplyInfo( @@ -82,19 +81,106 @@ public String save( return createdUniversityName; } - private HostUniversity findOrCreateHostUniversity(ImportData data) { + private void validateImportData( + ImportData data, + Map rowData, + Map columnMappings + ) { + List errors = new ArrayList<>(); + boolean universityKoreanNameBlank = data.universityKoreanName == null || data.universityKoreanName.isBlank(); + + if (universityKoreanNameBlank) { + errors.add(cellError( + rowData, + columnMappings, + "universityKoreanName", + "REQUIRED", + "대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다" + )); + } + + if (universityKoreanNameBlank) { + validateCountryCodeIfPresent(data, rowData, columnMappings, errors); + throwIfErrors(errors); + } + + boolean universityExists = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); + if (!universityExists && (data.countryCode == null || data.countryCode.isBlank())) { + errors.add(cellError( + rowData, + columnMappings, + "universityCountryCode", + "REQUIRED", + "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요." + )); + } + if (!universityExists) { + validateCountryCodeIfPresent(data, rowData, columnMappings, errors); + } + + throwIfErrors(errors); + } + + private void validateCountryCodeIfPresent( + ImportData data, + Map rowData, + Map columnMappings, + List errors + ) { + if (data.countryCode == null || data.countryCode.isBlank()) { + return; + } + if (countryRepository.findByCode(data.countryCode).isPresent()) { + return; + } + + errors.add(cellError( + rowData, + columnMappings, + "universityCountryCode", + "NOT_FOUND", + COUNTRY_NOT_FOUND.getMessage() + )); + } + + private void throwIfErrors(List errors) { + if (errors.isEmpty()) { + return; + } + + String message = errors.size() == 1 ? errors.get(0).message() : errors.size() + "개 컬럼에 문제가 있습니다."; + throw new UnivApplyInfoImportFailureException(message, errors); + } + + private HostUniversity findOrCreateHostUniversity( + ImportData data, + Map rowData, + Map columnMappings + ) { return hostUniversityRepository.findByKoreanName(data.universityKoreanName) - .orElseGet(() -> createHostUniversity(data)); + .orElseGet(() -> createHostUniversity(data, rowData, columnMappings)); } - private HostUniversity createHostUniversity(ImportData data) { + private HostUniversity createHostUniversity( + ImportData data, + Map rowData, + Map columnMappings + ) { if (data.countryCode == null || data.countryCode.isBlank()) { - throw new IllegalArgumentException( - "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); + throwFailure( + rowData, + columnMappings, + "universityCountryCode", + "REQUIRED", + "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요." + ); } Country country = countryRepository.findByCode(data.countryCode) - .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); + .orElseThrow(() -> new UnivApplyInfoImportFailureException( + COUNTRY_NOT_FOUND.getMessage(), + cellError(rowData, columnMappings, "universityCountryCode", "NOT_FOUND", COUNTRY_NOT_FOUND.getMessage()) + )); Region region = regionRepository.findById(country.getRegionCode()).orElse(null); return hostUniversityRepository.save(new HostUniversity( @@ -113,6 +199,36 @@ private HostUniversity createHostUniversity(ImportData data) { )); } + private void throwFailure( + Map rowData, + Map columnMappings, + String field, + String code, + String message + ) { + throw new UnivApplyInfoImportFailureException(message, cellError(rowData, columnMappings, field, code, message)); + } + + private CellError cellError( + Map rowData, + Map columnMappings, + String field, + String code, + String message + ) { + String header = findHeader(columnMappings, field); + String value = header == null ? null : rowData.get(header); + return new CellError(header, field, value, code, message); + } + + private String findHeader(Map columnMappings, String field) { + return columnMappings.entrySet().stream() + .filter(entry -> field.equals(entry.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + private ImportData buildImportData(Map rowData, Map columnMappings) { ImportData data = new ImportData(); rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index c7b2261ce..1b1367f16 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -50,6 +50,8 @@ public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportReque if (createdName != null) { createdUniversities.add(createdName); } + } catch (UnivApplyInfoImportFailureException e) { + failedRows.add(new FailedRow(i + 1, e.getMessage(), e.getErrors())); } catch (Exception e) { failedRows.add(new FailedRow(i + 1, e.getMessage())); } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java b/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java new file mode 100644 index 000000000..98e00b45a --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.admin.university.service; + +import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.CellError; +import java.util.List; + +public class UnivApplyInfoImportFailureException extends RuntimeException { + + private final List errors; + + public UnivApplyInfoImportFailureException(String message, CellError error) { + this(message, List.of(error)); + } + + public UnivApplyInfoImportFailureException(String message, List errors) { + super(message); + this.errors = errors; + } + + public List getErrors() { + return errors; + } +} diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index 915cb0f6f..ead5039a0 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -83,18 +83,6 @@ class 필드_목록을_조회한다 { ); } - @Test - void studentCapacity_필드에_인원_alias가_포함된다() { - // when - UnivApplyInfoFieldResponse response = adminUnivApplyInfoService.getFields(); - - // then - UnivApplyInfoFieldResponse.FieldInfo field = response.structuredFields().stream() - .filter(f -> "studentCapacity".equals(f.field())) - .findFirst() - .orElseThrow(); - assertThat(field.aliases()).contains("인원", "모집인원", "정원"); - } } @Nested @@ -238,10 +226,78 @@ class UnivApplyInfo를_임포트한다 { () -> assertThat(response.successCount()).isEqualTo(2), () -> assertThat(response.failedRows()).hasSize(1), () -> assertThat(response.failedRows().get(0).rowNumber()).isEqualTo(2), + () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { + assertThat(error.header()).isNull(); + assertThat(error.field()).isEqualTo("universityCountryCode"); + assertThat(error.value()).isNull(); + assertThat(error.code()).isEqualTo("REQUIRED"); + }), () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) ); } + @Test + void 실패한_셀의_원본_헤더와_값을_반환한다() { + // given + String markdown = """ + | 대학명 | 국가코드 | + |--------|----------| + | 새 대학교 | ZZ | + """; + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { + assertThat(error.header()).isEqualTo("국가코드"); + assertThat(error.field()).isEqualTo("universityCountryCode"); + assertThat(error.value()).isEqualTo("ZZ"); + assertThat(error.code()).isEqualTo("NOT_FOUND"); + }) + ); + } + + @Test + void 한_행의_검증_오류를_모두_반환한다() { + // given + String markdown = """ + | 대학명 | 국가코드 | + |--------|----------| + | | Belgium | + """; + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()) + .extracting("field") + .containsExactlyInAnyOrder("universityKoreanName", "universityCountryCode"), + () -> assertThat(response.failedRows().get(0).errors()) + .anySatisfy(error -> { + assertThat(error.header()).isEqualTo("국가코드"); + assertThat(error.field()).isEqualTo("universityCountryCode"); + assertThat(error.value()).isEqualTo("Belgium"); + assertThat(error.code()).isEqualTo("NOT_FOUND"); + }) + ); + } + @Test void 구분자_없는_마크다운이면_예외_응답을_반환한다() { // given From 6c34fbfbbe01759969b1c51f532c7e6718fd7f92 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 12:02:20 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat:=20=EC=85=80=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=C2=B7=EC=97=B4=EA=B1=B0=ED=98=95=C2=B7?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=EC=95=BD=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoRowSaver.java | 118 +++++++++++++----- 1 file changed, 87 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 2c34e533b..852bef262 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -17,10 +17,13 @@ import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -86,7 +89,7 @@ private void validateImportData( Map rowData, Map columnMappings ) { - List errors = new ArrayList<>(); + List errors = new ArrayList<>(data.parseErrors); boolean universityKoreanNameBlank = data.universityKoreanName == null || data.universityKoreanName.isBlank(); if (universityKoreanNameBlank) { @@ -252,42 +255,94 @@ private void applyField(ImportData data, String header, String value, Map data.universityKoreanName = value; - case "universityEnglishName" -> data.englishName = value; - case "universityFormatName" -> data.formatName = value; - case "universityCountryCode" -> data.countryCode = value; - case "universityHomepageUrl" -> data.homepageUrl = value; - case "universityEnglishCourseUrl" -> data.englishCourseUrl = value; - case "universityAccommodationUrl" -> data.accommodationUrl = value; - case "universityDetailsForLocal" -> data.detailsForLocal = value; - case "studentCapacity" -> data.studentCapacity = Integer.parseInt(value); - case "tuitionFeeType" -> data.tuitionFeeType = TuitionFeeType.valueOf(value); - case "semesterAvailableForDispatch" -> data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); - case "semesterRequirement" -> data.semesterRequirement = value; - case "detailsForLanguage" -> data.detailsForLanguage = value; - case "gpaRequirement" -> data.gpaRequirement = value; - case "gpaRequirementCriteria" -> data.gpaRequirementCriteria = value; - case "detailsForApply" -> data.detailsForApply = value; - case "detailsForMajor" -> data.detailsForMajor = value; - case "detailsForAccommodation" -> data.detailsForAccommodation = value; - case "detailsForEnglishCourse" -> data.detailsForEnglishCourse = value; - case "details" -> data.details = value; - default -> { return false; } + private void applyStructuredField(ImportData data, String header, String fieldName, String value) { + switch (fieldName) { + case "universityKoreanName" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.universityKoreanName = s); + case "universityEnglishName" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.englishName = s); + case "universityFormatName" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.formatName = s); + case "universityCountryCode" -> data.countryCode = value; + case "universityHomepageUrl" -> applyWithLength(data, header, fieldName, value, 500, + s -> data.homepageUrl = s); + case "universityEnglishCourseUrl" -> applyWithLength(data, header, fieldName, value, 500, + s -> data.englishCourseUrl = s); + case "universityAccommodationUrl" -> applyWithLength(data, header, fieldName, value, 500, + s -> data.accommodationUrl = s); + case "universityDetailsForLocal" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForLocal = s); + case "studentCapacity" -> { + try { + data.studentCapacity = Integer.parseInt(value); + } catch (NumberFormatException e) { + data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_FORMAT", + "선발 인원은 정수여야 합니다: '" + value + "'")); + } + } + case "tuitionFeeType" -> { + try { + data.tuitionFeeType = TuitionFeeType.valueOf(value); + } catch (IllegalArgumentException e) { + data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_VALUE", + "유효하지 않은 등록금 유형입니다. 가능한 값: " + validEnumValues(TuitionFeeType.values()))); + } } - return true; - } catch (IllegalArgumentException e) { - return false; + case "semesterAvailableForDispatch" -> { + try { + data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); + } catch (IllegalArgumentException e) { + data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_VALUE", + "유효하지 않은 파견 가능 학기입니다. 가능한 값: " + validEnumValues(SemesterAvailableForDispatch.values()))); + } + } + case "semesterRequirement" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.semesterRequirement = s); + case "detailsForLanguage" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForLanguage = s); + case "gpaRequirement" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.gpaRequirement = s); + case "gpaRequirementCriteria" -> applyWithLength(data, header, fieldName, value, 100, + s -> data.gpaRequirementCriteria = s); + case "detailsForApply" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForApply = s); + case "detailsForMajor" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForMajor = s); + case "detailsForAccommodation" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForAccommodation = s); + case "detailsForEnglishCourse" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.detailsForEnglishCourse = s); + case "details" -> applyWithLength(data, header, fieldName, value, 1000, + s -> data.details = s); + default -> data.extraInfo.put(header, value); } } + private void applyWithLength( + ImportData data, + String header, + String fieldName, + String value, + int maxLength, + Consumer setter + ) { + if (value.length() > maxLength) { + data.parseErrors.add(new CellError(header, fieldName, value, "TOO_LONG", + fieldName + " 값이 최대 길이(" + maxLength + "자)를 초과했습니다: " + value.length() + "자")); + return; + } + setter.accept(value); + } + + private String validEnumValues(Enum[] values) { + return Arrays.stream(values) + .map(Enum::name) + .collect(Collectors.joining(", ")); + } + private static class ImportData { String universityKoreanName; @@ -312,5 +367,6 @@ private static class ImportData { String details; Map extraInfo = new HashMap<>(); Map languageRequirements = new HashMap<>(); + List parseErrors = new ArrayList<>(); } } From b1608b486e1f832d5ce3cbd09df10e65a87cd6ab Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 12:03:45 +0900 Subject: [PATCH 18/34] =?UTF-8?q?test:=20enum=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=96=89=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EB=8F=99=EC=9E=91=EC=9C=BC=EB=A1=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoServiceTest.java | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index ead5039a0..0a8b8db03 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -114,7 +114,7 @@ class UnivApplyInfo를_임포트한다 { } @Test - void enum_변환_실패시_해당_컬럼은_extraInfo에_저장된다() { + void enum_변환_실패시_해당_행이_실패한다() { // given String markdown = String.format(""" | 대학명 | 파견가능학기 | @@ -130,12 +130,16 @@ class UnivApplyInfo를_임포트한다 { UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); // then - UnivApplyInfo saved = univApplyInfoRepository.findAll().get(0); assertAll( - () -> assertThat(response.successCount()).isEqualTo(1), - () -> assertThat(response.failedRows()).isEmpty(), - () -> assertThat(saved.getSemesterAvailableForDispatch()).isNull(), - () -> assertThat(saved.getExtraInfo()).containsEntry("파견가능학기", "알수없음") + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { + assertThat(error.header()).isEqualTo("파견가능학기"); + assertThat(error.field()).isEqualTo("semesterAvailableForDispatch"); + assertThat(error.value()).isEqualTo("알수없음"); + assertThat(error.code()).isEqualTo("INVALID_VALUE"); + }), + () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() ); } From 531bb26d8d224a17e049472872c96dd7e98bda56 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 12:04:54 +0900 Subject: [PATCH 19/34] =?UTF-8?q?test:=20=EC=85=80=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=C2=B7=EA=B8=B8=EC=9D=B4=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminUnivApplyInfoServiceTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index 0a8b8db03..ab4482428 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -350,5 +350,97 @@ class UnivApplyInfo를_임포트한다 { assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) .isInstanceOf(CustomException.class); } + + @Test + void 선발인원에_정수가_아닌_값이_들어오면_해당_행이_실패한다() { + // given + String markdown = String.format(""" + | 대학명 | 인원 | + |--------|------| + | %s | School of Business | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { + assertThat(error.header()).isEqualTo("인원"); + assertThat(error.field()).isEqualTo("studentCapacity"); + assertThat(error.value()).isEqualTo("School of Business"); + assertThat(error.code()).isEqualTo("INVALID_FORMAT"); + }), + () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() + ); + } + + @Test + void 길이_제한을_초과하는_값이_들어오면_해당_행이_실패한다() { + // given + String tooLongValue = "a".repeat(101); + String markdown = String.format(""" + | 대학명 | 학기요건 | + |--------|----------| + | %s | %s | + """, 괌_대학_한국명, tooLongValue); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "학기요건", "semesterRequirement") + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { + assertThat(error.header()).isEqualTo("학기요건"); + assertThat(error.field()).isEqualTo("semesterRequirement"); + assertThat(error.code()).isEqualTo("TOO_LONG"); + }), + () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() + ); + } + + @Test + void 한_행에_여러_검증_오류가_있으면_모두_반환한다() { + // given + String tooLong = "a".repeat(101); + String markdown = String.format(""" + | 대학명 | 인원 | 학기요건 | + |--------|------|----------| + | %s | 정수아님 | %s | + """, 괌_대학_한국명, tooLong); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of( + "대학명", "universityKoreanName", + "인원", "studentCapacity", + "학기요건", "semesterRequirement" + ) + ); + + // when + UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + assertAll( + () -> assertThat(response.successCount()).isZero(), + () -> assertThat(response.failedRows()).hasSize(1), + () -> assertThat(response.failedRows().get(0).errors()).hasSize(2), + () -> assertThat(response.failedRows().get(0).errors()) + .extracting(UnivApplyInfoImportResponse.CellError::code) + .containsExactlyInAnyOrder("INVALID_FORMAT", "TOO_LONG") + ); + } } } From a2ceae47030444e25dc6867c61b6d4dbeeb56454 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 14:08:02 +0900 Subject: [PATCH 20/34] =?UTF-8?q?fix:=20=EB=A7=88=ED=81=AC=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=ED=8C=8C=EC=84=9C=EC=97=90=EC=84=9C=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84=EB=90=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=20=EB=AC=B8=EC=9E=90=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/util/MarkdownTableParser.java | 4 ++-- .../common/util/MarkdownTableParserTest.java | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java index 82892d29a..f228e50c2 100644 --- a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java +++ b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java @@ -33,8 +33,8 @@ private List parseRow(String line) { String stripped = line.trim(); if (stripped.startsWith("|")) stripped = stripped.substring(1); if (stripped.endsWith("|")) stripped = stripped.substring(0, stripped.length() - 1); - return Arrays.stream(stripped.split("\\|")) - .map(String::trim) + return Arrays.stream(stripped.split("(? cell.replace("\\|", "|").trim()) .collect(Collectors.toList()); } diff --git a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java index 52d20b42b..41f58ff3d 100644 --- a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java +++ b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java @@ -56,6 +56,21 @@ class 정상_파싱 { .containsEntry("TOEIC", "800"); } + @Test + void 셀_내부의_이스케이프된_파이프는_컬럼_구분자가_아닌_값으로_처리된다() { + String markdown = """ + | 대학명 | 인원 | + |--------|------| + | RWTH Aachen \\| School of Business | 2 | + """; + + List> rows = parser.parse(markdown); + + assertThat(rows.get(0)) + .containsEntry("대학명", "RWTH Aachen | School of Business") + .containsEntry("인원", "2"); + } + @Test void 빈_셀은_결과_맵에_포함되지_않는다() { String markdown = """ From c5043256a1a26a3e7cfa51c4717bd35d12eac2f7 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 15:41:04 +0900 Subject: [PATCH 21/34] =?UTF-8?q?refactor:=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=99=20=EC=82=BD=EC=9E=85=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B0=81=20=EC=85=80=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/UnivApplyInfoImportResponse.java | 21 -- .../service/AdminUnivApplyInfoRowSaver.java | 211 +++--------------- .../service/AdminUnivApplyInfoService.java | 12 +- .../UnivApplyInfoImportFailureException.java | 22 -- 4 files changed, 37 insertions(+), 229 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java index b51e83b19..43eb10f09 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java @@ -4,27 +4,6 @@ public record UnivApplyInfoImportResponse( int successCount, - List failedRows, List createdUniversities ) { - - public record FailedRow( - int rowNumber, - String reason, - List errors - ) { - - public FailedRow(int rowNumber, String reason) { - this(rowNumber, reason, List.of()); - } - } - - public record CellError( - String header, - String field, - String value, - String code, - String message - ) { - } } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 852bef262..0f2610732 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -1,8 +1,9 @@ package com.example.solidconnection.admin.university.service; import static com.example.solidconnection.common.exception.ErrorCode.COUNTRY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.CellError; +import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.location.country.domain.Country; import com.example.solidconnection.location.country.repository.CountryRepository; import com.example.solidconnection.location.region.domain.Region; @@ -16,11 +17,9 @@ import com.example.solidconnection.university.domain.UnivApplyInfo; import com.example.solidconnection.university.repository.HostUniversityRepository; import com.example.solidconnection.university.repository.UnivApplyInfoRepository; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; -import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -46,10 +45,9 @@ public String save( long termId ) { ImportData data = buildImportData(rowData, columnMappings); - validateImportData(data, rowData, columnMappings); boolean existed = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); - HostUniversity hostUniversity = findOrCreateHostUniversity(data, rowData, columnMappings); + HostUniversity hostUniversity = findOrCreateHostUniversity(data); String createdUniversityName = existed ? null : hostUniversity.getKoreanName(); UnivApplyInfo univApplyInfo = new UnivApplyInfo( @@ -84,106 +82,19 @@ public String save( return createdUniversityName; } - private void validateImportData( - ImportData data, - Map rowData, - Map columnMappings - ) { - List errors = new ArrayList<>(data.parseErrors); - boolean universityKoreanNameBlank = data.universityKoreanName == null || data.universityKoreanName.isBlank(); - - if (universityKoreanNameBlank) { - errors.add(cellError( - rowData, - columnMappings, - "universityKoreanName", - "REQUIRED", - "대학명(universityKoreanName) 컬럼이 매핑되지 않았습니다" - )); - } - - if (universityKoreanNameBlank) { - validateCountryCodeIfPresent(data, rowData, columnMappings, errors); - throwIfErrors(errors); - } - - boolean universityExists = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); - if (!universityExists && (data.countryCode == null || data.countryCode.isBlank())) { - errors.add(cellError( - rowData, - columnMappings, - "universityCountryCode", - "REQUIRED", - "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요." - )); - } - if (!universityExists) { - validateCountryCodeIfPresent(data, rowData, columnMappings, errors); - } - - throwIfErrors(errors); - } - - private void validateCountryCodeIfPresent( - ImportData data, - Map rowData, - Map columnMappings, - List errors - ) { - if (data.countryCode == null || data.countryCode.isBlank()) { - return; - } - if (countryRepository.findByCode(data.countryCode).isPresent()) { - return; - } - - errors.add(cellError( - rowData, - columnMappings, - "universityCountryCode", - "NOT_FOUND", - COUNTRY_NOT_FOUND.getMessage() - )); - } - - private void throwIfErrors(List errors) { - if (errors.isEmpty()) { - return; - } - - String message = errors.size() == 1 ? errors.get(0).message() : errors.size() + "개 컬럼에 문제가 있습니다."; - throw new UnivApplyInfoImportFailureException(message, errors); - } - - private HostUniversity findOrCreateHostUniversity( - ImportData data, - Map rowData, - Map columnMappings - ) { + private HostUniversity findOrCreateHostUniversity(ImportData data) { return hostUniversityRepository.findByKoreanName(data.universityKoreanName) - .orElseGet(() -> createHostUniversity(data, rowData, columnMappings)); + .orElseGet(() -> createHostUniversity(data)); } - private HostUniversity createHostUniversity( - ImportData data, - Map rowData, - Map columnMappings - ) { + private HostUniversity createHostUniversity(ImportData data) { if (data.countryCode == null || data.countryCode.isBlank()) { - throwFailure( - rowData, - columnMappings, - "universityCountryCode", - "REQUIRED", - "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요." - ); + throw new CustomException(INVALID_INPUT, + "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); } Country country = countryRepository.findByCode(data.countryCode) - .orElseThrow(() -> new UnivApplyInfoImportFailureException( - COUNTRY_NOT_FOUND.getMessage(), - cellError(rowData, columnMappings, "universityCountryCode", "NOT_FOUND", COUNTRY_NOT_FOUND.getMessage()) - )); + .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); Region region = regionRepository.findById(country.getRegionCode()).orElse(null); return hostUniversityRepository.save(new HostUniversity( @@ -202,36 +113,6 @@ private HostUniversity createHostUniversity( )); } - private void throwFailure( - Map rowData, - Map columnMappings, - String field, - String code, - String message - ) { - throw new UnivApplyInfoImportFailureException(message, cellError(rowData, columnMappings, field, code, message)); - } - - private CellError cellError( - Map rowData, - Map columnMappings, - String field, - String code, - String message - ) { - String header = findHeader(columnMappings, field); - String value = header == null ? null : rowData.get(header); - return new CellError(header, field, value, code, message); - } - - private String findHeader(Map columnMappings, String field) { - return columnMappings.entrySet().stream() - .filter(entry -> field.equals(entry.getValue())) - .map(Map.Entry::getKey) - .findFirst() - .orElse(null); - } - private ImportData buildImportData(Map rowData, Map columnMappings) { ImportData data = new ImportData(); rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); @@ -260,79 +141,54 @@ private void applyField(ImportData data, String header, String value, Map applyWithLength(data, header, fieldName, value, 100, - s -> data.universityKoreanName = s); - case "universityEnglishName" -> applyWithLength(data, header, fieldName, value, 100, - s -> data.englishName = s); - case "universityFormatName" -> applyWithLength(data, header, fieldName, value, 100, - s -> data.formatName = s); + case "universityKoreanName" -> applyWithLength(value, 100, s -> data.universityKoreanName = s); + case "universityEnglishName" -> applyWithLength(value, 100, s -> data.englishName = s); + case "universityFormatName" -> applyWithLength(value, 100, s -> data.formatName = s); case "universityCountryCode" -> data.countryCode = value; - case "universityHomepageUrl" -> applyWithLength(data, header, fieldName, value, 500, - s -> data.homepageUrl = s); - case "universityEnglishCourseUrl" -> applyWithLength(data, header, fieldName, value, 500, - s -> data.englishCourseUrl = s); - case "universityAccommodationUrl" -> applyWithLength(data, header, fieldName, value, 500, - s -> data.accommodationUrl = s); - case "universityDetailsForLocal" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForLocal = s); + case "universityHomepageUrl" -> applyWithLength(value, 500, s -> data.homepageUrl = s); + case "universityEnglishCourseUrl" -> applyWithLength(value, 500, s -> data.englishCourseUrl = s); + case "universityAccommodationUrl" -> applyWithLength(value, 500, s -> data.accommodationUrl = s); + case "universityDetailsForLocal" -> applyWithLength(value, 1000, s -> data.detailsForLocal = s); case "studentCapacity" -> { try { data.studentCapacity = Integer.parseInt(value); } catch (NumberFormatException e) { - data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_FORMAT", - "선발 인원은 정수여야 합니다: '" + value + "'")); + throw new CustomException(INVALID_INPUT, "선발 인원은 정수여야 합니다: '" + value + "'"); } } case "tuitionFeeType" -> { try { data.tuitionFeeType = TuitionFeeType.valueOf(value); } catch (IllegalArgumentException e) { - data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_VALUE", - "유효하지 않은 등록금 유형입니다. 가능한 값: " + validEnumValues(TuitionFeeType.values()))); + throw new CustomException(INVALID_INPUT, + "유효하지 않은 등록금 유형입니다. 가능한 값: " + validEnumValues(TuitionFeeType.values())); } } case "semesterAvailableForDispatch" -> { try { data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); } catch (IllegalArgumentException e) { - data.parseErrors.add(new CellError(header, fieldName, value, "INVALID_VALUE", - "유효하지 않은 파견 가능 학기입니다. 가능한 값: " + validEnumValues(SemesterAvailableForDispatch.values()))); + throw new CustomException(INVALID_INPUT, + "유효하지 않은 파견 가능 학기입니다. 가능한 값: " + validEnumValues(SemesterAvailableForDispatch.values())); } } - case "semesterRequirement" -> applyWithLength(data, header, fieldName, value, 100, - s -> data.semesterRequirement = s); - case "detailsForLanguage" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForLanguage = s); - case "gpaRequirement" -> applyWithLength(data, header, fieldName, value, 100, - s -> data.gpaRequirement = s); - case "gpaRequirementCriteria" -> applyWithLength(data, header, fieldName, value, 100, - s -> data.gpaRequirementCriteria = s); - case "detailsForApply" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForApply = s); - case "detailsForMajor" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForMajor = s); - case "detailsForAccommodation" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForAccommodation = s); - case "detailsForEnglishCourse" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.detailsForEnglishCourse = s); - case "details" -> applyWithLength(data, header, fieldName, value, 1000, - s -> data.details = s); + case "semesterRequirement" -> applyWithLength(value, 100, s -> data.semesterRequirement = s); + case "detailsForLanguage" -> applyWithLength(value, 1000, s -> data.detailsForLanguage = s); + case "gpaRequirement" -> applyWithLength(value, 100, s -> data.gpaRequirement = s); + case "gpaRequirementCriteria" -> applyWithLength(value, 100, s -> data.gpaRequirementCriteria = s); + case "detailsForApply" -> applyWithLength(value, 1000, s -> data.detailsForApply = s); + case "detailsForMajor" -> applyWithLength(value, 1000, s -> data.detailsForMajor = s); + case "detailsForAccommodation" -> applyWithLength(value, 1000, s -> data.detailsForAccommodation = s); + case "detailsForEnglishCourse" -> applyWithLength(value, 1000, s -> data.detailsForEnglishCourse = s); + case "details" -> applyWithLength(value, 1000, s -> data.details = s); default -> data.extraInfo.put(header, value); } } - private void applyWithLength( - ImportData data, - String header, - String fieldName, - String value, - int maxLength, - Consumer setter - ) { + private void applyWithLength(String value, int maxLength, Consumer setter) { if (value.length() > maxLength) { - data.parseErrors.add(new CellError(header, fieldName, value, "TOO_LONG", - fieldName + " 값이 최대 길이(" + maxLength + "자)를 초과했습니다: " + value.length() + "자")); - return; + throw new CustomException(INVALID_INPUT, + "값이 최대 길이(" + maxLength + "자)를 초과했습니다: " + value.length() + "자"); } setter.accept(value); } @@ -367,6 +223,5 @@ private static class ImportData { String details; Map extraInfo = new HashMap<>(); Map languageRequirements = new HashMap<>(); - List parseErrors = new ArrayList<>(); } } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index 1b1367f16..25abcdf4d 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -6,7 +6,6 @@ import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.FailedRow; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.util.MarkdownTableParser; import com.example.solidconnection.term.repository.TermRepository; @@ -40,24 +39,21 @@ public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportReque List> rows = markdownTableParser.parse(request.markdown()); int successCount = 0; - List failedRows = new ArrayList<>(); List createdUniversities = new ArrayList<>(); - for (int i = 0; i < rows.size(); i++) { + for (Map row : rows) { try { - String createdName = rowSaver.save(rows.get(i), request.columnMappings(), homeUniversity, request.termId()); + String createdName = rowSaver.save(row, request.columnMappings(), homeUniversity, request.termId()); successCount++; if (createdName != null) { createdUniversities.add(createdName); } - } catch (UnivApplyInfoImportFailureException e) { - failedRows.add(new FailedRow(i + 1, e.getMessage(), e.getErrors())); } catch (Exception e) { - failedRows.add(new FailedRow(i + 1, e.getMessage())); + // row failed, skip } } - return new UnivApplyInfoImportResponse(successCount, failedRows, createdUniversities); + return new UnivApplyInfoImportResponse(successCount, createdUniversities); } private void validateTermExists(Long termId) { diff --git a/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java b/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java deleted file mode 100644 index 98e00b45a..000000000 --- a/src/main/java/com/example/solidconnection/admin/university/service/UnivApplyInfoImportFailureException.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.solidconnection.admin.university.service; - -import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse.CellError; -import java.util.List; - -public class UnivApplyInfoImportFailureException extends RuntimeException { - - private final List errors; - - public UnivApplyInfoImportFailureException(String message, CellError error) { - this(message, List.of(error)); - } - - public UnivApplyInfoImportFailureException(String message, List errors) { - super(message); - this.errors = errors; - } - - public List getErrors() { - return errors; - } -} From 6046b3f5742edc3258e71930db74794aec65e1db Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 15:50:35 +0900 Subject: [PATCH 22/34] =?UTF-8?q?test:=20=EA=B0=81=20=EC=85=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A0=9C=EA=B1=B0=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminUnivApplyInfoServiceTest.java | 70 ++----------------- 1 file changed, 6 insertions(+), 64 deletions(-) diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index ab4482428..33c0d0713 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -108,7 +108,6 @@ class UnivApplyInfo를_임포트한다 { // then assertAll( () -> assertThat(response.successCount()).isEqualTo(2), - () -> assertThat(response.failedRows()).isEmpty(), () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) ); } @@ -132,13 +131,6 @@ class UnivApplyInfo를_임포트한다 { // then assertAll( () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { - assertThat(error.header()).isEqualTo("파견가능학기"); - assertThat(error.field()).isEqualTo("semesterAvailableForDispatch"); - assertThat(error.value()).isEqualTo("알수없음"); - assertThat(error.code()).isEqualTo("INVALID_VALUE"); - }), () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() ); } @@ -228,20 +220,12 @@ class UnivApplyInfo를_임포트한다 { // then assertAll( () -> assertThat(response.successCount()).isEqualTo(2), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).rowNumber()).isEqualTo(2), - () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { - assertThat(error.header()).isNull(); - assertThat(error.field()).isEqualTo("universityCountryCode"); - assertThat(error.value()).isNull(); - assertThat(error.code()).isEqualTo("REQUIRED"); - }), () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) ); } @Test - void 실패한_셀의_원본_헤더와_값을_반환한다() { + void 존재하지_않는_국가코드면_행이_실패한다() { // given String markdown = """ | 대학명 | 국가코드 | @@ -257,20 +241,11 @@ class UnivApplyInfo를_임포트한다 { UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { - assertThat(error.header()).isEqualTo("국가코드"); - assertThat(error.field()).isEqualTo("universityCountryCode"); - assertThat(error.value()).isEqualTo("ZZ"); - assertThat(error.code()).isEqualTo("NOT_FOUND"); - }) - ); + assertThat(response.successCount()).isZero(); } @Test - void 한_행의_검증_오류를_모두_반환한다() { + void 대학명이_비어있으면_행이_실패한다() { // given String markdown = """ | 대학명 | 국가코드 | @@ -286,20 +261,7 @@ class UnivApplyInfo를_임포트한다 { UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()) - .extracting("field") - .containsExactlyInAnyOrder("universityKoreanName", "universityCountryCode"), - () -> assertThat(response.failedRows().get(0).errors()) - .anySatisfy(error -> { - assertThat(error.header()).isEqualTo("국가코드"); - assertThat(error.field()).isEqualTo("universityCountryCode"); - assertThat(error.value()).isEqualTo("Belgium"); - assertThat(error.code()).isEqualTo("NOT_FOUND"); - }) - ); + assertThat(response.successCount()).isZero(); } @Test @@ -370,13 +332,6 @@ class UnivApplyInfo를_임포트한다 { // then assertAll( () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { - assertThat(error.header()).isEqualTo("인원"); - assertThat(error.field()).isEqualTo("studentCapacity"); - assertThat(error.value()).isEqualTo("School of Business"); - assertThat(error.code()).isEqualTo("INVALID_FORMAT"); - }), () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() ); } @@ -401,18 +356,12 @@ class UnivApplyInfo를_임포트한다 { // then assertAll( () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()).singleElement().satisfies(error -> { - assertThat(error.header()).isEqualTo("학기요건"); - assertThat(error.field()).isEqualTo("semesterRequirement"); - assertThat(error.code()).isEqualTo("TOO_LONG"); - }), () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() ); } @Test - void 한_행에_여러_검증_오류가_있으면_모두_반환한다() { + void 파싱_오류가_있는_행은_실패_처리된다() { // given String tooLong = "a".repeat(101); String markdown = String.format(""" @@ -433,14 +382,7 @@ class UnivApplyInfo를_임포트한다 { UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(response.failedRows()).hasSize(1), - () -> assertThat(response.failedRows().get(0).errors()).hasSize(2), - () -> assertThat(response.failedRows().get(0).errors()) - .extracting(UnivApplyInfoImportResponse.CellError::code) - .containsExactlyInAnyOrder("INVALID_FORMAT", "TOO_LONG") - ); + assertThat(response.successCount()).isZero(); } } } From 1da7f46534faca938a1e8acb068e09e08910c1e0 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 16:41:13 +0900 Subject: [PATCH 23/34] =?UTF-8?q?refactor:=20=ED=95=99=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EA=B4=80=EB=A0=A8=20=EB=B3=80=EC=88=98?= =?UTF-8?q?=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - label -> name --- .../solidconnection/admin/term/dto/AdminTermResponse.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java index 7349a98df..583eb4f50 100644 --- a/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java +++ b/src/main/java/com/example/solidconnection/admin/term/dto/AdminTermResponse.java @@ -4,7 +4,7 @@ public record AdminTermResponse( Long id, - String label, + String name, boolean isCurrent ) { From d375d26572d4ebc73c0040d5222a852702a47538 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 16:44:48 +0900 Subject: [PATCH 24/34] =?UTF-8?q?refactor:=20=EB=B6=88=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=95=9C=20=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 활성화할 학기에 대한 변수 이름을 termToActivate로 수정한다. --- .../admin/term/service/AdminTermService.java | 6 +++--- .../admin/university/dto/UnivApplyInfoFieldResponse.java | 2 +- .../university/service/AdminUnivApplyInfoServiceTest.java | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java index e0051f80e..b1be8e127 100644 --- a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -39,10 +39,10 @@ public AdminTermResponse createTerm(AdminTermCreateRequest request) { @Transactional public AdminTermResponse activateTerm(Long id) { - Term target = termRepository.findById(id) + Term termToActivate = termRepository.findById(id) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); termRepository.deactivateCurrentTerm(); - target.activate(); - return AdminTermResponse.from(target); + termToActivate.activate(); + return AdminTermResponse.from(termToActivate); } } diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java index 8e2de76c8..3b4f71197 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java @@ -6,7 +6,7 @@ import java.util.List; public record UnivApplyInfoFieldResponse( - List structuredFields, + List fields, List languageTestTypes ) { diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index 33c0d0713..e5bf333e4 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -74,7 +74,7 @@ class 필드_목록을_조회한다 { // then assertAll( - () -> assertThat(response.structuredFields()) + () -> assertThat(response.fields()) .hasSize(UnivApplyInfoColumn.values().length), () -> assertThat(response.languageTestTypes()) .containsExactlyInAnyOrderElementsOf( From cc679f7ed13424b4cd8e97100cac53e5f7de5892 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 17:01:49 +0900 Subject: [PATCH 25/34] =?UTF-8?q?refactor:=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EB=8C=80=EC=8B=A0=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoRowSaver.java | 3 +- .../service/AdminUnivApplyInfoService.java | 15 ++-- .../AdminUnivApplyInfoServiceTest.java | 89 ++++++++----------- 3 files changed, 41 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 0f2610732..2e7271823 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -25,7 +25,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -37,7 +36,7 @@ public class AdminUnivApplyInfoRowSaver { private final CountryRepository countryRepository; private final RegionRepository regionRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public String save( Map rowData, Map columnMappings, diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index 25abcdf4d..db8030dd8 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -38,22 +39,16 @@ public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportReque List> rows = markdownTableParser.parse(request.markdown()); - int successCount = 0; List createdUniversities = new ArrayList<>(); for (Map row : rows) { - try { - String createdName = rowSaver.save(row, request.columnMappings(), homeUniversity, request.termId()); - successCount++; - if (createdName != null) { - createdUniversities.add(createdName); - } - } catch (Exception e) { - // row failed, skip + String createdName = rowSaver.save(row, request.columnMappings(), homeUniversity, request.termId()); + if (createdName != null) { + createdUniversities.add(createdName); } } - return new UnivApplyInfoImportResponse(successCount, createdUniversities); + return new UnivApplyInfoImportResponse(rows.size(), createdUniversities); } private void validateTermExists(Long termId) { diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index e5bf333e4..be7da772d 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -113,7 +113,7 @@ class UnivApplyInfo를_임포트한다 { } @Test - void enum_변환_실패시_해당_행이_실패한다() { + void enum_변환_실패시_전체가_실패한다() { // given String markdown = String.format(""" | 대학명 | 파견가능학기 | @@ -125,14 +125,10 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "파견가능학기", "semesterAvailableForDispatch") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() - ); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test @@ -200,7 +196,7 @@ class UnivApplyInfo를_임포트한다 { } @Test - void 존재하지_않는_대학명_행만_실패하고_나머지는_저장된다() { + void 존재하지_않는_대학명이_있으면_전체가_실패한다() { // given String markdown = String.format(""" | 대학명 | 인원 | @@ -214,18 +210,14 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertAll( - () -> assertThat(response.successCount()).isEqualTo(2), - () -> assertThat(univApplyInfoRepository.findAll()).hasSize(2) - ); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test - void 존재하지_않는_국가코드면_행이_실패한다() { + void 존재하지_않는_국가코드면_전체가_실패한다() { // given String markdown = """ | 대학명 | 국가코드 | @@ -237,15 +229,14 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertThat(response.successCount()).isZero(); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test - void 대학명이_비어있으면_행이_실패한다() { + void 대학명이_비어있으면_전체가_실패한다() { // given String markdown = """ | 대학명 | 국가코드 | @@ -257,11 +248,10 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertThat(response.successCount()).isZero(); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test @@ -314,7 +304,7 @@ class UnivApplyInfo를_임포트한다 { } @Test - void 선발인원에_정수가_아닌_값이_들어오면_해당_행이_실패한다() { + void 선발인원에_정수가_아닌_값이_들어오면_전체가_실패한다() { // given String markdown = String.format(""" | 대학명 | 인원 | @@ -326,18 +316,14 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() - ); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test - void 길이_제한을_초과하는_값이_들어오면_해당_행이_실패한다() { + void 길이_제한을_초과하는_값이_들어오면_전체가_실패한다() { // given String tooLongValue = "a".repeat(101); String markdown = String.format(""" @@ -350,18 +336,14 @@ class UnivApplyInfo를_임포트한다 { Map.of("대학명", "universityKoreanName", "학기요건", "semesterRequirement") ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertAll( - () -> assertThat(response.successCount()).isZero(), - () -> assertThat(univApplyInfoRepository.findAll()).isEmpty() - ); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } @Test - void 파싱_오류가_있는_행은_실패_처리된다() { + void 파싱_오류가_있는_행이_있으면_전체가_실패한다() { // given String tooLong = "a".repeat(101); String markdown = String.format(""" @@ -378,11 +360,10 @@ class UnivApplyInfo를_임포트한다 { ) ); - // when - UnivApplyInfoImportResponse response = adminUnivApplyInfoService.importUnivApplyInfos(request); - - // then - assertThat(response.successCount()).isZero(); + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); } } } From a52ec48ddade35c9135279207d59896d215e213a Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 19:42:54 +0900 Subject: [PATCH 26/34] =?UTF-8?q?chore:=20=EA=B5=AD=EA=B0=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index babb07cb6..54e7b036a 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -16,14 +16,18 @@ VALUES ('BN', '브루나이', 'ASIA'), ('CA', '캐나다', 'AMERICAS'), ('AU', '호주', 'ASIA'), ('BR', '브라질', 'AMERICAS'), + ('CO', '콜롬비아', 'AMERICAS'), ('NL', '네덜란드', 'EUROPE'), ('NO', '노르웨이', 'EUROPE'), + ('BE', '벨기에', 'EUROPE'), ('DK', '덴마크', 'EUROPE'), ('DE', '독일', 'EUROPE'), + ('EE', '에스토니아', 'EUROPE'), ('SE', '스웨덴', 'EUROPE'), ('CH', '스위스', 'EUROPE'), ('ES', '스페인', 'EUROPE'), ('GB', '영국', 'EUROPE'), + ('IE', '아일랜드', 'EUROPE'), ('AT', '오스트리아', 'EUROPE'), ('IT', '이탈리아', 'EUROPE'), ('CZ', '체코', 'EUROPE'), From 01f98c2c6f643c733764f7f8bc913e6b73461614 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 19:43:47 +0900 Subject: [PATCH 27/34] =?UTF-8?q?refactor:=20=EB=B9=84=EC=96=B4=EC=9E=88?= =?UTF-8?q?=EB=8A=94=20=ED=82=A4-=EA=B0=92=EC=97=90=20=EB=8C=80=ED=95=9C?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/dto/UnivApplyInfoImportRequest.java | 3 ++- .../university/service/AdminUnivApplyInfoService.java | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java index 0cde5eb80..e28dec9e2 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.university.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.util.Map; @@ -14,7 +15,7 @@ public record UnivApplyInfoImportRequest( @NotBlank(message = "마크다운 텍스트는 필수입니다") String markdown, - @NotNull(message = "컬럼은 필수입니다") + @NotEmpty(message = "컬럼은 필수입니다") Map columnMappings ) { } diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index db8030dd8..c4a0a1603 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.university.service; import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_INPUT; import static com.example.solidconnection.common.exception.ErrorCode.TERM_NOT_FOUND; import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; @@ -34,6 +35,7 @@ public UnivApplyInfoFieldResponse getFields() { @Transactional public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportRequest request) { + validateColumnMappings(request.columnMappings()); validateTermExists(request.termId()); HomeUniversity homeUniversity = findHomeUniversity(request.homeUniversityId()); @@ -51,6 +53,14 @@ public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportReque return new UnivApplyInfoImportResponse(rows.size(), createdUniversities); } + private void validateColumnMappings(Map columnMappings) { + boolean hasBlankEntry = columnMappings.entrySet().stream() + .anyMatch(e -> e.getKey().isBlank() || e.getValue().isBlank()); + if (hasBlankEntry) { + throw new CustomException(INVALID_INPUT, "컬럼 매핑의 키와 값은 공백일 수 없습니다"); + } + } + private void validateTermExists(Long termId) { termRepository.findById(termId) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); From 4fe41a7184eddf4bf1dba75e3b416df2460dda8d Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 20:05:46 +0900 Subject: [PATCH 28/34] =?UTF-8?q?refactor:=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 영문 대학명: 200자 - 기숙사, 어학 세부 조건: 2000자 --- .../dto/AdminHostUniversityCreateRequest.java | 2 +- .../dto/AdminHostUniversityUpdateRequest.java | 2 +- .../service/AdminUnivApplyInfoRowSaver.java | 12 ++++++------ .../university/domain/HostUniversity.java | 2 +- .../university/domain/UnivApplyInfo.java | 10 +++++----- .../db/migration/V51__expand_column_length.sql | 9 +++++++++ 6 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 src/main/resources/db/migration/V51__expand_column_length.sql diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java index 6b77061b6..01b8c4e24 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityCreateRequest.java @@ -9,7 +9,7 @@ public record AdminHostUniversityCreateRequest( String koreanName, @NotBlank(message = "영문 대학명은 필수입니다") - @Size(max = 100, message = "영문 대학명은 100자 이하여야 합니다") + @Size(max = 200, message = "영문 대학명은 200자 이하여야 합니다") String englishName, @NotBlank(message = "표시 대학명은 필수입니다") diff --git a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java index cb2e64a74..0e75d846c 100644 --- a/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java +++ b/src/main/java/com/example/solidconnection/admin/university/dto/AdminHostUniversityUpdateRequest.java @@ -9,7 +9,7 @@ public record AdminHostUniversityUpdateRequest( String koreanName, @NotBlank(message = "영문 대학명은 필수입니다") - @Size(max = 100, message = "영문 대학명은 100자 이하여야 합니다") + @Size(max = 200, message = "영문 대학명은 200자 이하여야 합니다") String englishName, @NotBlank(message = "표시 대학명은 필수입니다") diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index 2e7271823..e4aa8feca 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -141,7 +141,7 @@ private void applyField(ImportData data, String header, String value, Map applyWithLength(value, 100, s -> data.universityKoreanName = s); - case "universityEnglishName" -> applyWithLength(value, 100, s -> data.englishName = s); + case "universityEnglishName" -> applyWithLength(value, 200, s -> data.englishName = s); case "universityFormatName" -> applyWithLength(value, 100, s -> data.formatName = s); case "universityCountryCode" -> data.countryCode = value; case "universityHomepageUrl" -> applyWithLength(value, 500, s -> data.homepageUrl = s); @@ -172,14 +172,14 @@ private void applyStructuredField(ImportData data, String header, String fieldNa } } case "semesterRequirement" -> applyWithLength(value, 100, s -> data.semesterRequirement = s); - case "detailsForLanguage" -> applyWithLength(value, 1000, s -> data.detailsForLanguage = s); + case "detailsForLanguage" -> applyWithLength(value, 2000, s -> data.detailsForLanguage = s); case "gpaRequirement" -> applyWithLength(value, 100, s -> data.gpaRequirement = s); case "gpaRequirementCriteria" -> applyWithLength(value, 100, s -> data.gpaRequirementCriteria = s); - case "detailsForApply" -> applyWithLength(value, 1000, s -> data.detailsForApply = s); - case "detailsForMajor" -> applyWithLength(value, 1000, s -> data.detailsForMajor = s); - case "detailsForAccommodation" -> applyWithLength(value, 1000, s -> data.detailsForAccommodation = s); + case "detailsForApply" -> applyWithLength(value, 3000, s -> data.detailsForApply = s); + case "detailsForMajor" -> applyWithLength(value, 3000, s -> data.detailsForMajor = s); + case "detailsForAccommodation" -> applyWithLength(value, 2000, s -> data.detailsForAccommodation = s); case "detailsForEnglishCourse" -> applyWithLength(value, 1000, s -> data.detailsForEnglishCourse = s); - case "details" -> applyWithLength(value, 1000, s -> data.details = s); + case "details" -> applyWithLength(value, 3000, s -> data.details = s); default -> data.extraInfo.put(header, value); } } diff --git a/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java b/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java index 6f816a23c..edab5c240 100644 --- a/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java +++ b/src/main/java/com/example/solidconnection/university/domain/HostUniversity.java @@ -28,7 +28,7 @@ public class HostUniversity extends BaseEntity { @Column(name = "korean_name", nullable = false, unique = true, length = 100) private String koreanName; - @Column(name = "english_name", nullable = false, length = 100) + @Column(name = "english_name", nullable = false, length = 200) private String englishName; @Column(name = "format_name", nullable = false, length = 100) diff --git a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java index 1b245d410..0c5486b7c 100644 --- a/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfo.java @@ -62,7 +62,7 @@ public class UnivApplyInfo extends BaseEntity { @Column(name = "semester_requirement", length = 100) private String semesterRequirement; - @Column(name = "details_for_language", length = 1000) + @Column(name = "details_for_language", length = 2000) private String detailsForLanguage; @Column(name = "gpa_requirement", length = 100) @@ -71,19 +71,19 @@ public class UnivApplyInfo extends BaseEntity { @Column(name = "gpa_requirement_criteria", length = 100) private String gpaRequirementCriteria; - @Column(name = "details_for_apply", length = 1000) + @Column(name = "details_for_apply", length = 3000) private String detailsForApply; - @Column(name = "details_for_major", length = 1000) + @Column(name = "details_for_major", length = 3000) private String detailsForMajor; - @Column(name = "details_for_accommodation", length = 1000) + @Column(name = "details_for_accommodation", length = 2000) private String detailsForAccommodation; @Column(name = "details_for_english_course", length = 1000) private String detailsForEnglishCourse; - @Column(name = "details", length = 1000) + @Column(name = "details", length = 3000) private String details; @JdbcTypeCode(SqlTypes.JSON) diff --git a/src/main/resources/db/migration/V51__expand_column_length.sql b/src/main/resources/db/migration/V51__expand_column_length.sql new file mode 100644 index 000000000..b7de68862 --- /dev/null +++ b/src/main/resources/db/migration/V51__expand_column_length.sql @@ -0,0 +1,9 @@ +ALTER TABLE host_university + MODIFY COLUMN english_name VARCHAR(200) NOT NULL; + +ALTER TABLE university_info_for_apply + MODIFY COLUMN details_for_language VARCHAR(2000), + MODIFY COLUMN details_for_accommodation VARCHAR(2000), + MODIFY COLUMN details_for_apply VARCHAR(3000), + MODIFY COLUMN details_for_major VARCHAR(3000), + MODIFY COLUMN details VARCHAR(3000); From c8cc14165fd52b430c6c45396a5616c24fcafea6 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 23:38:35 +0900 Subject: [PATCH 29/34] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/term/service/AdminTermService.java | 4 +++- .../service/ApplicationSubmissionService.java | 11 +++++++---- .../solidconnection/common/exception/ErrorCode.java | 1 + .../common/util/MarkdownTableParser.java | 2 ++ .../custom/SiteUserFilterRepositoryImpl.java | 2 ++ .../db/migration/V51__expand_column_length.sql | 4 ++++ 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java index b1be8e127..2336b8922 100644 --- a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -41,7 +41,9 @@ public AdminTermResponse createTerm(AdminTermCreateRequest request) { public AdminTermResponse activateTerm(Long id) { Term termToActivate = termRepository.findById(id) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); - termRepository.deactivateCurrentTerm(); + if (!Boolean.TRUE.equals(termToActivate.getIsCurrent())) { + termRepository.deactivateCurrentTerm(); + } termToActivate.activate(); return AdminTermResponse.from(termToActivate); } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index 569b4f5b5..b87f4119c 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -7,6 +7,7 @@ import static com.example.solidconnection.common.exception.ErrorCode.INVALID_GPA_SCORE_STATUS; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE; import static com.example.solidconnection.common.exception.ErrorCode.INVALID_LANGUAGE_TEST_SCORE_STATUS; +import static com.example.solidconnection.common.exception.ErrorCode.NICKNAME_FOR_APPLY_GENERATE_FAILED; import static com.example.solidconnection.common.exception.ErrorCode.UNIV_APPLY_INFO_NOT_FOUND; import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; @@ -147,10 +148,12 @@ private void validateUpdateLimitNotExceed(Application application) { } private String getRandomNickname() { - String randomNickname = NicknameCreator.createRandomNickname(); - while (applicationRepository.existsByNicknameForApply(randomNickname)) { - randomNickname = NicknameCreator.createRandomNickname(); + for (int attempt = 0; attempt < 10; attempt++) { + String candidate = NicknameCreator.createRandomNickname(); + if (!applicationRepository.existsByNicknameForApply(candidate)) { + return candidate; + } } - return randomNickname; + throw new CustomException(NICKNAME_FOR_APPLY_GENERATE_FAILED); } } diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 3032c3efd..136f484fe 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -101,6 +101,7 @@ public enum ErrorCode { DUPLICATE_UNIV_APPLY_INFO_CHOICE(HttpStatus.BAD_REQUEST.value(), "지망 선택이 중복되었습니다."), INVALID_UNIV_APPLY_INFO_CHOICE(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 지망 대학교가 포함되어 있습니다."), CHOICE_COUNT_EXCEEDS_LIMIT(HttpStatus.BAD_REQUEST.value(), "지망 수가 최대 지망 수를 초과했습니다."), + NICKNAME_FOR_APPLY_GENERATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "지원서 닉네임 생성에 실패했습니다. 잠시 후 다시 시도해 주세요."), // community INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), diff --git a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java index f228e50c2..fc3d91eb9 100644 --- a/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java +++ b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java @@ -19,7 +19,9 @@ public List> parse(String markdown) { List headers = parseRow(lines[0]); return Arrays.stream(lines) .skip(2) + .filter(line -> !line.isBlank()) .map(line -> buildRowMap(headers, parseRow(line))) + .filter(row -> !row.isEmpty()) .collect(Collectors.toList()); } diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index dd840b0a3..118e59e17 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -44,6 +44,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -355,6 +356,7 @@ private UnivApplyInfoResponse fetchUnivApplyInfo(long userId) { List choiceNames = univApplyInfoIds.stream() .map(nameById::get) + .filter(Objects::nonNull) .toList(); return new UnivApplyInfoResponse(choiceNames); diff --git a/src/main/resources/db/migration/V51__expand_column_length.sql b/src/main/resources/db/migration/V51__expand_column_length.sql index b7de68862..c6bf3fe72 100644 --- a/src/main/resources/db/migration/V51__expand_column_length.sql +++ b/src/main/resources/db/migration/V51__expand_column_length.sql @@ -7,3 +7,7 @@ ALTER TABLE university_info_for_apply MODIFY COLUMN details_for_apply VARCHAR(3000), MODIFY COLUMN details_for_major VARCHAR(3000), MODIFY COLUMN details VARCHAR(3000); + +ALTER TABLE application +ADD CONSTRAINT uk_application_nickname_for_apply +UNIQUE (nickname_for_apply); From 98e4d6094ce08d41e96c3bcf2e33f96218b3b5b4 Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 23:52:20 +0900 Subject: [PATCH 30/34] =?UTF-8?q?refactor:=20=EC=BB=A8=EB=B2=A4=EC=85=98?= =?UTF-8?q?=20=EC=9C=84=EB=B0=98=20=ED=95=AD=EB=AA=A9=20=EC=A0=84=EB=A9=B4?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../term/controller/AdminTermController.java | 2 +- .../admin/term/service/AdminTermService.java | 2 +- .../service/AdminUnivApplyInfoRowSaver.java | 62 +++++++++---------- .../service/AdminUnivApplyInfoService.java | 1 - .../service/ApplicationSubmissionService.java | 1 + .../custom/SiteUserFilterRepositoryImpl.java | 5 +- .../common/util/MarkdownTableParserTest.java | 6 +- 7 files changed, 41 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java b/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java index 01dd6b2f3..4c6077367 100644 --- a/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java +++ b/src/main/java/com/example/solidconnection/admin/term/controller/AdminTermController.java @@ -39,7 +39,7 @@ public ResponseEntity createTerm( @PatchMapping("/{id}/activate") public ResponseEntity activateTerm( - @PathVariable Long id + @PathVariable long id ) { AdminTermResponse response = adminTermService.activateTerm(id); return ResponseEntity.ok(response); diff --git a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java index 2336b8922..7ff19e616 100644 --- a/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -38,7 +38,7 @@ public AdminTermResponse createTerm(AdminTermCreateRequest request) { } @Transactional - public AdminTermResponse activateTerm(Long id) { + public AdminTermResponse activateTerm(long id) { Term termToActivate = termRepository.findById(id) .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); if (!Boolean.TRUE.equals(termToActivate.getIsCurrent())) { diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java index e4aa8feca..d4591ff3e 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -81,37 +81,6 @@ public String save( return createdUniversityName; } - private HostUniversity findOrCreateHostUniversity(ImportData data) { - return hostUniversityRepository.findByKoreanName(data.universityKoreanName) - .orElseGet(() -> createHostUniversity(data)); - } - - private HostUniversity createHostUniversity(ImportData data) { - if (data.countryCode == null || data.countryCode.isBlank()) { - throw new CustomException(INVALID_INPUT, - "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); - } - - Country country = countryRepository.findByCode(data.countryCode) - .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); - Region region = regionRepository.findById(country.getRegionCode()).orElse(null); - - return hostUniversityRepository.save(new HostUniversity( - null, - data.universityKoreanName, - data.englishName != null ? data.englishName : "", - data.formatName != null ? data.formatName : "", - data.homepageUrl, - data.englishCourseUrl, - data.accommodationUrl, - "", - "", - data.detailsForLocal, - country, - region - )); - } - private ImportData buildImportData(Map rowData, Map columnMappings) { ImportData data = new ImportData(); rowData.forEach((header, value) -> applyField(data, header, value, columnMappings)); @@ -198,6 +167,37 @@ private String validEnumValues(Enum[] values) { .collect(Collectors.joining(", ")); } + private HostUniversity findOrCreateHostUniversity(ImportData data) { + return hostUniversityRepository.findByKoreanName(data.universityKoreanName) + .orElseGet(() -> createHostUniversity(data)); + } + + private HostUniversity createHostUniversity(ImportData data) { + if (data.countryCode == null || data.countryCode.isBlank()) { + throw new CustomException(INVALID_INPUT, + "대학 '" + data.universityKoreanName + "'이(가) 존재하지 않습니다. 신규 대학 생성을 위해 국가코드(countryCode) 컬럼을 매핑해 주세요."); + } + + Country country = countryRepository.findByCode(data.countryCode) + .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); + Region region = regionRepository.findById(country.getRegionCode()).orElse(null); + + return hostUniversityRepository.save(new HostUniversity( + null, + data.universityKoreanName, + data.englishName != null ? data.englishName : "", + data.formatName != null ? data.formatName : "", + data.homepageUrl, + data.englishCourseUrl, + data.accommodationUrl, + "", + "", + data.detailsForLocal, + country, + region + )); + } + private static class ImportData { String universityKoreanName; diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index c4a0a1603..10237f999 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -15,7 +15,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; - import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index b87f4119c..408e9a94d 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -43,6 +43,7 @@ public class ApplicationSubmissionService { public static final int APPLICATION_UPDATE_COUNT_LIMIT = 3; + private final ApplicationRepository applicationRepository; private final GpaScoreRepository gpaScoreRepository; private final LanguageTestScoreRepository languageTestScoreRepository; diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index 118e59e17..c1132d755 100644 --- a/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java +++ b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java @@ -1,13 +1,13 @@ package com.example.solidconnection.siteuser.repository.custom; import static com.example.solidconnection.application.domain.QApplication.application; -import static com.example.solidconnection.university.domain.QUnivApplyInfo.univApplyInfo; import static com.example.solidconnection.mentor.domain.QMentor.mentor; import static com.example.solidconnection.mentor.domain.QMentorApplication.mentorApplication; import static com.example.solidconnection.mentor.domain.QMentoring.mentoring; import static com.example.solidconnection.report.domain.QReport.report; import static com.example.solidconnection.siteuser.domain.QSiteUser.siteUser; import static com.example.solidconnection.siteuser.domain.QUserBan.userBan; +import static com.example.solidconnection.university.domain.QUnivApplyInfo.univApplyInfo; import static java.time.ZoneOffset.UTC; import static org.springframework.util.StringUtils.hasText; @@ -26,9 +26,9 @@ import com.example.solidconnection.admin.dto.UserInfoDetailResponse; import com.example.solidconnection.admin.dto.UserSearchCondition; import com.example.solidconnection.admin.dto.UserSearchResponse; -import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.application.domain.Application; import com.example.solidconnection.application.domain.ApplicationChoice; +import com.example.solidconnection.siteuser.domain.Role; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.domain.UserStatus; import com.querydsl.core.Tuple; @@ -119,7 +119,6 @@ public class SiteUserFilterRepositoryImpl implements SiteUserFilterRepository { userBan.createdAt ); - private final JPAQueryFactory queryFactory; @Autowired diff --git a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java index 41f58ff3d..aa46b6481 100644 --- a/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java +++ b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java @@ -4,16 +4,20 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.support.TestContainerSpringBootTest; import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +@TestContainerSpringBootTest @DisplayName("마크다운 표 파서 테스트") class MarkdownTableParserTest { - private final MarkdownTableParser parser = new MarkdownTableParser(); + @Autowired + private MarkdownTableParser parser; @Nested class 정상_파싱 { From 1cea41da30659111a6a4df0458f3eb58aed8e8ae Mon Sep 17 00:00:00 2001 From: whqtker Date: Wed, 17 Jun 2026 23:56:11 +0900 Subject: [PATCH 31/34] =?UTF-8?q?chore:=20=EB=91=90=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=ED=95=98=EB=82=98=EB=A1=9C=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V50__dynamic_choice_count.sql | 14 ++++++++++++++ .../db/migration/V51__expand_column_length.sql | 13 ------------- 2 files changed, 14 insertions(+), 13 deletions(-) delete mode 100644 src/main/resources/db/migration/V51__expand_column_length.sql diff --git a/src/main/resources/db/migration/V50__dynamic_choice_count.sql b/src/main/resources/db/migration/V50__dynamic_choice_count.sql index a3cd0e68e..ce6344ede 100644 --- a/src/main/resources/db/migration/V50__dynamic_choice_count.sql +++ b/src/main/resources/db/migration/V50__dynamic_choice_count.sql @@ -18,3 +18,17 @@ CREATE INDEX idx_app_choice_univ_apply_info_id ON application_choice (univ_apply ALTER TABLE application MODIFY COLUMN first_choice_university_info_for_apply_id BIGINT NULL; + +ALTER TABLE host_university + MODIFY COLUMN english_name VARCHAR(200) NOT NULL; + +ALTER TABLE university_info_for_apply + MODIFY COLUMN details_for_language VARCHAR(2000), + MODIFY COLUMN details_for_accommodation VARCHAR(2000), + MODIFY COLUMN details_for_apply VARCHAR(3000), + MODIFY COLUMN details_for_major VARCHAR(3000), + MODIFY COLUMN details VARCHAR(3000); + +ALTER TABLE application + ADD CONSTRAINT uk_application_nickname_for_apply + UNIQUE (nickname_for_apply); diff --git a/src/main/resources/db/migration/V51__expand_column_length.sql b/src/main/resources/db/migration/V51__expand_column_length.sql deleted file mode 100644 index c6bf3fe72..000000000 --- a/src/main/resources/db/migration/V51__expand_column_length.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE host_university - MODIFY COLUMN english_name VARCHAR(200) NOT NULL; - -ALTER TABLE university_info_for_apply - MODIFY COLUMN details_for_language VARCHAR(2000), - MODIFY COLUMN details_for_accommodation VARCHAR(2000), - MODIFY COLUMN details_for_apply VARCHAR(3000), - MODIFY COLUMN details_for_major VARCHAR(3000), - MODIFY COLUMN details VARCHAR(3000); - -ALTER TABLE application -ADD CONSTRAINT uk_application_nickname_for_apply -UNIQUE (nickname_for_apply); From 036e17e612465b535eeae50d30b956587416da39 Mon Sep 17 00:00:00 2001 From: whqtker Date: Thu, 18 Jun 2026 00:00:43 +0900 Subject: [PATCH 32/34] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=99=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminUnivApplyInfoService.java | 6 ++++ .../AdminUnivApplyInfoServiceTest.java | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java index 10237f999..630c301dc 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -7,6 +7,7 @@ import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; +import com.example.solidconnection.cache.annotation.DefaultCacheOut; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.util.MarkdownTableParser; import com.example.solidconnection.term.repository.TermRepository; @@ -33,6 +34,11 @@ public UnivApplyInfoFieldResponse getFields() { } @Transactional + @DefaultCacheOut( + key = {"univApplyInfoTextSearch", "university:recommend:general"}, + cacheManager = "customCacheManager", + prefix = true + ) public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportRequest request) { validateColumnMappings(request.columnMappings()); validateTermExists(request.termId()); diff --git a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java index be7da772d..e654072f7 100644 --- a/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -3,10 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.times; import com.example.solidconnection.admin.university.dto.UnivApplyInfoFieldResponse; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportRequest; import com.example.solidconnection.admin.university.dto.UnivApplyInfoImportResponse; +import com.example.solidconnection.cache.manager.CustomCacheManager; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.support.TestContainerSpringBootTest; import com.example.solidconnection.term.domain.Term; @@ -26,6 +29,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @TestContainerSpringBootTest @DisplayName("UnivApplyInfo 임포트 서비스 테스트") @@ -49,6 +53,9 @@ class AdminUnivApplyInfoServiceTest { @Autowired private UniversityFixture universityFixture; + @MockitoSpyBean + private CustomCacheManager cacheManager; + private Term term; private HomeUniversity homeUniversity; @@ -112,6 +119,27 @@ class UnivApplyInfo를_임포트한다 { ); } + @Test + void 임포트_성공_시_검색과_추천_캐시가_무효화된다() { + // given + String markdown = String.format(""" + | 대학명 | 인원 | + |--------|------| + | %s | 2 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "인원", "studentCapacity") + ); + + // when + adminUnivApplyInfoService.importUnivApplyInfos(request); + + // then + then(cacheManager).should(times(1)).evictUsingPrefix("univApplyInfoTextSearch"); + then(cacheManager).should(times(1)).evictUsingPrefix("university:recommend:general"); + } + @Test void enum_변환_실패시_전체가_실패한다() { // given From 9b3795ea2cce982911e9f0137a8a59ddf33b624c Mon Sep 17 00:00:00 2001 From: whqtker Date: Thu, 18 Jun 2026 11:17:48 +0900 Subject: [PATCH 33/34] =?UTF-8?q?chore:=20=EB=AA=A9=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 54e7b036a..e2d22101b 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -43,7 +43,16 @@ VALUES ('BN', '브루나이', 'ASIA'), ('KZ', '카자흐스탄', 'ASIA'), ('IL', '이스라엘', 'ASIA'), ('MY', '말레이시아', 'ASIA'), - ('RU', '러시아', 'EUROPE'); + ('RU', '러시아', 'EUROPE'), + ('VN', '베트남', 'ASIA'), + ('NZ', '뉴질랜드', 'ASIA'), + ('SI', '슬로베니아', 'EUROPE'), + ('PL', '폴란드', 'EUROPE'), + ('RO', '루마니아', 'EUROPE'), + ('UY', '우루과이', 'AMERICAS'), + ('MX', '멕시코', 'AMERICAS'), + ('MA', '모로코', 'EUROPE'), + ('MO', '마카오', 'CHINA'); INSERT INTO term (name, is_current) VALUES ('2024-1', true); From 78def58d3f7d98fe95408d49300ac9cd4265cf17 Mon Sep 17 00:00:00 2001 From: whqtker Date: Thu, 18 Jun 2026 15:40:01 +0900 Subject: [PATCH 34/34] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=AA=A9=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data.sql | 56 ++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 2e54520d1..8c585f47f 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -63,7 +63,22 @@ VALUES ('test@test.email', 'yonso', 'https://github.com/nayonsoso.png', '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 ('admin@test.email', 'admin', 'https://github.com/nayonsoso.png', 'CONSIDERING', 'ADMIN', - '$2a$10$etoPG1B6Ua9Lj2VwruWKGurpMdToxl06g2WGHVk1mFKGfXyKgA5Pm', 'EMAIL'); -- Admin@1234 + '$2a$10$etoPG1B6Ua9Lj2VwruWKGurpMdToxl06g2WGHVk1mFKGfXyKgA5Pm', 'EMAIL'), -- Admin@1234 + ('after1@test.email', '교환완료자1', 'https://github.com/nayonsoso.png', + 'AFTER_EXCHANGE', 'MENTEE', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 + ('studying1@test.email', '파견중유저1', 'https://github.com/nayonsoso.png', + 'STUDYING_ABROAD', 'MENTEE', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 + ('approved1@test.email', '승인된멘토1', 'https://github.com/nayonsoso.png', + 'AFTER_EXCHANGE', 'MENTOR', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 + ('rejected1@test.email', '거절된유저1', 'https://github.com/nayonsoso.png', + 'AFTER_EXCHANGE', 'MENTEE', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'), -- 12341234 + ('score1@test.email', '성적보유자1', 'https://github.com/nayonsoso.png', + 'CONSIDERING', 'MENTEE', + '$2a$10$psmwlxPfqWnIlq9JrlQJkuXr1XtjRNsyVOgcTWYZub5jFfn0TML76', 'EMAIL'); -- 12341234 INSERT INTO home_university (id, name, max_choice_count, email_domain) VALUES (1, '인하대학교', 3, 'inha.edu'); @@ -288,3 +303,42 @@ VALUES ('EUROPE', '유럽권'), ('AMERICAS', '미주권'), ('ASIA', '아시아권'), ('FREE', '자유게시판'); + +-- 성적 관리 테스트 데이터 +-- site_user ID: 1(yonso), 3(교환완료자1), 4(파견중유저1), 7(성적보유자1) +INSERT INTO gpa_score (gpa, gpa_criteria, gpa_report_url, verify_status, rejected_reason, site_user_id) +VALUES (3.5, 4.5, 'https://example.com/gpa/yonso.pdf', 'PENDING', NULL, 1), + (3.8, 4.5, 'https://example.com/gpa/after1.pdf', 'APPROVED', NULL, 3), + (3.2, 4.5, 'https://example.com/gpa/studying1.pdf', 'REJECTED', '성적표 진위 확인 불가', 4), + (4.0, 4.5, 'https://example.com/gpa/score1.pdf', 'PENDING', NULL, 7); + +INSERT INTO language_test_score (language_test_type, language_test_score, language_test_report_url, verify_status, rejected_reason, site_user_id) +VALUES ('TOEFL_IBT', '85', 'https://example.com/lang/yonso.pdf', 'PENDING', NULL, 1), + ('IELTS', '7.0', 'https://example.com/lang/after1.pdf', 'APPROVED', NULL, 3), + ('TOEIC', '800', 'https://example.com/lang/studying1.pdf', 'REJECTED', '성적표 유효기간 만료', 4), + ('TOEFL_IBT', '90', 'https://example.com/lang/score1.pdf', 'PENDING', NULL, 7); + +-- 멘토 승격 요청 테스트 데이터 +-- PENDING(CATALOG): site_user 3 → 심사 대기 중, 카탈로그 대학 선택 +-- PENDING(OTHER): site_user 4 → 심사 대기 중, 대학 미선택 (assignUniversity 기능 테스트용) +-- APPROVED: site_user 5 → 이미 승인됨 +-- REJECTED: site_user 6 → 거절됨 +INSERT INTO mentor_application (site_user_id, country_code, university_id, university_select_type, + mentor_proof_url, term_id, rejected_reason, + exchange_status, mentor_application_status, approved_at) +VALUES (3, 'US', 1, 'CATALOG', + 'https://example.com/proof/after1.pdf', 1, NULL, + 'AFTER_EXCHANGE', 'PENDING', NULL), + (4, 'JP', NULL, 'OTHER', + 'https://example.com/proof/studying1.pdf', 1, NULL, + 'STUDYING_ABROAD', 'PENDING', NULL), + (5, 'US', 1, 'CATALOG', + 'https://example.com/proof/approved1.pdf', 1, NULL, + 'AFTER_EXCHANGE', 'APPROVED', NOW()), + (6, 'US', 2, 'CATALOG', + 'https://example.com/proof/rejected1.pdf', 1, '파견 증빙 서류 불충분', + 'AFTER_EXCHANGE', 'REJECTED', NULL); + +-- APPROVED 멘토 신청에 대응하는 mentor 레코드 (site_user 5) +INSERT INTO mentor (site_user_id, university_id, term_id, mentee_count, has_badge) +VALUES (5, 1, 1, 0, false);