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