From 850676c002ff6a350613e7ceba4aaa85409c16fb Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:25:01 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EB=8C=80=EC=8B=A0=20extra=5Finfo=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20(#743)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/UnivApplyInfoDetailResponse.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java index 7ed5826b3..e94f1d1e5 100644 --- a/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java +++ b/src/main/java/com/example/solidconnection/university/dto/UnivApplyInfoDetailResponse.java @@ -3,6 +3,7 @@ import com.example.solidconnection.university.domain.HostUniversity; import com.example.solidconnection.university.domain.UnivApplyInfo; import java.util.List; +import java.util.Map; public record UnivApplyInfoDetailResponse( long id, @@ -17,20 +18,16 @@ public record UnivApplyInfoDetailResponse( String backgroundImageUrl, String detailsForLocal, int studentCapacity, - String tuitionFeeType, String semesterAvailableForDispatch, List languageRequirements, String detailsForLanguage, String gpaRequirement, String gpaRequirementCriteria, String semesterRequirement, - String detailsForApply, - String detailsForMajor, String detailsForAccommodation, - String detailsForEnglishCourse, - String details, String accommodationUrl, - String englishCourseUrl) { + String englishCourseUrl, + Map extraInfo) { public static UnivApplyInfoDetailResponse of( HostUniversity university, @@ -50,7 +47,6 @@ public static UnivApplyInfoDetailResponse of( university.getBackgroundImageUrl(), university.getDetailsForLocal(), univApplyInfo.getStudentCapacity(), - univApplyInfo.getTuitionFeeType().getKoreanName(), univApplyInfo.getSemesterAvailableForDispatch().getKoreanName(), univApplyInfo.getLanguageRequirements().stream() .map(LanguageRequirementResponse::from) @@ -59,13 +55,10 @@ public static UnivApplyInfoDetailResponse of( univApplyInfo.getGpaRequirement(), univApplyInfo.getGpaRequirementCriteria(), univApplyInfo.getSemesterRequirement(), - univApplyInfo.getDetailsForApply(), - univApplyInfo.getDetailsForMajor(), univApplyInfo.getDetailsForAccommodation(), - univApplyInfo.getDetailsForEnglishCourse(), - univApplyInfo.getDetails(), university.getAccommodationUrl(), - university.getEnglishCourseUrl() + university.getEnglishCourseUrl(), + univApplyInfo.getExtraInfo() ); } } From 1bb075c11fc3b458d59543c74e75635e38c3626e 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 02/12] =?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 eedc11253bcfab3ae02704d9335ef9bec1885771 Mon Sep 17 00:00:00 2001 From: hyungjun <115551339+sukangpunch@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:56:40 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=95=99=EA=B5=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=EC=9C=BC=EB=A1=9C=20Hom?= =?UTF-8?q?eUniversity=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 학교별 이메일 인증으로 HomeUniversity 자동 매핑 (#751) - HomeUniversity에 emailDomain 컬럼 추가 (V50 마이그레이션) - HomeUniversityRepository에 findByEmailDomain 메서드 추가 - EmailService 구현 (JavaMailSender 기반 인증 코드 발송) - SchoolEmailService 구현 (인증 코드 발급/확인, Redis TTL 5분) - SiteUser.verifySchool() 도메인 메서드 추가 - MyPageController에 POST /my/school-email, POST /my/school-email/confirm 엔드포인트 추가 - 어드민 HomeUniversity DTO에 emailDomain 필드 반영 * refactor: EmailService를 common/mail/MailService로 이동 email 발송은 공통 인프라 관심사이므로 별도 email 패키지 대신 common/mail 패키지로 이동 * refactor: 학교 이메일 인증 API 응답 body 제거 클라이언트가 이미 알고 있는 이메일을 응답으로 돌려줄 필요가 없으므로 SchoolEmailResponse 제거 및 반환 타입을 void로 변경 * refactor: Redis 저장 실패 시 CustomException으로 처리 RuntimeException 대신 SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED ErrorCode를 사용하여 예외 처리를 명확하게 표현 * refactor: Redis 인증 정보 역직렬화 실패 시 CORRUPTED 예외로 처리 데이터가 존재하지만 파싱 실패인 경우 REQUEST_NOT_FOUND 대신 SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED로 명확하게 구분 * test: 학교 이메일 인증 테스트를 인하대학교(inha.edu) 기반으로 변경 * style: 테스트 Given-When-Then 주석 소문자로 통일 * feat: data.sql에 HomeUniversity email_domain 데이터 추가 * fix: CodeRabbit 리뷰 반영 - emailDomain 검증 강화 및 트랜잭션 범위 축소 - V50 마이그레이션에 기존 대학 email_domain 백필 UPDATE 추가 - AdminHomeUniversityService에 emailDomain 중복 검증 추가 - SchoolEmailService에서 이메일 발송 실패 시 Redis 보상 삭제 추가 - requestSchoolEmailVerification에서 불필요한 @Transactional 제거 - extractEmailDomain에서 도메인 소문자 정규화 적용 - AdminHomeUniversity DTO의 emailDomain 검증을 @Pattern으로 강화 * revert: V50 마이그레이션에서 DML 제거 * refactor: @Email 검증으로 보장된 중복 atIndex 검사 제거 * refactor: develop rebase 충돌 해결 및 테스트 코드 적용 * fix: emailDomain @Size 검증 추가 --- build.gradle | 3 + .../dto/AdminHomeUniversityCreateRequest.java | 10 +- .../dto/AdminHomeUniversityResponse.java | 6 +- .../dto/AdminHomeUniversityUpdateRequest.java | 11 +- .../service/AdminHomeUniversityService.java | 29 +++- .../common/exception/ErrorCode.java | 9 ++ .../common/mail/MailService.java | 21 +++ .../siteuser/controller/MyPageController.java | 23 +++ .../siteuser/domain/SiteUser.java | 4 + .../dto/SchoolEmailConfirmRequest.java | 10 ++ .../siteuser/dto/SchoolEmailRequest.java | 12 ++ .../siteuser/dto/SchoolVerificationInfo.java | 15 ++ .../siteuser/service/SchoolEmailService.java | 112 ++++++++++++++ .../university/domain/HomeUniversity.java | 6 +- .../repository/HomeUniversityRepository.java | 2 + src/main/resources/data.sql | 4 +- ...1__add_email_domain_to_home_university.sql | 2 + .../AdminHomeUniversityServiceTest.java | 50 +++++-- .../service/SchoolEmailServiceTest.java | 137 ++++++++++++++++++ .../fixture/HomeUniversityFixture.java | 3 + .../fixture/HomeUniversityFixtureBuilder.java | 8 +- src/test/resources/application.yml | 5 + 22 files changed, 462 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/common/mail/MailService.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailConfirmRequest.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/dto/SchoolVerificationInfo.java create mode 100644 src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java create mode 100644 src/main/resources/db/migration/V51__add_email_domain_to_home_university.sql create mode 100644 src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java diff --git a/build.gradle b/build.gradle index 02e066023..b7b7b90f1 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,9 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' testImplementation 'org.awaitility:awaitility:4.2.0' + // Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + // Etc implementation platform('software.amazon.awssdk:bom:2.41.4') implementation 'software.amazon.awssdk:s3' 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 9451e8100..c009ceb73 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 @@ -2,6 +2,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record AdminHomeUniversityCreateRequest( @@ -10,7 +11,14 @@ public record AdminHomeUniversityCreateRequest( String name, @Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다") - int maxChoiceCount + int maxChoiceCount, + + @Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다") + @Pattern( + regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)+$", + message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)" + ) + String emailDomain ) { } 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 6842b4909..8569492a4 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 @@ -5,14 +5,16 @@ public record AdminHomeUniversityResponse( long id, String name, - int maxChoiceCount + int maxChoiceCount, + String emailDomain ) { public static AdminHomeUniversityResponse from(HomeUniversity homeUniversity) { return new AdminHomeUniversityResponse( homeUniversity.getId(), homeUniversity.getName(), - homeUniversity.getMaxChoiceCount() + homeUniversity.getMaxChoiceCount(), + homeUniversity.getEmailDomain() ); } } 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 464192bf3..d505ab8af 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 @@ -2,6 +2,7 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record AdminHomeUniversityUpdateRequest( @@ -10,7 +11,15 @@ public record AdminHomeUniversityUpdateRequest( String name, @Min(value = 1, message = "최대 지망 수는 1 이상이어야 합니다") - int maxChoiceCount + int maxChoiceCount, + + @Size(max = 100, message = "이메일 도메인은 100자 이하여야 합니다") + @Pattern( + regexp = "^[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)+$", + message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)" + ) + String emailDomain + ) { } 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 af3a10f35..c38e6878b 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 @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.university.service; import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS; +import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS; import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_HAS_REFERENCES; import static com.example.solidconnection.common.exception.ErrorCode.HOME_UNIVERSITY_NOT_FOUND; @@ -48,7 +49,8 @@ public AdminHomeUniversityResponse getHomeUniversity(Long id) { ) public AdminHomeUniversityResponse createHomeUniversity(AdminHomeUniversityCreateRequest request) { validateNameNotExists(request.name()); - HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.maxChoiceCount()); + validateEmailDomainNotExists(request.emailDomain()); + HomeUniversity homeUniversity = new HomeUniversity(null, request.name(), request.maxChoiceCount(), request.emailDomain()); return AdminHomeUniversityResponse.from(homeUniversityRepository.save(homeUniversity)); } @@ -59,6 +61,16 @@ private void validateNameNotExists(String name) { }); } + private void validateEmailDomainNotExists(String emailDomain) { + if (emailDomain == null || emailDomain.isBlank()) { + return; + } + homeUniversityRepository.findByEmailDomain(emailDomain) + .ifPresent(existing -> { + throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS); + }); + } + @Transactional @DefaultCacheOut( key = {"univApplyInfoTextSearch", "university:recommend:general"}, @@ -69,7 +81,8 @@ 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(), request.maxChoiceCount()); + validateEmailDomainNotDuplicated(request.emailDomain(), id); + homeUniversity.update(request.name(), request.maxChoiceCount(), request.emailDomain()); return AdminHomeUniversityResponse.from(homeUniversity); } @@ -82,6 +95,18 @@ private void validateNameNotDuplicated(String name, Long excludeId) { }); } + private void validateEmailDomainNotDuplicated(String emailDomain, Long excludeId) { + if (emailDomain == null || emailDomain.isBlank()) { + return; + } + homeUniversityRepository.findByEmailDomain(emailDomain) + .ifPresent(existing -> { + if (!existing.getId().equals(excludeId)) { + throw new CustomException(HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS); + } + }); + } + @Transactional @DefaultCacheOut( key = {"univApplyInfoTextSearch", "university:recommend:general"}, 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..d87f4f23b 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -47,6 +47,7 @@ public enum ErrorCode { HOST_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 파견 대학을 참조하는 대학 지원 정보가 존재합니다."), HOME_UNIVERSITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "협정 대학교를 찾을 수 없습니다."), HOME_UNIVERSITY_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 존재하는 협정 대학입니다."), + HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS(HttpStatus.CONFLICT.value(), "이미 사용 중인 이메일 도메인입니다."), HOME_UNIVERSITY_HAS_REFERENCES(HttpStatus.CONFLICT.value(), "해당 협정 대학을 참조하는 데이터가 존재합니다."), COUNTRY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "국가를 찾을 수 없습니다."), COUNTRY_NOT_FOUND_BY_KOREAN_NAME(HttpStatus.NOT_FOUND.value(), "이름에 해당하는 국가를 찾을 수 없습니다."), @@ -78,6 +79,14 @@ public enum ErrorCode { SIGN_IN_FAILED(HttpStatus.UNAUTHORIZED.value(), "로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요."), OAUTH_USER_CANNOT_CHANGE_PASSWORD(HttpStatus.BAD_REQUEST.value(), "소셜 로그인 사용자는 비밀번호를 변경할 수 없습니다."), + // school email verification + SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."), + SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."), + SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."), + SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."), + SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보 저장에 실패했습니다."), + SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED(HttpStatus.INTERNAL_SERVER_ERROR.value(), "학교 이메일 인증 정보가 손상되었습니다. 인증 코드 발송을 다시 요청해주세요."), + // s3 S3_SERVICE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 서비스 에러 발생"), S3_CLIENT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "S3 클라이언트 에러 발생"), diff --git a/src/main/java/com/example/solidconnection/common/mail/MailService.java b/src/main/java/com/example/solidconnection/common/mail/MailService.java new file mode 100644 index 000000000..97d23247c --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/mail/MailService.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.common.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender javaMailSender; + + public void sendVerificationEmail(String to, String verificationCode) { + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(to); + message.setSubject("[Solid Connect] 학교 이메일 인증"); + message.setText("인증 코드: " + verificationCode + "\n\n인증 코드는 5분간 유효합니다."); + javaMailSender.send(message); + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java index 00c3077e3..1af2b253f 100644 --- a/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java +++ b/src/main/java/com/example/solidconnection/siteuser/controller/MyPageController.java @@ -5,12 +5,16 @@ import com.example.solidconnection.siteuser.dto.LocationUpdateRequest; import com.example.solidconnection.siteuser.dto.MyPageResponse; import com.example.solidconnection.siteuser.dto.PasswordUpdateRequest; +import com.example.solidconnection.siteuser.dto.SchoolEmailConfirmRequest; +import com.example.solidconnection.siteuser.dto.SchoolEmailRequest; import com.example.solidconnection.siteuser.service.MyPageService; +import com.example.solidconnection.siteuser.service.SchoolEmailService; 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.PatchMapping; +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.RequestParam; @@ -23,6 +27,7 @@ class MyPageController { private final MyPageService myPageService; + private final SchoolEmailService schoolEmailService; @GetMapping public ResponseEntity getMyPageInfo( @@ -59,4 +64,22 @@ public ResponseEntity updateLocation( myPageService.updateLocation(siteUserId, request); return ResponseEntity.ok().build(); } + + @PostMapping("/school-email") + public ResponseEntity requestSchoolEmailVerification( + @AuthorizedUser long siteUserId, + @RequestBody @Valid SchoolEmailRequest request + ) { + schoolEmailService.requestSchoolEmailVerification(siteUserId, request.schoolEmail()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/school-email/confirm") + public ResponseEntity confirmSchoolEmail( + @AuthorizedUser long siteUserId, + @RequestBody @Valid SchoolEmailConfirmRequest request + ) { + schoolEmailService.confirmSchoolEmail(siteUserId, request.code()); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java index 2a53348ad..f072e9eb8 100644 --- a/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java +++ b/src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java @@ -159,6 +159,10 @@ public void updateUserStatus(UserStatus status) { this.userStatus = status; } + public void verifySchool(Long homeUniversityId) { + this.homeUniversityId = homeUniversityId; + } + public void becomeMentor() { this.role = Role.MENTOR; } diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailConfirmRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailConfirmRequest.java new file mode 100644 index 000000000..14d1d4e18 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailConfirmRequest.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.siteuser.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SchoolEmailConfirmRequest( + @NotBlank(message = "인증 코드는 필수입니다") + String code +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java new file mode 100644 index 000000000..46f1e9bec --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.siteuser.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SchoolEmailRequest( + @NotBlank(message = "학교 이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + String schoolEmail +) { + +} diff --git a/src/main/java/com/example/solidconnection/siteuser/dto/SchoolVerificationInfo.java b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolVerificationInfo.java new file mode 100644 index 000000000..cc38071f0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/dto/SchoolVerificationInfo.java @@ -0,0 +1,15 @@ +package com.example.solidconnection.siteuser.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SchoolVerificationInfo { + + private String schoolEmail; + private Long homeUniversityId; + private String code; +} diff --git a/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java b/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java new file mode 100644 index 000000000..ec8d9c8fc --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java @@ -0,0 +1,112 @@ +package com.example.solidconnection.siteuser.service; + +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.mail.MailService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.dto.SchoolVerificationInfo; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.repository.HomeUniversityRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SchoolEmailService { + + private static final long VERIFICATION_CODE_TTL_SECONDS = 300; + private static final String KEY_PREFIX = "school-email:"; + + private final SiteUserRepository siteUserRepository; + private final HomeUniversityRepository homeUniversityRepository; + private final MailService mailService; + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public void requestSchoolEmailVerification(long siteUserId, String schoolEmail) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + if (siteUser.getHomeUniversityId() != null) { + throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED); + } + + String domain = extractEmailDomain(schoolEmail); + HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain) + .orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED)); + + String code = generateVerificationCode(); + saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code)); + + try { + mailService.sendVerificationEmail(schoolEmail, code); + } catch (Exception e) { + redisTemplate.delete(KEY_PREFIX + siteUserId); + throw e; + } + } + + @Transactional + public void confirmSchoolEmail(long siteUserId, String code) { + SiteUser siteUser = siteUserRepository.findById(siteUserId) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + + SchoolVerificationInfo info = getVerificationInfo(siteUserId); + + if (!info.getCode().equals(code)) { + throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT); + } + + siteUser.verifySchool(info.getHomeUniversityId()); + redisTemplate.delete(KEY_PREFIX + siteUserId); + } + + private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) { + try { + redisTemplate.opsForValue().set( + KEY_PREFIX + siteUserId, + objectMapper.writeValueAsString(info), + VERIFICATION_CODE_TTL_SECONDS, + TimeUnit.SECONDS + ); + } catch (JsonProcessingException e) { + throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_SAVE_FAILED); + } + } + + private SchoolVerificationInfo getVerificationInfo(long siteUserId) { + String jsonInfo = redisTemplate.opsForValue().get(KEY_PREFIX + siteUserId); + if (jsonInfo == null) { + throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND); + } + try { + return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class); + } catch (JsonProcessingException e) { + redisTemplate.delete(KEY_PREFIX + siteUserId); + throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED); + } + } + + private String extractEmailDomain(String email) { + return email.substring(email.indexOf('@') + 1).toLowerCase(); + } + + private String generateVerificationCode() { + return String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); + } +} 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 ec26d9924..634646d92 100644 --- a/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java +++ b/src/main/java/com/example/solidconnection/university/domain/HomeUniversity.java @@ -30,8 +30,12 @@ public class HomeUniversity extends BaseEntity { @Column(name = "max_choice_count", nullable = false) private int maxChoiceCount; - public void update(String name, int maxChoiceCount) { + @Column(name = "email_domain", unique = true, length = 100) + private String emailDomain; + + public void update(String name, int maxChoiceCount, String emailDomain) { this.name = name; this.maxChoiceCount = maxChoiceCount; + this.emailDomain = emailDomain; } } diff --git a/src/main/java/com/example/solidconnection/university/repository/HomeUniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/HomeUniversityRepository.java index 0cfc0593c..e79a5730e 100644 --- a/src/main/java/com/example/solidconnection/university/repository/HomeUniversityRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/HomeUniversityRepository.java @@ -10,4 +10,6 @@ public interface HomeUniversityRepository extends JpaRepository findAllByIdIn(List ids); Optional findByName(String name); + + Optional findByEmailDomain(String emailDomain); } diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index c8d040bf4..fa067fa8a 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, max_choice_count) -VALUES (1, '인하대학교', 3); +INSERT INTO home_university (id, name, max_choice_count, email_domain) +VALUES (1, '인하대학교', 3, 'inha.edu'); 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/V51__add_email_domain_to_home_university.sql b/src/main/resources/db/migration/V51__add_email_domain_to_home_university.sql new file mode 100644 index 000000000..dde41adc0 --- /dev/null +++ b/src/main/resources/db/migration/V51__add_email_domain_to_home_university.sql @@ -0,0 +1,2 @@ +ALTER TABLE home_university + ADD COLUMN email_domain VARCHAR(100) NULL UNIQUE; 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 3977f2f1a..68329ba00 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 @@ -85,7 +85,8 @@ class 협정대학_단건_조회 { assertAll( () -> assertThat(response.id()).isEqualTo(homeUniversity.getId()), () -> assertThat(response.name()).isEqualTo(homeUniversity.getName()), - () -> assertThat(response.maxChoiceCount()).isEqualTo(homeUniversity.getMaxChoiceCount()) + () -> assertThat(response.maxChoiceCount()).isEqualTo(homeUniversity.getMaxChoiceCount()), + () -> assertThat(response.emailDomain()).isEqualTo(homeUniversity.getEmailDomain()) ); } @@ -104,7 +105,7 @@ class 협정대학_생성 { @Test void 유효한_요청으로_협정대학을_생성하면_성공한다() { // given - AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3); + AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3, "inha.edu"); // when AdminHomeUniversityResponse response = adminHomeUniversityService.createHomeUniversity(request); @@ -114,8 +115,10 @@ class 협정대학_생성 { assertAll( () -> assertThat(response.name()).isEqualTo("인하대학교"), () -> assertThat(response.maxChoiceCount()).isEqualTo(3), + () -> assertThat(response.emailDomain()).isEqualTo("inha.edu"), () -> assertThat(saved.getName()).isEqualTo("인하대학교"), - () -> assertThat(saved.getMaxChoiceCount()).isEqualTo(3) + () -> assertThat(saved.getMaxChoiceCount()).isEqualTo(3), + () -> assertThat(saved.getEmailDomain()).isEqualTo("inha.edu") ); } @@ -123,13 +126,25 @@ class 협정대학_생성 { void 이미_존재하는_이름으로_생성하면_예외가_발생한다() { // given homeUniversityFixture.인하대학교(); - AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3); + AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("인하대학교", 3, "other.ac.kr"); // when & then assertThatCode(() -> adminHomeUniversityService.createHomeUniversity(request)) .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + + @Test + void 이미_존재하는_이메일_도메인으로_생성하면_예외가_발생한다() { + // given + homeUniversityFixture.인하대학교(); + AdminHomeUniversityCreateRequest request = new AdminHomeUniversityCreateRequest("연세대학교", 3, "inha.edu"); + + // when & then + assertThatCode(() -> adminHomeUniversityService.createHomeUniversity(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS.getMessage()); + } } @Nested @@ -139,7 +154,7 @@ class 협정대학_수정 { void 유효한_요청으로_협정대학을_수정하면_성공한다() { // given HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 5); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 5, "yonsei.ac.kr"); // when AdminHomeUniversityResponse response = adminHomeUniversityService.updateHomeUniversity(homeUniversity.getId(), request); @@ -149,15 +164,17 @@ class 협정대학_수정 { assertAll( () -> assertThat(response.name()).isEqualTo("연세대학교"), () -> assertThat(response.maxChoiceCount()).isEqualTo(5), + () -> assertThat(response.emailDomain()).isEqualTo("yonsei.ac.kr"), () -> assertThat(updated.getName()).isEqualTo("연세대학교"), - () -> assertThat(updated.getMaxChoiceCount()).isEqualTo(5) + () -> assertThat(updated.getMaxChoiceCount()).isEqualTo(5), + () -> assertThat(updated.getEmailDomain()).isEqualTo("yonsei.ac.kr") ); } @Test void 존재하지_않는_협정대학을_수정하면_예외가_발생한다() { // given - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 3); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 3, "yonsei.ac.kr"); // when & then assertThatCode(() -> adminHomeUniversityService.updateHomeUniversity(999L, request)) @@ -169,8 +186,8 @@ class 협정대학_수정 { void 다른_협정대학의_이름으로_수정하면_예외가_발생한다() { // given homeUniversityFixture.인하대학교(); - HomeUniversity other = homeUniversityRepository.save(new HomeUniversity(null, "연세대학교", 3)); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3); + HomeUniversity other = homeUniversityRepository.save(new HomeUniversity(null, "연세대학교", 3, "yonsei.ac.kr")); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3, "yonsei.ac.kr"); // when & then assertThatCode(() -> adminHomeUniversityService.updateHomeUniversity(other.getId(), request)) @@ -178,11 +195,24 @@ class 협정대학_수정 { .hasMessage(ErrorCode.HOME_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + @Test + void 다른_협정대학의_이메일_도메인으로_수정하면_예외가_발생한다() { + // given + homeUniversityFixture.인하대학교(); + HomeUniversity other = homeUniversityRepository.save(new HomeUniversity(null, "연세대학교", 3, "yonsei.ac.kr")); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("연세대학교", 3, "inha.edu"); + + // when & then + assertThatCode(() -> adminHomeUniversityService.updateHomeUniversity(other.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOME_UNIVERSITY_EMAIL_DOMAIN_ALREADY_EXISTS.getMessage()); + } + @Test void 같은_이름으로_수정하면_성공한다() { // given HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); - AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3); + AdminHomeUniversityUpdateRequest request = new AdminHomeUniversityUpdateRequest("인하대학교", 3, "inha.edu"); // when AdminHomeUniversityResponse response = adminHomeUniversityService.updateHomeUniversity(homeUniversity.getId(), request); diff --git a/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java b/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java new file mode 100644 index 000000000..2cf4de360 --- /dev/null +++ b/src/test/java/com/example/solidconnection/siteuser/service/SchoolEmailServiceTest.java @@ -0,0 +1,137 @@ +package com.example.solidconnection.siteuser.service; + +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.mail.MailService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.university.domain.HomeUniversity; +import com.example.solidconnection.university.fixture.HomeUniversityFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@TestContainerSpringBootTest +@DisplayName("학교 이메일 인증 서비스 테스트") +class SchoolEmailServiceTest { + + @Autowired + private SchoolEmailService schoolEmailService; + + @MockitoBean + private MailService mailService; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private HomeUniversityFixture homeUniversityFixture; + + @Autowired + private SiteUserRepository siteUserRepository; + + @Nested + @DisplayName("학교 이메일 인증 요청") + class 학교_이메일_인증_요청 { + + @Test + void 인증_코드가_발급되고_이메일이_발송된다() { + // given + homeUniversityFixture.인하대학교(); + SiteUser siteUser = siteUserFixture.사용자(); + + // when & then + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu"); + then(mailService).should().sendVerificationEmail(eq("test@inha.edu"), any()); + } + + @Test + void 이미_학교_인증된_사용자는_예외가_발생한다() { + // given + HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); + SiteUser siteUser = siteUserFixture.국내_대학_정보_소지_사용자(homeUniversity.getId()); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_ALREADY_VERIFIED.getMessage()); + } + + @Test + void 지원하지_않는_이메일_도메인은_예외가_발생한다() { + // given + SiteUser siteUser = siteUserFixture.사용자(); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@unknown.ac.kr")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED.getMessage()); + } + } + + @Nested + @DisplayName("학교 이메일 인증 확인") + class 학교_이메일_인증_확인 { + + @Test + void 인증_코드가_일치하면_homeUniversityId가_설정되고_인증이_완료된다() { + // given + HomeUniversity homeUniversity = homeUniversityFixture.인하대학교(); + SiteUser siteUser = siteUserFixture.사용자(); + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu"); + + ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class); + then(mailService).should().sendVerificationEmail(any(), codeCaptor.capture()); + String code = codeCaptor.getValue(); + + // When + schoolEmailService.confirmSchoolEmail(siteUser.getId(), code); + + // Then + SiteUser updated = siteUserRepository.findById(siteUser.getId()).orElseThrow(); + assertThat(updated.getHomeUniversityId()).isEqualTo(homeUniversity.getId()); + } + + @Test + void 인증_정보가_없으면_예외가_발생한다() { + // given + SiteUser siteUser = siteUserFixture.사용자(); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.confirmSchoolEmail(siteUser.getId(), "123456")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND.getMessage()); + } + + @Test + void 인증_코드가_다르면_예외가_발생한다() { + // given + homeUniversityFixture.인하대학교(); + SiteUser siteUser = siteUserFixture.사용자(); + schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu"); + + // when & then + assertThatThrownBy(() -> + schoolEmailService.confirmSchoolEmail(siteUser.getId(), "000000")) + .isInstanceOf(CustomException.class) + .hasMessage(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT.getMessage()); + } + } +} 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 f3bdd0e11..732536d11 100644 --- a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java +++ b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixture.java @@ -14,6 +14,7 @@ public class HomeUniversityFixture { return homeUniversityFixtureBuilder.homeUniversity() .name("인하대학교") .maxChoiceCount(3) + .emailDomain("inha.edu") .create(); } @@ -21,6 +22,7 @@ public class HomeUniversityFixture { return homeUniversityFixtureBuilder.homeUniversity() .name("테스트협정대학교_최대2지망") .maxChoiceCount(2) + .emailDomain("inha.ac.kr") .create(); } @@ -28,6 +30,7 @@ public class HomeUniversityFixture { return homeUniversityFixtureBuilder.homeUniversity() .name("인천대학교") .maxChoiceCount(3) + .emailDomain("inu.ac.kr") .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 5287123ed..77f43f3f4 100644 --- a/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java +++ b/src/test/java/com/example/solidconnection/university/fixture/HomeUniversityFixtureBuilder.java @@ -13,6 +13,7 @@ public class HomeUniversityFixtureBuilder { private String name; private int maxChoiceCount = 3; + private String emailDomain; public HomeUniversityFixtureBuilder homeUniversity() { return new HomeUniversityFixtureBuilder(homeUniversityRepository); @@ -23,6 +24,11 @@ public HomeUniversityFixtureBuilder name(String name) { return this; } + public HomeUniversityFixtureBuilder emailDomain(String emailDomain) { + this.emailDomain = emailDomain; + return this; + } + public HomeUniversityFixtureBuilder maxChoiceCount(int maxChoiceCount) { this.maxChoiceCount = maxChoiceCount; return this; @@ -30,6 +36,6 @@ public HomeUniversityFixtureBuilder maxChoiceCount(int maxChoiceCount) { public HomeUniversity create() { return homeUniversityRepository.findByName(name) - .orElseGet(() -> homeUniversityRepository.save(new HomeUniversity(null, name, maxChoiceCount))); + .orElseGet(() -> homeUniversityRepository.save(new HomeUniversity(null, name, maxChoiceCount, emailDomain))); } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 165e12a53..8fc126ba9 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -19,6 +19,11 @@ spring: format_sql: true flyway: enabled: false + mail: + host: localhost + port: 25 + username: test + password: test # cloud cloud: From 0186e89d3f8b8350accfc1d1fa728c52da55e7a0 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:56:08 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20prod/stage=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20nginx=20=EB=B8=94=EB=A3=A8/=EA=B7=B8=EB=A6=B0=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=B0=A9=EC=8B=9D=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(#753)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: prod/stage 환경 nginx 블루/그린 배포 방식 도입 * fix: 리뷰 반영(management port 충돌 문제 해결, MySQL 경로 버그 문제 해결) * fix: 액츄에이터 노출 & dev/prod 스크립트 일치 반영 --- .github/workflows/dev-cd.yml | 111 ++++++++++++++++++----------- .github/workflows/prod-cd.yml | 102 +++++++++++++++++--------- docker-compose.dev.yml | 4 +- docker-compose.prod.yml | 4 +- src/main/resources/application.yml | 2 +- 5 files changed, 146 insertions(+), 77 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 82eec9d37..b75d2a18d 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -66,7 +66,7 @@ jobs: cache-from: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache cache-to: type=registry,ref=${{ steps.image_meta.outputs.image_name }}:buildcache,mode=max - # --- 이미지 정리 (이전 Job에 있던 것) --- + # --- 이미지 정리 --- - name: Clean up old image versions from GHCR uses: snok/container-retention-policy@v2 with: @@ -87,7 +87,6 @@ jobs: packages: read steps: - # 설정 파일 전송을 위해 코드 체크아웃 (서브모듈 불필요) - name: Checkout config files uses: actions/checkout@v4 with: @@ -96,52 +95,84 @@ jobs: docs/infra-config sparse-checkout-cone-mode: false - # --- 설정 파일 전송 --- - name: Copy config files to remote run: | echo "${{ secrets.DEV_PRIVATE_KEY }}" > deploy_key.pem chmod 600 deploy_key.pem - + scp -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ./docker-compose.dev.yml \ ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}:/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev/ - # --- 서버에서 Docker Pull 및 재시작 --- - - name: Run deployment on server + - name: Blue/Green deploy run: | ssh -i deploy_key.pem \ - -o StrictHostKeyChecking=no \ - ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} \ - ' - set -e - - # 1. 환경 변수 설정 (이전 Job의 Output 사용) - export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" - export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG_ONLY}" - export IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" - - # 2. Pull 전 정리 (디스크 공간 확보) - echo "Cleaning up old tagged images (keeping last 2)..." - docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ - sort -r | \ - tail -n +3 | \ - xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" || true - - echo "Pruning dangling images..." - docker image prune -f - - # 3. GHCR 로그인 & Pull - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - echo "Pulling new image: $FULL_IMAGE_NAME" - docker pull $FULL_IMAGE_NAME - - # 4. Spring Boot 앱 재시작 - echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" - cd /home/${{ secrets.DEV_USERNAME }}/solid-connection-dev - docker compose -f docker-compose.dev.yml down || true - OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.dev.yml up -d - - echo "Deployment finished successfully." - ' + -o StrictHostKeyChecking=no \ + ${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }} \ + ' + set -e + OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" + FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG_ONLY}" + IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev" + WORK_DIR="/home/${{ secrets.DEV_USERNAME }}/solid-connection-dev" + CONTAINER_BASE="solid-connection-dev" + + # 1. Active 슬롯 확인 (upstream.conf 기준) + UPSTREAM_PORT=$(grep -oE "server 127\.0\.0\.1:[0-9]+" /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE "[0-9]+$" || echo "8081") + if [ "$UPSTREAM_PORT" = "8080" ]; then + ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=8081; MANAGEMENT_PORT=9081 + else + ACTIVE_SLOT="green"; ACTIVE_PORT=8081; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=9080 + fi + echo "Active: ${ACTIVE_SLOT}(${ACTIVE_PORT}) → Deploy: ${NEW_SLOT}(${NEW_PORT}), management: ${MANAGEMENT_PORT}" + + # 2. 작업 디렉토리 이동 (이후 모든 compose 명령 기준) + cd "${WORK_DIR}" + + # 3. MySQL 기동 확인 (블루/그린 전환 대상 아님) + docker compose -f docker-compose.dev.yml up -d mysql 2>/dev/null || true + + # 4. Pull 전 디스크 정리 (태그 이미지 최근 2개 유지) + docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ + grep -v buildcache | sort -r | tail -n +3 | \ + xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" 2>/dev/null || true + docker image prune -f + + # 5. GHCR 로그인 & Pull + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + docker pull "${FULL_IMAGE_NAME}" + + # 6. 새 슬롯 잔여 컨테이너 정리 + docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + + # 7. 새 컨테이너 시작 + SLOT="${NEW_SLOT}" APP_PORT="${NEW_PORT}" MANAGEMENT_PORT="${MANAGEMENT_PORT}" OWNER_LOWERCASE="${OWNER_LOWERCASE}" IMAGE_TAG="${IMAGE_TAG_ONLY}" \ + docker compose -p "${CONTAINER_BASE}-${NEW_SLOT}" -f docker-compose.dev.yml up -d solid-connection-dev + + # 8. 헬스 체크 (앱 기동 대기, 최대 150초) + echo "Waiting for app on management port ${MANAGEMENT_PORT}..." + for i in $(seq 1 30); do + STATUS=$(curl -s --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" | grep -o '"status":"UP"' || true) + [ "$STATUS" = '"status":"UP"' ] && { echo "App healthy (attempt ${i})"; break; } + [ "$i" = "30" ] && { + echo "Health check timed out after 150s" >&2 + docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + exit 1 + } + sleep 5 + done + + # 9. Nginx upstream 전환 (무중단) + sudo sed -i "s|server 127.0.0.1:[0-9]*;|server 127.0.0.1:${NEW_PORT};|" /etc/nginx/conf.d/upstream.conf + sudo nginx -s reload + echo "Traffic switched → ${NEW_SLOT}(${NEW_PORT})" + + # 10. 구 컨테이너 종료 + docker compose -p "${CONTAINER_BASE}-${ACTIVE_SLOT}" -f docker-compose.dev.yml down 2>/dev/null || true + + echo "Deployment complete. Active: ${NEW_SLOT}(${NEW_PORT})" + ' diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index f0832907d..bb31d5b7d 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -57,7 +57,7 @@ jobs: id: image_meta run: | OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - + # Trigger가 Release인 경우: Release의 Tag Name (예: v1.0.0) 사용 if [ "${{ github.event_name }}" == "release" ]; then IMAGE_TAG="${{ github.ref_name }}" @@ -65,9 +65,9 @@ jobs: else IMAGE_TAG="${{ inputs.tag_name }}" fi - + echo "Docker Image Tag: $IMAGE_TAG" - + echo "image_name=ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" >> $GITHUB_OUTPUT echo "image_tag=${IMAGE_TAG}" >> $GITHUB_OUTPUT @@ -103,7 +103,6 @@ jobs: packages: read steps: - # 설정 파일 전송을 위해 코드 체크아웃 (서브모듈 불필요) - name: Checkout config files uses: actions/checkout@v4 with: @@ -112,44 +111,79 @@ jobs: docs/infra-config sparse-checkout-cone-mode: false - # --- 설정 파일 전송 --- - name: Copy config files to remote run: | echo "${{ secrets.PRIVATE_KEY }}" > deploy_key.pem chmod 600 deploy_key.pem - + scp -i deploy_key.pem \ -o StrictHostKeyChecking=no \ ./docker-compose.prod.yml \ ${{ secrets.USERNAME }}@${{ secrets.HOST }}:/home/${{ secrets.USERNAME }}/solid-connection-prod/ - # --- 서버에서 Docker Pull 및 재시작 --- - - name: Run docker compose and apply nginx config + - name: Blue/Green deploy run: | ssh -i deploy_key.pem \ - -o StrictHostKeyChecking=no \ - ${{ secrets.USERNAME }}@${{ secrets.HOST }} \ - ' - set -e - - # 1. 변수 설정 (이전 Job의 Output 사용) - export OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - export IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" - export FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" - - # 2. GHCR 로그인 & Pull - # App Token 대신 현재 워크플로우의 임시 토큰을 사용합니다. - echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin - echo "Pulling new image: $FULL_IMAGE_NAME" - docker pull $FULL_IMAGE_NAME - - # 3. Spring Boot 앱 재시작 - echo "Restarting Docker Compose with tag: $IMAGE_TAG_ONLY" - cd /home/${{ secrets.USERNAME }}/solid-connection-prod - docker compose -f docker-compose.prod.yml down || true - OWNER_LOWERCASE=$OWNER_LOWERCASE IMAGE_TAG=$IMAGE_TAG_ONLY docker compose -f docker-compose.prod.yml up -d - - # 6. 정리 - docker image prune -f - echo "Deployment finished successfully." - ' + -o StrictHostKeyChecking=no \ + ${{ secrets.USERNAME }}@${{ secrets.HOST }} \ + ' + set -e + OWNER_LOWERCASE=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + IMAGE_TAG_ONLY="${{ needs.build-and-push.outputs.image_tag }}" + FULL_IMAGE_NAME="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG_ONLY}" + IMAGE_NAME_BASE="ghcr.io/${OWNER_LOWERCASE}/solid-connection-server" + WORK_DIR="/home/${{ secrets.USERNAME }}/solid-connection-prod" + CONTAINER_BASE="solid-connection-server" + + # 1. Active 슬롯 확인 (upstream.conf 기준) + UPSTREAM_PORT=$(grep -oE "server 127\.0\.0\.1:[0-9]+" /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE "[0-9]+$" || echo "8081") + if [ "$UPSTREAM_PORT" = "8080" ]; then + ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=8081; MANAGEMENT_PORT=9081 + else + ACTIVE_SLOT="green"; ACTIVE_PORT=8081; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=9080 + fi + echo "Active: ${ACTIVE_SLOT}(${ACTIVE_PORT}) → Deploy: ${NEW_SLOT}(${NEW_PORT}), management: ${MANAGEMENT_PORT}" + + # 2. Pull 전 디스크 정리 (태그 이미지 최근 2개 유지) + docker images "${IMAGE_NAME_BASE}" --format "{{.Tag}}" | \ + grep -v buildcache | sort -r | tail -n +3 | \ + xargs -I {} docker rmi "${IMAGE_NAME_BASE}:{}" 2>/dev/null || true + docker image prune -f + + # 3. GHCR 로그인 & Pull + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + docker pull "${FULL_IMAGE_NAME}" + + # 4. 새 슬롯 잔여 컨테이너 정리 + docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + + # 5. 새 컨테이너 시작 + cd "${WORK_DIR}" + SLOT="${NEW_SLOT}" APP_PORT="${NEW_PORT}" MANAGEMENT_PORT="${MANAGEMENT_PORT}" OWNER_LOWERCASE="${OWNER_LOWERCASE}" IMAGE_TAG="${IMAGE_TAG_ONLY}" \ + docker compose -p "${CONTAINER_BASE}-${NEW_SLOT}" -f docker-compose.prod.yml up -d solid-connection-server + + # 6. 헬스 체크 (앱 기동 대기, 최대 150초) + echo "Waiting for app on management port ${MANAGEMENT_PORT}..." + for i in $(seq 1 30); do + STATUS=$(curl -s --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" | grep -o '"status":"UP"' || true) + [ "$STATUS" = '"status":"UP"' ] && { echo "App healthy (attempt ${i})"; break; } + [ "$i" = "30" ] && { + echo "Health check timed out after 150s" >&2 + docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + docker rm "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true + exit 1 + } + sleep 5 + done + + # 7. Nginx upstream 전환 (무중단) + sudo sed -i "s|server 127.0.0.1:[0-9]*;|server 127.0.0.1:${NEW_PORT};|" /etc/nginx/conf.d/upstream.conf + sudo nginx -s reload + echo "Traffic switched → ${NEW_SLOT}(${NEW_PORT})" + + # 8. 구 컨테이너 종료 + docker compose -p "${CONTAINER_BASE}-${ACTIVE_SLOT}" -f docker-compose.prod.yml down 2>/dev/null || true + + echo "Deployment complete. Active: ${NEW_SLOT}(${NEW_PORT})" + ' diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a7a909d1a..f2cd9b9e5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,10 +3,12 @@ version: '3.8' services: solid-connection-dev: image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-dev:${IMAGE_TAG:-latest} - container_name: solid-connection-dev + container_name: solid-connection-dev-${SLOT:-blue} network_mode: "host" environment: - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=${APP_PORT:-8080} + - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-9080} - AWS_REGION=ap-northeast-2 - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 99ea7db7b..57363231b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,10 +3,12 @@ version: '3.8' services: solid-connection-server: image: ghcr.io/${OWNER_LOWERCASE}/solid-connection-server:${IMAGE_TAG:-latest} - container_name: solid-connection-server + container_name: solid-connection-server-${SLOT:-blue} network_mode: "host" environment: - SPRING_PROFILES_ACTIVE=prod + - SERVER_PORT=${APP_PORT:-8080} + - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-9080} - AWS_REGION=ap-northeast-2 - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e5590f44..e8d228f5d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,7 +23,7 @@ management: endpoints: web: exposure: - include: prometheus + include: prometheus, health --- spring: From 615f17a9685abc57fea840ef22f2f56519c73b96 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:54:21 +0900 Subject: [PATCH 05/12] =?UTF-8?q?refactor:=20prod/stage=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20nginx=20=EB=B8=94=EB=A3=A8/=EA=B7=B8=EB=A6=B0=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=B0=A9=EC=8B=9D=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(#756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: prod/stage 환경 nginx 블루/그린 배포 방식 도입 * fix: 리뷰 반영(management port 충돌 문제 해결, MySQL 경로 버그 문제 해결) * fix: 액츄에이터 노출 & dev/prod 스크립트 일치 반영 * fix: 레거시 액추에이터 노출 포트와 일치시키기 --- .github/workflows/dev-cd.yml | 4 ++-- .github/workflows/prod-cd.yml | 4 ++-- docker-compose.dev.yml | 2 +- docker-compose.prod.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index b75d2a18d..60ef98b42 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -122,9 +122,9 @@ jobs: # 1. Active 슬롯 확인 (upstream.conf 기준) UPSTREAM_PORT=$(grep -oE "server 127\.0\.0\.1:[0-9]+" /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE "[0-9]+$" || echo "8081") if [ "$UPSTREAM_PORT" = "8080" ]; then - ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=8081; MANAGEMENT_PORT=9081 + ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=9080; MANAGEMENT_PORT=9081 else - ACTIVE_SLOT="green"; ACTIVE_PORT=8081; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=9080 + ACTIVE_SLOT="green"; ACTIVE_PORT=9080; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=8081 fi echo "Active: ${ACTIVE_SLOT}(${ACTIVE_PORT}) → Deploy: ${NEW_SLOT}(${NEW_PORT}), management: ${MANAGEMENT_PORT}" diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index bb31d5b7d..606f976a5 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -138,9 +138,9 @@ jobs: # 1. Active 슬롯 확인 (upstream.conf 기준) UPSTREAM_PORT=$(grep -oE "server 127\.0\.0\.1:[0-9]+" /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE "[0-9]+$" || echo "8081") if [ "$UPSTREAM_PORT" = "8080" ]; then - ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=8081; MANAGEMENT_PORT=9081 + ACTIVE_SLOT="blue"; ACTIVE_PORT=8080; NEW_SLOT="green"; NEW_PORT=9080; MANAGEMENT_PORT=9081 else - ACTIVE_SLOT="green"; ACTIVE_PORT=8081; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=9080 + ACTIVE_SLOT="green"; ACTIVE_PORT=9080; NEW_SLOT="blue"; NEW_PORT=8080; MANAGEMENT_PORT=8081 fi echo "Active: ${ACTIVE_SLOT}(${ACTIVE_PORT}) → Deploy: ${NEW_SLOT}(${NEW_PORT}), management: ${MANAGEMENT_PORT}" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f2cd9b9e5..6fda6d478 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: environment: - SPRING_PROFILES_ACTIVE=dev - SERVER_PORT=${APP_PORT:-8080} - - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-9080} + - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-8081} - AWS_REGION=ap-northeast-2 - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 57363231b..d9879f922 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -8,7 +8,7 @@ services: environment: - SPRING_PROFILES_ACTIVE=prod - SERVER_PORT=${APP_PORT:-8080} - - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-9080} + - MANAGEMENT_SERVER_PORT=${MANAGEMENT_PORT:-8081} - AWS_REGION=ap-northeast-2 - SPRING_DATA_REDIS_HOST=127.0.0.1 - SPRING_DATA_REDIS_PORT=6379 From aedf4bc27390c4e75bea7469334e755789aac198 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:34:43 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20grep=20=EC=BF=BC=ED=8C=85=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#757)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: prod/stage 환경 nginx 블루/그린 배포 방식 도입 * fix: 리뷰 반영(management port 충돌 문제 해결, MySQL 경로 버그 문제 해결) * fix: 액츄에이터 노출 & dev/prod 스크립트 일치 반영 * fix: 레거시 액추에이터 노출 포트와 일치시키기 From bf0b7b207c97993615103d8a7cc2ca5922b886eb Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:53:51 +0900 Subject: [PATCH 07/12] =?UTF-8?q?fix:=20=ED=97=AC=EC=8A=A4=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20grep=20=EC=BF=BC=ED=8C=85=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#758)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor: 액추에이터 포트 검사 방식 변경(status:UP -> http_status_code) --- .github/workflows/dev-cd.yml | 5 +++-- .github/workflows/prod-cd.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 60ef98b42..745fc4d93 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -153,10 +153,11 @@ jobs: docker compose -p "${CONTAINER_BASE}-${NEW_SLOT}" -f docker-compose.dev.yml up -d solid-connection-dev # 8. 헬스 체크 (앱 기동 대기, 최대 150초) + # HTTP 200 = UP, 503 = DOWN (single-quote 내부 quoting 문제 없이 상태코드로 판단) echo "Waiting for app on management port ${MANAGEMENT_PORT}..." for i in $(seq 1 30); do - STATUS=$(curl -s --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" | grep -o '"status":"UP"' || true) - [ "$STATUS" = '"status":"UP"' ] && { echo "App healthy (attempt ${i})"; break; } + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" || true) + [ "$HTTP_CODE" = "200" ] && { echo "App healthy (attempt ${i})"; break; } [ "$i" = "30" ] && { echo "Health check timed out after 150s" >&2 docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 606f976a5..919491ac7 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -164,10 +164,11 @@ jobs: docker compose -p "${CONTAINER_BASE}-${NEW_SLOT}" -f docker-compose.prod.yml up -d solid-connection-server # 6. 헬스 체크 (앱 기동 대기, 최대 150초) + # HTTP 200 = UP, 503 = DOWN (single-quote 내부 quoting 문제 없이 상태코드로 판단) echo "Waiting for app on management port ${MANAGEMENT_PORT}..." for i in $(seq 1 30); do - STATUS=$(curl -s --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" | grep -o '"status":"UP"' || true) - [ "$STATUS" = '"status":"UP"' ] && { echo "App healthy (attempt ${i})"; break; } + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 2 "http://localhost:${MANAGEMENT_PORT}/actuator/health" || true) + [ "$HTTP_CODE" = "200" ] && { echo "App healthy (attempt ${i})"; break; } [ "$i" = "30" ] && { echo "Health check timed out after 150s" >&2 docker stop "${CONTAINER_BASE}-${NEW_SLOT}" 2>/dev/null || true From 8977b286058075102de49b2354197607baf9dc03 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:57:07 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20Blue/Green=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=8B=9C=20Prometheus=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20?= =?UTF-8?q?=ED=83=80=EA=B2=9F=20=EC=9E=90=EB=8F=99=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#763)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CD 과정에서 블루/그린 환경 변화에 따른 Promethus actuator port 동적 변경 * refactor: hostname -I SSH 호출 로직 제거 * fix: 파싱 결과가 8080/9080이 아닌 경우 명시적 실패 처리 --- .github/workflows/dev-cd.yml | 22 ++++++++++++++++++++++ .github/workflows/prod-cd.yml | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 745fc4d93..7942d0fd0 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -177,3 +177,25 @@ jobs: echo "Deployment complete. Active: ${NEW_SLOT}(${NEW_PORT})" ' + + - name: Update Prometheus scrape target (stage) + run: | + echo "${{ secrets.MONITORING_PRIVATE_KEY }}" > monitoring_key.pem + chmod 600 monitoring_key.pem + + UPSTREAM_PORT=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}" \ + "grep -oE 'server 127\.0\.0\.1:[0-9]+' /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE '[0-9]+$'") + + if [ "$UPSTREAM_PORT" != "8080" ] && [ "$UPSTREAM_PORT" != "9080" ]; then + echo "Unexpected UPSTREAM_PORT: '${UPSTREAM_PORT}'" >&2 + exit 1 + fi + + if [ "$UPSTREAM_PORT" = "8080" ]; then NEW_MGMT_PORT=8081; else NEW_MGMT_PORT=9081; fi + + ssh -i monitoring_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.MONITORING_USERNAME }}@${{ secrets.MONITORING_HOST }}" \ + "echo '[{\"targets\":[\"${{ secrets.DEV_HOST }}:${NEW_MGMT_PORT}\"]}]' \ + | tee ~/solid-connection-monitor/prometheus/targets/stage.json > /dev/null \ + && echo 'Prometheus target updated: ${{ secrets.DEV_HOST }}:${NEW_MGMT_PORT}'" diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 919491ac7..03025ab5f 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -188,3 +188,25 @@ jobs: echo "Deployment complete. Active: ${NEW_SLOT}(${NEW_PORT})" ' + + - name: Update Prometheus scrape target (prod) + run: | + echo "${{ secrets.MONITORING_PRIVATE_KEY }}" > monitoring_key.pem + chmod 600 monitoring_key.pem + + UPSTREAM_PORT=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.USERNAME }}@${{ secrets.HOST }}" \ + "grep -oE 'server 127\.0\.0\.1:[0-9]+' /etc/nginx/conf.d/upstream.conf 2>/dev/null | grep -oE '[0-9]+$'") + + if [ "$UPSTREAM_PORT" != "8080" ] && [ "$UPSTREAM_PORT" != "9080" ]; then + echo "Unexpected UPSTREAM_PORT: '${UPSTREAM_PORT}'" >&2 + exit 1 + fi + + if [ "$UPSTREAM_PORT" = "8080" ]; then NEW_MGMT_PORT=8081; else NEW_MGMT_PORT=9081; fi + + ssh -i monitoring_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.MONITORING_USERNAME }}@${{ secrets.MONITORING_HOST }}" \ + "echo '[{\"targets\":[\"${{ secrets.HOST }}:${NEW_MGMT_PORT}\"]}]' \ + | tee ~/solid-connection-monitor/prometheus/targets/prod.json > /dev/null \ + && echo 'Prometheus target updated: ${{ secrets.HOST }}:${NEW_MGMT_PORT}'" From c3e95213c4e524e223091ec77dff3d5be19d436f Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:15:20 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20Prometheus=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=9E=A9=20=ED=83=80=EA=B2=9F=EC=9D=84=20EC2=20=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B9=97=20IP=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20(#767)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Promethus target을 public 엔드포인트가 아닌 private IP로 수정 * fix: IMDSv2 반영 및 PRIVATE_IP 빈값 검증 및 명시적 실패 로직 추가 --- .github/workflows/dev-cd.yml | 13 +++++++++++-- .github/workflows/prod-cd.yml | 13 +++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 7942d0fd0..714edfc4d 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -194,8 +194,17 @@ jobs: if [ "$UPSTREAM_PORT" = "8080" ]; then NEW_MGMT_PORT=8081; else NEW_MGMT_PORT=9081; fi + PRIVATE_IP=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.DEV_USERNAME }}@${{ secrets.DEV_HOST }}" \ + "TOKEN=\$(curl -sf -X PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600') && curl -sf -H \"X-aws-ec2-metadata-token: \$TOKEN\" http://169.254.169.254/latest/meta-data/local-ipv4") + + if [ -z "$PRIVATE_IP" ]; then + echo "Failed to retrieve private IP" >&2 + exit 1 + fi + ssh -i monitoring_key.pem -o StrictHostKeyChecking=no \ "${{ secrets.MONITORING_USERNAME }}@${{ secrets.MONITORING_HOST }}" \ - "echo '[{\"targets\":[\"${{ secrets.DEV_HOST }}:${NEW_MGMT_PORT}\"]}]' \ + "echo '[{\"targets\":[\"${PRIVATE_IP}:${NEW_MGMT_PORT}\"]}]' \ | tee ~/solid-connection-monitor/prometheus/targets/stage.json > /dev/null \ - && echo 'Prometheus target updated: ${{ secrets.DEV_HOST }}:${NEW_MGMT_PORT}'" + && echo 'Prometheus target updated: ${PRIVATE_IP}:${NEW_MGMT_PORT}'" diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 03025ab5f..1e60d0db4 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -205,8 +205,17 @@ jobs: if [ "$UPSTREAM_PORT" = "8080" ]; then NEW_MGMT_PORT=8081; else NEW_MGMT_PORT=9081; fi + PRIVATE_IP=$(ssh -i deploy_key.pem -o StrictHostKeyChecking=no \ + "${{ secrets.USERNAME }}@${{ secrets.HOST }}" \ + "TOKEN=\$(curl -sf -X PUT 'http://169.254.169.254/latest/api/token' -H 'X-aws-ec2-metadata-token-ttl-seconds: 21600') && curl -sf -H \"X-aws-ec2-metadata-token: \$TOKEN\" http://169.254.169.254/latest/meta-data/local-ipv4") + + if [ -z "$PRIVATE_IP" ]; then + echo "Failed to retrieve private IP" >&2 + exit 1 + fi + ssh -i monitoring_key.pem -o StrictHostKeyChecking=no \ "${{ secrets.MONITORING_USERNAME }}@${{ secrets.MONITORING_HOST }}" \ - "echo '[{\"targets\":[\"${{ secrets.HOST }}:${NEW_MGMT_PORT}\"]}]' \ + "echo '[{\"targets\":[\"${PRIVATE_IP}:${NEW_MGMT_PORT}\"]}]' \ | tee ~/solid-connection-monitor/prometheus/targets/prod.json > /dev/null \ - && echo 'Prometheus target updated: ${{ secrets.HOST }}:${NEW_MGMT_PORT}'" + && echo 'Prometheus target updated: ${PRIVATE_IP}:${NEW_MGMT_PORT}'" From 70d8febe4a351c24790491bcaa79ab2806f268aa Mon Sep 17 00:00:00 2001 From: Yeon <84384499+lsy1307@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:31:48 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EB=8C=80=ED=95=99=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#760)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 관리자 대학 이미지 업로드 경로 추가 - 관리자 대학 로고와 배경 이미지 업로드 API를 추가 - 대학 영문명을 서버에서 업로드 디렉토리명으로 변환 - S3 업로드 서비스에 동적 하위 디렉토리 경로 생성을 추가 * feat: 관리자 대학 이미지 업로드 테스트 추가 - 대학 영문명 디렉토리명 변환 테스트를 추가 - 동적 하위 디렉토리 기반 S3 업로드 경로 테스트를 추가 * feat: 대학 이미지 업로드 경로 충돌 방지 - 대학 영문명 디렉토리명에 원본 영문명 해시를 추가 - 파견 대학 영문명 중복 검증과 유니크 제약을 추가 - 정규화 충돌과 영문명 중복 검증 테스트를 추가 * feat: 이미지 전용 업로드 확장자 제한 - 업로드 경로별 이미지 전용 검증 정책을 추가 - 관리자 대학 이미지 업로드에서 문서 확장자를 차단 - 증빙 파일 업로드의 문서 확장자 허용 동작을 테스트로 보장 --- .../service/AdminHostUniversityService.java | 18 +++++ .../s3/controller/S3Controller.java | 35 ++++++++++ .../s3/domain/UploadDirectoryName.java | 45 ++++++++++++ .../solidconnection/s3/domain/UploadPath.java | 32 ++++++--- .../solidconnection/s3/service/S3Service.java | 17 ++++- .../university/domain/HostUniversity.java | 2 +- .../repository/HostUniversityRepository.java | 2 + ...traint_to_host_university_english_name.sql | 2 + .../AdminHostUniversityServiceTest.java | 49 +++++++++++++ .../s3/domain/UploadDirectoryNameTest.java | 69 +++++++++++++++++++ .../s3/service/S3ServiceDynamicPathTest.java | 57 +++++++++++++++ .../s3/service/S3ServiceTest.java | 40 +++++++++++ 12 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java create mode 100644 src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql create mode 100644 src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java create mode 100644 src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java index 416ba8fb8..7f96f3c4e 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java @@ -67,6 +67,7 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) { ) public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) { validateKoreanNameNotExists(request.koreanName()); + validateEnglishNameNotExists(request.englishName()); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -97,6 +98,13 @@ private void validateKoreanNameNotExists(String koreanName) { }); } + private void validateEnglishNameNotExists(String englishName) { + hostUniversityRepository.findByEnglishName(englishName) + .ifPresent(existingUniversity -> { + throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); + }); + } + @Transactional @DefaultCacheOut( key = {"univApplyInfoTextSearch", "university:recommend:general"}, @@ -108,6 +116,7 @@ public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHost .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); validateKoreanNameNotDuplicated(request.koreanName(), id); + validateEnglishNameNotDuplicated(request.englishName(), id); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -140,6 +149,15 @@ private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) }); } + private void validateEnglishNameNotDuplicated(String englishName, Long excludeId) { + hostUniversityRepository.findByEnglishName(englishName) + .ifPresent(existingUniversity -> { + if (!existingUniversity.getId().equals(excludeId)) { + throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); + } + }); + } + private Country findCountryByCode(String countryCode) { return countryRepository.findByCode(countryCode) .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); diff --git a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java index 8e98c863b..3b5832718 100644 --- a/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java +++ b/src/main/java/com/example/solidconnection/s3/controller/S3Controller.java @@ -1,10 +1,13 @@ package com.example.solidconnection.s3.controller; import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.s3.domain.UploadDirectoryName; import com.example.solidconnection.s3.domain.UploadPath; import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; import com.example.solidconnection.s3.dto.UrlPrefixResponse; import com.example.solidconnection.s3.service.S3Service; +import com.example.solidconnection.security.annotation.RequireRoleAccess; +import com.example.solidconnection.siteuser.domain.Role; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -77,6 +80,38 @@ public ResponseEntity> uploadChatFile( return ResponseEntity.ok(chatImageUrls); } + @RequireRoleAccess(roles = Role.ADMIN) + @PostMapping("/admin/university/logo") + public ResponseEntity uploadAdminUniversityLogo( + @AuthorizedUser long adminId, + @RequestParam("file") MultipartFile imageFile, + @RequestParam("englishName") String englishName + ) { + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + UploadedFileUrlResponse logoImageUrl = s3Service.uploadFile( + imageFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + directoryName + ); + return ResponseEntity.ok(logoImageUrl); + } + + @RequireRoleAccess(roles = Role.ADMIN) + @PostMapping("/admin/university/background") + public ResponseEntity uploadAdminUniversityBackground( + @AuthorizedUser long adminId, + @RequestParam("file") MultipartFile imageFile, + @RequestParam("englishName") String englishName + ) { + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + UploadedFileUrlResponse backgroundImageUrl = s3Service.uploadFile( + imageFile, + UploadPath.ADMIN_UNIVERSITY_BACKGROUND, + directoryName + ); + return ResponseEntity.ok(backgroundImageUrl); + } + @GetMapping("/s3-url-prefix") public ResponseEntity getS3UrlPrefix() { return ResponseEntity.ok(new UrlPrefixResponse(s3Default, s3Uploaded, cloudFrontDefault, cloudFrontUploaded)); diff --git a/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java new file mode 100644 index 000000000..4b53396dc --- /dev/null +++ b/src/main/java/com/example/solidconnection/s3/domain/UploadDirectoryName.java @@ -0,0 +1,45 @@ +package com.example.solidconnection.s3.domain; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class UploadDirectoryName { + + private static final int HASH_PREFIX_LENGTH = 12; + + private UploadDirectoryName() { + } + + public static String fromUniversityEnglishName(String englishName) { + if (englishName == null || englishName.isBlank()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + String directoryName = englishName.trim() + .toLowerCase() + .replaceAll("\\s*&\\s*", "_and_") + .replaceAll("\\s+", "_") + .replaceAll("_+", "_") + .replaceAll("[^a-z0-9_-]", ""); + + if (directoryName.isBlank()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + + return directoryName + "_" + hash(englishName.trim()); + } + + private static String hash(String value) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + byte[] digest = messageDigest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest).substring(0, HASH_PREFIX_LENGTH); + } catch (NoSuchAlgorithmException e) { + throw new CustomException(ErrorCode.NOT_DEFINED_ERROR); + } + } +} diff --git a/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java b/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java index d94ed1fb8..b7afabbcc 100644 --- a/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java +++ b/src/main/java/com/example/solidconnection/s3/domain/UploadPath.java @@ -3,23 +3,28 @@ import com.example.solidconnection.common.constant.FileConstants; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; +import java.util.List; import lombok.Getter; @Getter public enum UploadPath { - PROFILE("profile"), - GPA("gpa"), - LANGUAGE_TEST("language"), - COMMUNITY("community"), - NEWS("news"), - CHAT("chat/files"), - MENTOR_PROOF("mentor-proof"), + PROFILE("profile", true), + GPA("gpa", false), + LANGUAGE_TEST("language", false), + COMMUNITY("community", true), + NEWS("news", true), + CHAT("chat/files", false), + MENTOR_PROOF("mentor-proof", false), + ADMIN_UNIVERSITY_LOGO("admin/logo", true), + ADMIN_UNIVERSITY_BACKGROUND("admin/background", true) ; private final String type; + private final boolean imageOnly; - UploadPath(String type) { + UploadPath(String type, boolean imageOnly) { this.type = type; + this.imageOnly = imageOnly; } public boolean isResizable(long fileSize, String extension, long maxSizeBytes) { @@ -35,7 +40,7 @@ public boolean isResizable(long fileSize, String extension, long maxSizeBytes) { } public void validateExtension(String extension) { - if (extension == null || !FileConstants.ALL_ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) { + if (extension == null || !getAllowedExtensions().contains(extension.toLowerCase())) { throw new CustomException(ErrorCode.NOT_ALLOWED_FILE_EXTENSIONS, "허용된 형식: " + getAllowedExtensionsMessage()); } @@ -46,6 +51,13 @@ public boolean isImage(String extension) { } public String getAllowedExtensionsMessage() { - return String.join(", ", FileConstants.ALL_ALLOWED_EXTENSIONS); + return String.join(", ", getAllowedExtensions()); + } + + private List getAllowedExtensions() { + if (imageOnly) { + return FileConstants.IMAGE_EXTENSIONS; + } + return FileConstants.ALL_ALLOWED_EXTENSIONS; } } diff --git a/src/main/java/com/example/solidconnection/s3/service/S3Service.java b/src/main/java/com/example/solidconnection/s3/service/S3Service.java index 6dc3004e6..b5ad88d85 100644 --- a/src/main/java/com/example/solidconnection/s3/service/S3Service.java +++ b/src/main/java/com/example/solidconnection/s3/service/S3Service.java @@ -50,12 +50,20 @@ public class S3Service { * - 5mb 미만의 파일은 바로 업로드한다. * */ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPath uploadPath) { + return uploadFile(multipartFile, uploadPath, null); + } + + public UploadedFileUrlResponse uploadFile( + MultipartFile multipartFile, + UploadPath uploadPath, + String subDirectory + ) { validateFile(multipartFile, uploadPath); UUID randomUUID = UUID.randomUUID(); String extension = getFileExtension(Objects.requireNonNull(multipartFile.getOriginalFilename())); String baseFileName = randomUUID + "." + extension; - String fileName = uploadPath.getType() + "/" + baseFileName; + String fileName = createFileName(uploadPath, subDirectory, baseFileName); final boolean shouldResize = uploadPath.isResizable( multipartFile.getSize(), extension, MAX_FILE_SIZE_MB); @@ -73,6 +81,13 @@ public UploadedFileUrlResponse uploadFile(MultipartFile multipartFile, UploadPat return new UploadedFileUrlResponse(returnPath); } + private String createFileName(UploadPath uploadPath, String subDirectory, String baseFileName) { + if (subDirectory == null || subDirectory.isBlank()) { + return uploadPath.getType() + "/" + baseFileName; + } + return uploadPath.getType() + "/" + subDirectory + "/" + baseFileName; + } + private byte[] extractBytes(MultipartFile file) { try { return file.getBytes(); 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..de80a281f 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, unique = true, length = 100) private String englishName; @Column(name = "format_name", nullable = false, length = 100) diff --git a/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java index 3fa80629a..4264ed04d 100644 --- a/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java @@ -16,4 +16,6 @@ default HostUniversity getHostUniversityById(Long id) { } Optional findByKoreanName(String koreanName); + + Optional findByEnglishName(String englishName); } diff --git a/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql b/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql new file mode 100644 index 000000000..3dc3a5d66 --- /dev/null +++ b/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE host_university + ADD CONSTRAINT uk_host_university_english_name UNIQUE (english_name); diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java index 5e2bbbf1d..a97303465 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java @@ -256,6 +256,31 @@ class 생성 { .isInstanceOf(CustomException.class) .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + + @Test + void 이미_존재하는_영문명으로_생성하면_예외_응답을_반환한다() { + // given + HostUniversity existing = universityFixture.괌_대학(); + Country country = countryFixture.미국(); + Region region = regionFixture.영미권(); + + AdminHostUniversityCreateRequest request = new AdminHostUniversityCreateRequest( + "신규 대학", + existing.getEnglishName(), + "표시명", + null, null, null, + "https://logo.com/image.png", + "https://background.com/image.png", + null, + country.getCode(), + region.getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.createHostUniversity(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + } } @Nested @@ -341,6 +366,30 @@ class 수정 { .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); } + @Test + void 다른_대학의_영문명으로_수정하면_예외_응답을_반환한다() { + // given + HostUniversity university1 = universityFixture.괌_대학(); + HostUniversity university2 = universityFixture.메이지_대학(); + + AdminHostUniversityUpdateRequest request = new AdminHostUniversityUpdateRequest( + university1.getKoreanName(), + university2.getEnglishName(), + "수정된 표시명", + null, null, null, + "https://logo.com/image.png", + "https://background.com/image.png", + null, + university1.getCountry().getCode(), + university1.getRegion().getCode() + ); + + // when & then + assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + } + @Test void 같은_대학의_한글명으로_수정하면_성공한다() { // given diff --git a/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java new file mode 100644 index 000000000..041b7c42f --- /dev/null +++ b/src/test/java/com/example/solidconnection/s3/domain/UploadDirectoryNameTest.java @@ -0,0 +1,69 @@ +package com.example.solidconnection.s3.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.example.solidconnection.common.exception.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("업로드 디렉토리명 테스트") +class UploadDirectoryNameTest { + + @Nested + class 대학_영문명_변환_테스트 { + + @Test + void 대학_영문명의_공백을_언더스코어로_변환한다() { + // given + String englishName = "University of Tokyo"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + + // then + assertThat(directoryName) + .startsWith("university_of_tokyo_") + .matches("university_of_tokyo_[0-9a-f]{12}"); + } + + @Test + void 특수문자를_제거하고_앰퍼샌드는_and로_변환한다() { + // given + String englishName = "Texas A&M University, Austin"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + + // then + assertThat(directoryName) + .startsWith("texas_a_and_m_university_austin_") + .matches("texas_a_and_m_university_austin_[0-9a-f]{12}"); + } + + @Test + void 같은_slug로_변환되는_서로_다른_영문명은_다른_디렉토리명을_반환한다() { + // given + String englishName = "Texas A&M University"; + String normalizedCollisionName = "Texas A and M University"; + + // when + String directoryName = UploadDirectoryName.fromUniversityEnglishName(englishName); + String collisionDirectoryName = UploadDirectoryName.fromUniversityEnglishName(normalizedCollisionName); + + // then + assertThat(directoryName).isNotEqualTo(collisionDirectoryName); + } + + @Test + void 공백_문자열이면_예외가_발생한다() { + // given + String blankName = " "; + + // when & then + assertThatThrownBy(() -> UploadDirectoryName.fromUniversityEnglishName(blankName)) + .isInstanceOf(CustomException.class); + } + } +} diff --git a/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java b/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java new file mode 100644 index 000000000..679d9ef77 --- /dev/null +++ b/src/test/java/com/example/solidconnection/s3/service/S3ServiceDynamicPathTest.java @@ -0,0 +1,57 @@ +package com.example.solidconnection.s3.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.s3.domain.UploadPath; +import com.example.solidconnection.s3.dto.UploadedFileUrlResponse; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.services.s3.S3Client; + +@DisplayName("S3 서비스 동적 업로드 경로 테스트") +@ExtendWith(MockitoExtension.class) +class S3ServiceDynamicPathTest { + + @InjectMocks + private S3Service s3Service; + + @Mock + private S3Client s3Client; + + @Mock + private SiteUserRepository siteUserRepository; + + @Mock + private FileUploadService fileUploadService; + + @Nested + class 동적_하위_디렉토리_업로드_테스트 { + + @Test + void 업로드_경로와_파일명_사이에_동적_하위_디렉토리를_포함한다() { + // given + MockMultipartFile file = new MockMultipartFile("file", "logo.png", "image/png", new byte[100]); + + // when + UploadedFileUrlResponse response = s3Service.uploadFile( + file, + UploadPath.ADMIN_UNIVERSITY_LOGO, + "university_of_tokyo" + ); + + // then + assertAll( + () -> assertThat(response.fileUrl()).startsWith("admin/logo/university_of_tokyo/"), + () -> assertThat(response.fileUrl()).endsWith(".png") + ); + } + } +} diff --git a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java index c4485858a..543aa924f 100644 --- a/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java +++ b/src/test/java/com/example/solidconnection/s3/service/S3ServiceTest.java @@ -110,5 +110,45 @@ class 파일_검증 { .doesNotThrowAnyException() ); } + + @Test + void 이미지_전용_업로드_경로는_문서_확장자를_허용하지_않는다() { + // given + MockMultipartFile pdfFile = createMockFile("logo.pdf", 100); + + // when & then + assertThatThrownBy(() -> s3Service.uploadFile( + pdfFile, + UploadPath.ADMIN_UNIVERSITY_LOGO, + "university_of_tokyo" + )) + .isInstanceOf(CustomException.class) + .hasMessageContaining("허용된 형식"); + } + + @Test + void 멘토_증빙_업로드는_이미지_외의_허용된_문서_확장자도_검증을_통과한다() { + // given + MockMultipartFile pdfFile = createMockFile("proof.pdf", 100); + + // when & then + assertThatCode(() -> s3Service.uploadFile(pdfFile, UploadPath.MENTOR_PROOF)) + .doesNotThrowAnyException(); + } + + @Test + void 성적_증빙_업로드는_이미지_외의_허용된_문서_확장자도_검증을_통과한다() { + // given + MockMultipartFile gpaPdfFile = createMockFile("gpa.pdf", 100); + MockMultipartFile languageTestPdfFile = createMockFile("language-test.pdf", 100); + + // when & then + assertAll( + () -> assertThatCode(() -> s3Service.uploadFile(gpaPdfFile, UploadPath.GPA)) + .doesNotThrowAnyException(), + () -> assertThatCode(() -> s3Service.uploadFile(languageTestPdfFile, UploadPath.LANGUAGE_TEST)) + .doesNotThrowAnyException() + ); + } } } From f0acacb4a72af01e4e9142ae14c01576a5b3a482 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:41:39 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A7=80=EC=9B=90=20=EB=8C=80=ED=95=99?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20(#755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 모의지원 최대 지망 수를 동적으로 관리하도록 (#746) * 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: 최대 지망 대학임을 바로 알 수 있도록 메서드명 변경 * feat: 컬럼 매핑 alias enum class 작성 * feat: 마크다운 표 파서 클래스 추가 * feat: DTO 작성 * refactor: deprecated 컬럼 제거 * feat: 서비스 계층 로직 작성 * feat: 지원 대학 정보 삽입 관련 로직 추가 * feat: 컨트롤러 작성 * fix: 중간 빈 셀이 있는 경우 정상적으로 저장이 되지 않던 문제 수정 * chore: 어드민 관련 계정 추가 외 - admin@test.email - Admin@1234 - 로컬 어드민 웹에 대한 접근 허용 * feat: 학기 추가, 조회, 현재 학기 설정 관련 어드민 API 추가 * refactor: 현재 학기 설정 관련 무결성 제약조건 위반하지 않도록 Modifying 쿼리 사용 * refactor: 누락 필드 임시 추가 - 추후 제거 예정 * feat: host_university 테이블 컬럼 또한 입력으로 받도록 - host_university가 없는 경우 추가해줘야 한다. * fix: 컬럼명 오타 수정 * feat: 세부 레코드 삽입 실패 이유까지 전달하도록 * feat: 셀 단위 타입·열거형·길이 제약 검증 추가 * test: enum 변환 실패 시 행 실패 동작으로 테스트 수정 * test: 셀 단위 타입·길이 검증 테스트 추가 * fix: 마크다운 파서에서 이스케이프된 파이프 문자 처리 * refactor: 지원 대학 삽입 응답에서 각 셀 검증 제거 * test: 각 셀 검증 제거에 따른 테스트 수정 * refactor: 학기 이름 관련 변수명 수정 - label -> name * refactor: 불명확한 변수명 수정 - 활성화할 학기에 대한 변수 이름을 termToActivate로 수정한다. * refactor: 부분 실패 대신 전체 실패 * chore: 국가 코드 추가 * refactor: 비어있는 키-값에 대한 검증 추가 * refactor: 컬럼 길이 제한 수정 - 영문 대학명: 200자 - 기숙사, 어학 세부 조건: 2000자 * refactor: 코드래빗 리뷰 반영 * refactor: 컨벤션 위반 항목 전면 수정 * chore: 두 스크립트 하나로 병합 * feat: 지원 대학 추가 시 캐시 무효화 * chore: 목데이터 수정 * chore: 어드민 기능 테스트 관련 목데이터 추가 --- .../term/controller/AdminTermController.java | 47 +++ .../term/dto/AdminTermCreateRequest.java | 11 + .../admin/term/dto/AdminTermResponse.java | 18 + .../admin/term/service/AdminTermService.java | 50 +++ .../AdminUnivApplyInfoController.java | 34 ++ .../dto/AdminHomeUniversityUpdateRequest.java | 1 - .../dto/AdminHostUniversityCreateRequest.java | 2 +- .../dto/AdminHostUniversityUpdateRequest.java | 2 +- .../dto/UnivApplyInfoFieldResponse.java | 22 + .../dto/UnivApplyInfoImportRequest.java | 21 + .../dto/UnivApplyInfoImportResponse.java | 9 + .../service/AdminUnivApplyInfoRowSaver.java | 226 ++++++++++ .../service/AdminUnivApplyInfoService.java | 78 ++++ .../service/ApplicationSubmissionService.java | 11 +- .../common/exception/ErrorCode.java | 5 + .../common/util/MarkdownTableParser.java | 52 +++ .../custom/SiteUserFilterRepositoryImpl.java | 7 +- .../solidconnection/term/domain/Term.java | 8 + .../term/repository/TermRepository.java | 6 + .../university/domain/HostUniversity.java | 2 +- .../university/domain/UnivApplyInfo.java | 10 +- .../domain/UnivApplyInfoColumn.java | 33 ++ .../resources/config/application-variable.yml | 1 + src/main/resources/data.sql | 72 +++- ..._extend_univ_apply_info_import_columns.sql | 13 + .../AdminUnivApplyInfoServiceTest.java | 397 ++++++++++++++++++ .../common/util/MarkdownTableParserTest.java | 127 ++++++ 27 files changed, 1248 insertions(+), 17 deletions(-) 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 create mode 100644 src/main/java/com/example/solidconnection/admin/university/controller/AdminUnivApplyInfoController.java 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 create mode 100644 src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java create mode 100644 src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java create mode 100644 src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java create mode 100644 src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java create mode 100644 src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql create mode 100644 src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java create mode 100644 src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.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..4c6077367 --- /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..583eb4f50 --- /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 name, + 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..7ff19e616 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/term/service/AdminTermService.java @@ -0,0 +1,50 @@ +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 termToActivate = termRepository.findById(id) + .orElseThrow(() -> new CustomException(TERM_NOT_FOUND)); + if (!Boolean.TRUE.equals(termToActivate.getIsCurrent())) { + termRepository.deactivateCurrentTerm(); + } + termToActivate.activate(); + return AdminTermResponse.from(termToActivate); + } +} 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)); + } +} 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 d505ab8af..b96118cb0 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 @@ -19,7 +19,6 @@ public record AdminHomeUniversityUpdateRequest( message = "올바른 이메일 도메인 형식이 아닙니다 (예: inha.edu, inu.ac.kr)" ) String emailDomain - ) { } 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/dto/UnivApplyInfoFieldResponse.java b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java new file mode 100644 index 000000000..3b4f71197 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoFieldResponse.java @@ -0,0 +1,22 @@ +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 fields, + List languageTestTypes +) { + + public static UnivApplyInfoFieldResponse of() { + List fields = Arrays.stream(UnivApplyInfoColumn.values()) + .map(UnivApplyInfoColumn::getFieldName) + .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..e28dec9e2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportRequest.java @@ -0,0 +1,21 @@ +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; + +public record UnivApplyInfoImportRequest( + @NotNull(message = "학기는 필수입니다") + Long termId, + + @NotNull(message = "대학은 필수입니다") + Long homeUniversityId, + + @NotBlank(message = "마크다운 텍스트는 필수입니다") + String markdown, + + @NotEmpty(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..43eb10f09 --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/dto/UnivApplyInfoImportResponse.java @@ -0,0 +1,9 @@ +package com.example.solidconnection.admin.university.dto; + +import java.util.List; + +public record UnivApplyInfoImportResponse( + int successCount, + List createdUniversities +) { +} 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..d4591ff3e --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoRowSaver.java @@ -0,0 +1,226 @@ +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.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; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +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.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminUnivApplyInfoRowSaver { + + private final HostUniversityRepository hostUniversityRepository; + private final UnivApplyInfoRepository univApplyInfoRepository; + private final CountryRepository countryRepository; + private final RegionRepository regionRepository; + + @Transactional + public String save( + Map rowData, + Map columnMappings, + HomeUniversity homeUniversity, + long termId + ) { + ImportData data = buildImportData(rowData, columnMappings); + + boolean existed = hostUniversityRepository.findByKoreanName(data.universityKoreanName).isPresent(); + HostUniversity hostUniversity = findOrCreateHostUniversity(data); + String createdUniversityName = existed ? null : hostUniversity.getKoreanName(); + + UnivApplyInfo univApplyInfo = new UnivApplyInfo( + null, + termId, + homeUniversity, + data.universityKoreanName, + data.studentCapacity, + data.tuitionFeeType, + data.semesterAvailableForDispatch, + data.semesterRequirement, + data.detailsForLanguage, + data.gpaRequirement, + data.gpaRequirementCriteria, + data.detailsForApply, + data.detailsForMajor, + data.detailsForAccommodation, + data.detailsForEnglishCourse, + data.details, + 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); + }); + + return createdUniversityName; + } + + 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) { + } + + applyStructuredField(data, header, targetField, value); + } + + private void applyStructuredField(ImportData data, String header, String fieldName, String value) { + switch (fieldName) { + case "universityKoreanName" -> applyWithLength(value, 100, s -> data.universityKoreanName = 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); + 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) { + throw new CustomException(INVALID_INPUT, "선발 인원은 정수여야 합니다: '" + value + "'"); + } + } + case "tuitionFeeType" -> { + try { + data.tuitionFeeType = TuitionFeeType.valueOf(value); + } catch (IllegalArgumentException e) { + throw new CustomException(INVALID_INPUT, + "유효하지 않은 등록금 유형입니다. 가능한 값: " + validEnumValues(TuitionFeeType.values())); + } + } + case "semesterAvailableForDispatch" -> { + try { + data.semesterAvailableForDispatch = SemesterAvailableForDispatch.valueOf(value); + } catch (IllegalArgumentException e) { + throw new CustomException(INVALID_INPUT, + "유효하지 않은 파견 가능 학기입니다. 가능한 값: " + validEnumValues(SemesterAvailableForDispatch.values())); + } + } + case "semesterRequirement" -> applyWithLength(value, 100, s -> data.semesterRequirement = 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, 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, 3000, s -> data.details = s); + default -> data.extraInfo.put(header, value); + } + } + + private void applyWithLength(String value, int maxLength, Consumer setter) { + if (value.length() > maxLength) { + throw new CustomException(INVALID_INPUT, + "값이 최대 길이(" + maxLength + "자)를 초과했습니다: " + value.length() + "자"); + } + setter.accept(value); + } + + private String validEnumValues(Enum[] values) { + return Arrays.stream(values) + .map(Enum::name) + .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; + 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/admin/university/service/AdminUnivApplyInfoService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java new file mode 100644 index 000000000..630c301dc --- /dev/null +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoService.java @@ -0,0 +1,78 @@ +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; +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; +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 + @DefaultCacheOut( + key = {"univApplyInfoTextSearch", "university:recommend:general"}, + cacheManager = "customCacheManager", + prefix = true + ) + public UnivApplyInfoImportResponse importUnivApplyInfos(UnivApplyInfoImportRequest request) { + validateColumnMappings(request.columnMappings()); + validateTermExists(request.termId()); + HomeUniversity homeUniversity = findHomeUniversity(request.homeUniversityId()); + + List> rows = markdownTableParser.parse(request.markdown()); + + List createdUniversities = new ArrayList<>(); + + for (Map row : rows) { + String createdName = rowSaver.save(row, request.columnMappings(), homeUniversity, request.termId()); + if (createdName != null) { + createdUniversities.add(createdName); + } + } + + 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)); + } + + private HomeUniversity findHomeUniversity(Long homeUniversityId) { + return homeUniversityRepository.findById(homeUniversityId) + .orElseThrow(() -> new CustomException(HOME_UNIVERSITY_NOT_FOUND)); + } +} 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 d87f4f23b..b9ff8979e 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -61,6 +61,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(), "신고 내역이 존재하지 않습니다."), @@ -109,6 +110,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(), "잘못된 카테고리명입니다."), @@ -180,6 +182,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..fc3d91eb9 --- /dev/null +++ b/src/main/java/com/example/solidconnection/common/util/MarkdownTableParser.java @@ -0,0 +1,52 @@ +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) + .filter(line -> !line.isBlank()) + .map(line -> buildRowMap(headers, parseRow(line))) + .filter(row -> !row.isEmpty()) + .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) { + 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("(? cell.replace("\\|", "|").trim()) + .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/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java b/src/main/java/com/example/solidconnection/siteuser/repository/custom/SiteUserFilterRepositoryImpl.java index dd840b0a3..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; @@ -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; @@ -118,7 +119,6 @@ public class SiteUserFilterRepositoryImpl implements SiteUserFilterRepository { userBan.createdAt ); - private final JPAQueryFactory queryFactory; @Autowired @@ -355,6 +355,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/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; + } } 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(); } 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 de80a281f..c9d667ffe 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, unique = true, length = 100) + @Column(name = "english_name", nullable = false, unique = true, 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/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..85cb19aac --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/domain/UnivApplyInfoColumn.java @@ -0,0 +1,33 @@ +package com.example.solidconnection.university.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UnivApplyInfoColumn { + + UNIVERSITY_KOREAN_NAME("universityKoreanName"), + 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"), + 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; +} 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 fa067fa8a..8c585f47f 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'), @@ -39,13 +43,40 @@ 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); 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 + ('admin@test.email', 'admin', 'https://github.com/nayonsoso.png', + 'CONSIDERING', 'ADMIN', + '$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 @@ -272,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); diff --git a/src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql b/src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql new file mode 100644 index 000000000..951aab53b --- /dev/null +++ b/src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql @@ -0,0 +1,13 @@ +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/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..e654072f7 --- /dev/null +++ b/src/test/java/com/example/solidconnection/admin/university/service/AdminUnivApplyInfoServiceTest.java @@ -0,0 +1,397 @@ +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 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; +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; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +@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; + + @MockitoSpyBean + private CustomCacheManager cacheManager; + + 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.fields()) + .hasSize(UnivApplyInfoColumn.values().length), + () -> assertThat(response.languageTestTypes()) + .containsExactlyInAnyOrderElementsOf( + Arrays.stream(LanguageTestType.values()).map(Enum::name).toList() + ) + ); + } + + } + + @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(univApplyInfoRepository.findAll()).hasSize(2) + ); + } + + @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 + String markdown = String.format(""" + | 대학명 | 파견가능학기 | + |--------|------------| + | %s | 알수없음 | + """, 괌_대학_한국명); + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "파견가능학기", "semesterAvailableForDispatch") + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); + } + + @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 & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); + } + + @Test + void 존재하지_않는_국가코드면_전체가_실패한다() { + // given + String markdown = """ + | 대학명 | 국가코드 | + |--------|----------| + | 새 대학교 | ZZ | + """; + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); + } + + @Test + void 대학명이_비어있으면_전체가_실패한다() { + // given + String markdown = """ + | 대학명 | 국가코드 | + |--------|----------| + | | Belgium | + """; + UnivApplyInfoImportRequest request = new UnivApplyInfoImportRequest( + term.getId(), homeUniversity.getId(), markdown, + Map.of("대학명", "universityKoreanName", "국가코드", "universityCountryCode") + ); + + // when & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); + } + + @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); + } + + @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 & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + 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 & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + 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 & then + assertThatCode(() -> adminUnivApplyInfoService.importUnivApplyInfos(request)) + .isInstanceOf(CustomException.class); + assertThat(univApplyInfoRepository.findAll()).isEmpty(); + } + } +} 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..aa46b6481 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/util/MarkdownTableParserTest.java @@ -0,0 +1,127 @@ +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 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 { + + @Autowired + private MarkdownTableParser parser; + + @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 = """ + | 대학명 | 인원 | TOEIC | + |--------|------|-------| + | MIT | | 800 | + """; + + List> rows = parser.parse(markdown); + + assertThat(rows.get(0)) + .containsEntry("대학명", "MIT") + .doesNotContainKey("인원") + .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 = """ + | 대학명 | 인원 | + |--------|------| + | 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 443665f7e78da4b19ee408f1e5feee15afc9da64 Mon Sep 17 00:00:00 2001 From: seonghyeok cho <65901319+whqtker@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:54:41 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20english=5Fname=20UK=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#771)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: (english_name, korean_name) 조합으로 UK 설정하도록 * refactor: 복합 UK 제거, english_name 중복 허용 --- .../service/AdminHostUniversityService.java | 18 ------------- .../university/domain/HostUniversity.java | 2 +- .../repository/HostUniversityRepository.java | 2 -- ...traint_to_host_university_english_name.sql | 2 -- ...extend_univ_apply_info_import_columns.sql} | 0 .../AdminHostUniversityServiceTest.java | 25 +++++++++++-------- 6 files changed, 16 insertions(+), 33 deletions(-) delete mode 100644 src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql rename src/main/resources/db/migration/{V53__extend_univ_apply_info_import_columns.sql => V52__extend_univ_apply_info_import_columns.sql} (100%) diff --git a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java index 7f96f3c4e..416ba8fb8 100644 --- a/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java +++ b/src/main/java/com/example/solidconnection/admin/university/service/AdminHostUniversityService.java @@ -67,7 +67,6 @@ public AdminHostUniversityDetailResponse getHostUniversity(Long id) { ) public AdminHostUniversityDetailResponse createHostUniversity(AdminHostUniversityCreateRequest request) { validateKoreanNameNotExists(request.koreanName()); - validateEnglishNameNotExists(request.englishName()); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -98,13 +97,6 @@ private void validateKoreanNameNotExists(String koreanName) { }); } - private void validateEnglishNameNotExists(String englishName) { - hostUniversityRepository.findByEnglishName(englishName) - .ifPresent(existingUniversity -> { - throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); - }); - } - @Transactional @DefaultCacheOut( key = {"univApplyInfoTextSearch", "university:recommend:general"}, @@ -116,7 +108,6 @@ public AdminHostUniversityDetailResponse updateHostUniversity(Long id, AdminHost .orElseThrow(() -> new CustomException(UNIVERSITY_NOT_FOUND)); validateKoreanNameNotDuplicated(request.koreanName(), id); - validateEnglishNameNotDuplicated(request.englishName(), id); Country country = findCountryByCode(request.countryCode()); Region region = findRegionByCode(request.regionCode()); @@ -149,15 +140,6 @@ private void validateKoreanNameNotDuplicated(String koreanName, Long excludeId) }); } - private void validateEnglishNameNotDuplicated(String englishName, Long excludeId) { - hostUniversityRepository.findByEnglishName(englishName) - .ifPresent(existingUniversity -> { - if (!existingUniversity.getId().equals(excludeId)) { - throw new CustomException(HOST_UNIVERSITY_ALREADY_EXISTS); - } - }); - } - private Country findCountryByCode(String countryCode) { return countryRepository.findByCode(countryCode) .orElseThrow(() -> new CustomException(COUNTRY_NOT_FOUND)); 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 c9d667ffe..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, unique = true, length = 200) + @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/repository/HostUniversityRepository.java b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java index 4264ed04d..3fa80629a 100644 --- a/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java +++ b/src/main/java/com/example/solidconnection/university/repository/HostUniversityRepository.java @@ -16,6 +16,4 @@ default HostUniversity getHostUniversityById(Long id) { } Optional findByKoreanName(String koreanName); - - Optional findByEnglishName(String englishName); } diff --git a/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql b/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql deleted file mode 100644 index 3dc3a5d66..000000000 --- a/src/main/resources/db/migration/V52__add_unique_constraint_to_host_university_english_name.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE host_university - ADD CONSTRAINT uk_host_university_english_name UNIQUE (english_name); diff --git a/src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql b/src/main/resources/db/migration/V52__extend_univ_apply_info_import_columns.sql similarity index 100% rename from src/main/resources/db/migration/V53__extend_univ_apply_info_import_columns.sql rename to src/main/resources/db/migration/V52__extend_univ_apply_info_import_columns.sql diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java index a97303465..29a1ae163 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminHostUniversityServiceTest.java @@ -258,7 +258,7 @@ class 생성 { } @Test - void 이미_존재하는_영문명으로_생성하면_예외_응답을_반환한다() { + void 이미_존재하는_영문명이어도_한글명이_다르면_생성한다() { // given HostUniversity existing = universityFixture.괌_대학(); Country country = countryFixture.미국(); @@ -276,10 +276,12 @@ class 생성 { region.getCode() ); - // when & then - assertThatCode(() -> adminHostUniversityService.createHostUniversity(request)) - .isInstanceOf(CustomException.class) - .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + // when + AdminHostUniversityDetailResponse response = adminHostUniversityService.createHostUniversity(request); + + // then + assertThat(response.koreanName()).isEqualTo(request.koreanName()); + assertThat(response.englishName()).isEqualTo(existing.getEnglishName()); } } @@ -367,7 +369,7 @@ class 수정 { } @Test - void 다른_대학의_영문명으로_수정하면_예외_응답을_반환한다() { + void 다른_대학의_영문명이어도_한글명이_다르면_수정한다() { // given HostUniversity university1 = universityFixture.괌_대학(); HostUniversity university2 = universityFixture.메이지_대학(); @@ -384,10 +386,13 @@ class 수정 { university1.getRegion().getCode() ); - // when & then - assertThatCode(() -> adminHostUniversityService.updateHostUniversity(university1.getId(), request)) - .isInstanceOf(CustomException.class) - .hasMessage(ErrorCode.HOST_UNIVERSITY_ALREADY_EXISTS.getMessage()); + // when + AdminHostUniversityDetailResponse response = adminHostUniversityService.updateHostUniversity( + university1.getId(), request); + + // then + assertThat(response.koreanName()).isEqualTo(university1.getKoreanName()); + assertThat(response.englishName()).isEqualTo(university2.getEnglishName()); } @Test