diff --git a/build.gradle b/build.gradle index d90c98a1..56d0d180 100644 --- a/build.gradle +++ b/build.gradle @@ -111,6 +111,10 @@ dependencies { //타임리프 implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + //pdf + implementation 'org.apache.pdfbox:pdfbox:3.0.5' + implementation 'org.apache.pdfbox:fontbox:3.0.5' } configurations.configureEach { diff --git a/src/main/java/life/mosu/mosuserver/application/admin/AdminApplicationImportService.java b/src/main/java/life/mosu/mosuserver/application/admin/AdminApplicationImportService.java new file mode 100644 index 00000000..3372faac --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/AdminApplicationImportService.java @@ -0,0 +1,66 @@ +package life.mosu.mosuserver.application.admin; + +import jakarta.transaction.Transactional; +import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo; +import life.mosu.mosuserver.application.admin.dto.ImportResultDto; +import life.mosu.mosuserver.application.admin.processor.ApplyGuestStepProcessor; +import life.mosu.mosuserver.application.admin.processor.ChangeTestPaperCheckedStepProcessor; +import life.mosu.mosuserver.application.admin.processor.GetApplicationGuestRequestStepProcessor; +import life.mosu.mosuserver.application.admin.processor.RegisterVirtualAccountStepProcessor; +import life.mosu.mosuserver.application.admin.util.CsvReader; +import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminApplicationImportService { + + private final GetApplicationGuestRequestStepProcessor getApplicationGuestRequestStepProcessor; + private final ApplyGuestStepProcessor applyGuestStepProcessor; + private final ChangeTestPaperCheckedStepProcessor changeTestPaperCheckedStepProcessor; + private final RegisterVirtualAccountStepProcessor registerVirtualAccountStepProcessor; + private final CsvReader csvReader; + + @Transactional + public ImportResultDto importGuestApplications(MultipartFile file) { + List rows = csvReader.read(file); + + int total = rows.size(); + int success = 0; + + int lineNo = 1; + for (ApplicationCsvInfo row : rows) { + lineNo++; + try { + processGuestRow(row); + success++; + } catch (Exception e) { + log.error("게스트 신청 CSV 행 {} 처리 실패: {}", lineNo, e.getMessage(), e); + throw new RuntimeException("CSV 일괄 처리 중 오류 발생"); + } + } + + int fail = total - success; + log.info("CSV Import 완료 - 총 {}건, 성공 {}, 실패 {}", total, success, fail); + return new ImportResultDto(total, success, fail); + } + + + private void processGuestRow(ApplicationCsvInfo csvInfo) { + ApplicationGuestRequest applicationGuestRequest = getApplicationGuestRequestStepProcessor.process(csvInfo); + + Long applicationId = applyGuestStepProcessor.process(applicationGuestRequest); + + if (Boolean.TRUE.equals(csvInfo.isTestPaperChecked())) { + changeTestPaperCheckedStepProcessor.process(applicationId); + } + + registerVirtualAccountStepProcessor.process(applicationId); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/dto/ApplicationCsvInfo.java b/src/main/java/life/mosu/mosuserver/application/admin/dto/ApplicationCsvInfo.java new file mode 100644 index 00000000..52222a4f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/dto/ApplicationCsvInfo.java @@ -0,0 +1,70 @@ +package life.mosu.mosuserver.application.admin.dto; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; + +public record ApplicationCsvInfo( + String createdAt, + String name, + String gender, + LocalDate birth, + String phoneNumber, + LocalDate examDate, + String examSchool, + Boolean isLunchChecked, + Boolean isTestPaperChecked, + String subject1, + String subject2 +) { + public static ApplicationCsvInfo of( + String createdAt, + String name, + String gender, + LocalDate birth, + String phoneNumber, + LocalDate examDate, + String examSchool, + Boolean isLunchChecked, + Boolean isTestPaperChecked, + String subject1, + String subject2 + ) { + return new ApplicationCsvInfo(createdAt, name, gender, birth, phoneNumber, examDate, examSchool, isLunchChecked, isTestPaperChecked, subject1, subject2); + } + + public static ApplicationCsvInfo of(String[] values) { + if (values.length < 11) { + throw new IllegalArgumentException("CSV 행이 필수 컬럼 수(11개)보다 부족합니다. 실제 컬럼 수: " + values.length); + } + return new ApplicationCsvInfo( + values[0], // createdAt + values[1], // name + values[2], // gender + parseDate(values[3]), // birth + values[4], // phoneNumber + parseDate(values[5]), // examDate + values[6], // examSchool + parseBoolean(values[7]), // isLunchChecked + parseBoolean(values[8]), // isTestPaperChecked + values[9], // subject1 + values[10] // subject2 + ); + } + + private static LocalDate parseDate(String dateStr) { + try { + return LocalDate.parse(dateStr.trim()); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("날짜 형식이 올바르지 않습니다"); + } + } + + private static Boolean parseBoolean(String bool) { + if (bool == null || bool.isBlank()) { + return false; + } + String trimmedValue = bool.trim(); + return Boolean.parseBoolean(trimmedValue); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/dto/ImportResultDto.java b/src/main/java/life/mosu/mosuserver/application/admin/dto/ImportResultDto.java new file mode 100644 index 00000000..b9c72757 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/dto/ImportResultDto.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.application.admin.dto; + +public record ImportResultDto( + int totalProcessed, + int totalSuccess, + int totalFailure +) { +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/processor/ApplyGuestStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/admin/processor/ApplyGuestStepProcessor.java new file mode 100644 index 00000000..b5594f09 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/processor/ApplyGuestStepProcessor.java @@ -0,0 +1,31 @@ +package life.mosu.mosuserver.application.admin.processor; + +import life.mosu.mosuserver.application.application.ApplicationService; +import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity; +import life.mosu.mosuserver.domain.application.entity.ApplicationStatus; +import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest; +import life.mosu.mosuserver.presentation.application.dto.CreateApplicationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ApplyGuestStepProcessor implements StepProcessor { + + private final ApplicationService applicationService; + private final ApplicationJpaRepository applicationJpaRepository; + + @Override + public Long process(ApplicationGuestRequest request) { + CreateApplicationResponse response = applicationService.applyByGuest(request); + + Long applicationId = response.applicationId(); + ApplicationJpaEntity application = applicationJpaRepository.findById(applicationId) + .orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다. id=" + applicationId)); + + application.changeStatus(ApplicationStatus.APPROVED); + return applicationId; + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/processor/ChangeTestPaperCheckedStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/admin/processor/ChangeTestPaperCheckedStepProcessor.java new file mode 100644 index 00000000..e2e7ff33 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/processor/ChangeTestPaperCheckedStepProcessor.java @@ -0,0 +1,34 @@ +package life.mosu.mosuserver.application.admin.processor; + +import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.global.processor.StepProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class ChangeTestPaperCheckedStepProcessor implements StepProcessor> { + + private final ExamApplicationJpaRepository examApplicationJpaRepository; + + @Override + public List process(Long applicationId) { + + List examApplications = examApplicationJpaRepository.findByApplicationId(applicationId); + if (examApplications.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); + } + + examApplications.forEach(ExamApplicationJpaEntity::setTestPaperChecked); + + return examApplications.stream() + .map(ExamApplicationJpaEntity::getId) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/processor/GetApplicationGuestRequestStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/admin/processor/GetApplicationGuestRequestStepProcessor.java new file mode 100644 index 00000000..fa01488a --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/processor/GetApplicationGuestRequestStepProcessor.java @@ -0,0 +1,49 @@ +package life.mosu.mosuserver.application.admin.processor; + +import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.presentation.application.dto.ApplicationGuestRequest; +import life.mosu.mosuserver.presentation.application.dto.ExamApplicationRequest; +import life.mosu.mosuserver.presentation.common.FileRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Set; + +@Component +@RequiredArgsConstructor +public class GetApplicationGuestRequestStepProcessor implements StepProcessor { + + private final ExamJpaRepository examJpaRepository; + private static final String ORG_NAME = "모수사전예약"; + + @Override + public ApplicationGuestRequest process(ApplicationCsvInfo csvInfo) { + + LocalDate examDate = csvInfo.examDate(); + String schoolName = csvInfo.examSchool(); + ExamJpaEntity exam = examJpaRepository.findByExamDateAndSchoolName(examDate, schoolName); + + ExamApplicationRequest examApplicationRequest = new ExamApplicationRequest( + exam.getId(), + csvInfo.isLunchChecked() + ); + FileRequest admissionTicket = new FileRequest("", ""); + Set subjects = Set.of(csvInfo.subject1(), csvInfo.subject2()); + + return new ApplicationGuestRequest( + ORG_NAME, + csvInfo.gender(), + csvInfo.name(), + csvInfo.birth(), + csvInfo.phoneNumber(), + examApplicationRequest, + subjects, + admissionTicket + ); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/processor/RegisterVirtualAccountStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/admin/processor/RegisterVirtualAccountStepProcessor.java new file mode 100644 index 00000000..58b62828 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/processor/RegisterVirtualAccountStepProcessor.java @@ -0,0 +1,26 @@ +package life.mosu.mosuserver.application.admin.processor; + +import life.mosu.mosuserver.application.virtualaccount.VirtualAccountOrderIdGenerator; +import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaEntity; +import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaRepository; +import life.mosu.mosuserver.global.processor.StepProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RegisterVirtualAccountStepProcessor implements StepProcessor { + + private final VirtualAccountLogJpaRepository virtualAccountLogJpaRepository; + private final VirtualAccountOrderIdGenerator orderIdGenerator; + + @Override + public Long process(Long applicationId) { + String orderId = orderIdGenerator.generate(); + VirtualAccountLogJpaEntity virtualAccountLog = VirtualAccountLogJpaEntity.create(applicationId, orderId, null, null, null, null); + virtualAccountLog.setDepositSuccess(); + VirtualAccountLogJpaEntity saved = virtualAccountLogJpaRepository.save(virtualAccountLog); + + return saved.getId(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java b/src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java new file mode 100644 index 00000000..2b3073e9 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/admin/util/CsvReader.java @@ -0,0 +1,59 @@ +package life.mosu.mosuserver.application.admin.util; + +import life.mosu.mosuserver.application.admin.dto.ApplicationCsvInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +public class CsvReader { + + private static final String CSV_DELIMITER = ","; + + public List read(MultipartFile file) { + List results = new ArrayList<>(); + + if (file == null || file.isEmpty()) { + log.warn("업로드된 파일이 비어있습니다."); + return results; + } + + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + + String line; + boolean isHeader = true; + int lineNumber = 0; + + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isBlank()) continue; + if (isHeader) { + isHeader = false; + continue; + } + + try { + String[] values = line.split(CSV_DELIMITER, -1); + results.add(ApplicationCsvInfo.of(values)); + } catch (Exception e) { + log.error("CSV 파싱 실패 (Line {}): {}", lineNumber, e.getMessage()); + throw new RuntimeException("CSV 파싱 오류"); + + } + } + } catch (Exception e) { + log.error("파일 읽기 실패: {}", e.getMessage(), e); + throw new RuntimeException("CSV 읽기 오류"); + } + + return results; + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/application/ApplicationContext.java b/src/main/java/life/mosu/mosuserver/application/application/ApplicationContext.java index b358ab5a..83f6187a 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/ApplicationContext.java +++ b/src/main/java/life/mosu/mosuserver/application/application/ApplicationContext.java @@ -1,12 +1,5 @@ package life.mosu.mosuserver.application.application; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity; import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity; @@ -17,6 +10,10 @@ import life.mosu.mosuserver.presentation.application.dto.ExamApplicationResponse; import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationWithStatus; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + public record ApplicationContext( List applications, List examApplications, @@ -130,6 +127,7 @@ private Map.Entry createExamApplicationResponse( exam.getExamDate(), subjects, lunchName +// examApp.getIsTestPaperChecked() ); return Map.entry(examApp.getApplicationId(), response); diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java index 6ae044d7..7f9fd707 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java @@ -1,10 +1,5 @@ package life.mosu.mosuserver.application.examapplication; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent; import life.mosu.mosuserver.domain.application.entity.Subject; import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository; @@ -15,7 +10,6 @@ import life.mosu.mosuserver.domain.examapplication.projection.ExamTicketInfoProjection; import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; import life.mosu.mosuserver.domain.examapplication.repository.ExamSubjectJpaRepository; -import life.mosu.mosuserver.domain.examapplication.service.ExamNumberGenerationService; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.s3.S3Service; @@ -29,6 +23,12 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + @Slf4j @Service @RequiredArgsConstructor @@ -37,14 +37,12 @@ public class ExamApplicationService { private final ExamApplicationJpaRepository examApplicationJpaRepository; private final ApplicationJpaRepository applicationJpaRepository; private final ExamSubjectJpaRepository examSubjectJpaRepository; - private final ExamNumberGenerationService examNumberGenerationService; private final S3Service s3Service; private final FixedQuantityDiscountCalculator calculator; @Transactional public List register(RegisterExamApplicationEvent event) { List examApplicationEntities = event.toEntity(); - examNumberGenerationService.grantTo(examApplicationEntities); return examApplicationEntities; } @@ -143,6 +141,7 @@ public ExamApplicationInfoResponse getApplication(Long userId, Long examApplicat AddressResponse.from(examApplicationInfo.address()), subjects, examApplicationInfo.isLunchChecked() ? examApplicationInfo.lunchName() : "도시락 X", +// examApplicationInfo.isTeacherChecked(), paymentAmount, discountAmount, examApplicationInfo.paymentMethod().getName() diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/code/ExamNumberCode.java b/src/main/java/life/mosu/mosuserver/application/examapplication/code/ExamNumberCode.java new file mode 100644 index 00000000..3ee52404 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/code/ExamNumberCode.java @@ -0,0 +1,55 @@ +package life.mosu.mosuserver.application.examapplication.code; + +import life.mosu.mosuserver.domain.exam.entity.Area; +import lombok.Getter; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Map; +import java.util.Optional; + +@Component +@Getter +public class ExamNumberCode { + + private final Map roundCodeMap = Map.of( + LocalDate.of(2025, 10, 19), 1, + LocalDate.of(2025, 10, 26), 2, + LocalDate.of(2025, 11, 2), 3 + ); + + private final Map areaCodeMap = Map.of( + Area.DAECHI, 1, + Area.MOKDONG, 2, + Area.NOWON, 3, + Area.DAEGU, 4 + ); + + private final Map schoolCodeMap = Map.of( + "대치중학교", 7, + "개원중학교", 6, + "문래중학교", 5, + "목운중학교", 4, + "신서중학교", 3, + "온곡중학교", 2, + "노변중학교", 1 + ); + + public Integer getRoundCode(LocalDate examDate) { + return Optional.ofNullable(roundCodeMap.get(examDate)) + .orElseThrow(() -> new IllegalArgumentException( + "해당 시험일에 해당하는 코드가 존재하지 않습니다")); + } + + public Integer getAreaCode(Area area) { + return Optional.ofNullable(areaCodeMap.get(area)) + .orElseThrow(() -> new IllegalArgumentException( + "해당 지역에 해당하는 코드가 존재하지 않습니다")); + } + + public Integer getSchoolCode(String schoolName) { + return Optional.ofNullable(schoolCodeMap.get(schoolName)) + .orElseThrow(() -> new IllegalArgumentException( + "해당 학교명에 해당하는 코드가 존재하지 않습니다")); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutor.java b/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutor.java new file mode 100644 index 00000000..cc8aec8c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutor.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.application.examapplication.cron; + +import java.time.LocalDate; +import life.mosu.mosuserver.infra.cron.support.CronJobExecutor; + +public interface ExamNumberGeneratorExecutor extends CronJobExecutor { + + void generate(LocalDate examDate); + +} diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutorImpl.java b/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutorImpl.java new file mode 100644 index 00000000..4fbf1815 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/cron/ExamNumberGeneratorExecutorImpl.java @@ -0,0 +1,92 @@ +package life.mosu.mosuserver.application.examapplication.cron; + +import life.mosu.mosuserver.application.examapplication.code.ExamNumberCode; +import life.mosu.mosuserver.application.examapplication.util.ExamNumberUtil; +import life.mosu.mosuserver.domain.application.entity.Subject; +import life.mosu.mosuserver.domain.exam.entity.Area; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; +import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository; +import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@CronTarget +@RequiredArgsConstructor +public class ExamNumberGeneratorExecutorImpl implements ExamNumberGeneratorExecutor { + + private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final ExamJpaRepository examJpaRepository; + private final ExamNumberCode examNumberCode; + private final int GAP_BETWEEN_CHECKED_AND_UNCHECKED = 25; + + private static final List SOCIAL_SUBJECTS = List.of( + Subject.LIFE_AND_ETHICS, + Subject.WORLD_HISTORY, + Subject.ECONOMICS, + Subject.POLITICS_AND_LAW, + Subject.SOCIETY_AND_CULTURE, + Subject.ETHICS_AND_IDEOLOGY, + Subject.KOREAN_GEOGRAPHY, + Subject.WORLD_GEOGRAPHY, + Subject.EAST_ASIAN_HISTORY + ); + + private static final List SCIENCE_SUBJECTS = List.of( + Subject.PHYSICS_1, + Subject.CHEMISTRY_1, + Subject.BIOLOGY_1, + Subject.EARTH_SCIENCE_1, + Subject.PHYSICS_2, + Subject.CHEMISTRY_2, + Subject.BIOLOGY_2, + Subject.EARTH_SCIENCE_2 + ); + + @Override + @Transactional + public void generate(LocalDate examDate) { + List exams = examJpaRepository.findAllByExamDate(examDate); + + exams.forEach(exam -> { + Long examId = exam.getId(); + Area area = exam.getArea(); + String schoolName = exam.getSchoolName(); + + List examApplications = examApplicationJpaRepository.findDoneAndSortByTestPaperGroup( + examId, + SOCIAL_SUBJECTS, + SCIENCE_SUBJECTS + ); + + int roundCode = examNumberCode.getRoundCode(examDate); + int areaCode = examNumberCode.getAreaCode(area); + int schoolCode = examNumberCode.getSchoolCode(schoolName); + int personalCode = 0; + + boolean gapInserted = false; + + for (int i = 0; i < examApplications.size(); i++) { + ExamApplicationJpaEntity examApplication = examApplications.get(i); + + if (!gapInserted && Boolean.FALSE.equals(examApplication.getIsTestPaperChecked())) { + personalCode += GAP_BETWEEN_CHECKED_AND_UNCHECKED; + gapInserted = true; + } + + personalCode++; + + String examNumber = ExamNumberUtil.formatExamNumber( + roundCode, areaCode, schoolCode, personalCode); + + examApplication.grantExamNumber(examNumber); + } + }); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java b/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java new file mode 100644 index 00000000..86ab20a1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java @@ -0,0 +1,23 @@ +package life.mosu.mosuserver.application.examapplication.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ExamNumberUtil { + + public static String formatExamNumber(Integer roundCode, Integer areaCode, Integer schoolCode, + Integer personalCode) { + + if (roundCode == null || areaCode == null || schoolCode == null || personalCode == null) { + throw new IllegalArgumentException("수험 번호 생성을 위한 모든 코드를 전달해야 합니다."); + } + + return String.format( + "%d%02d%02d%04d", + roundCode, + areaCode, + schoolCode, + personalCode + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/examticket/ExamTicketService.java b/src/main/java/life/mosu/mosuserver/application/examticket/ExamTicketService.java new file mode 100644 index 00000000..4f25ad58 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examticket/ExamTicketService.java @@ -0,0 +1,34 @@ +package life.mosu.mosuserver.application.examticket; + +import life.mosu.mosuserver.application.examticket.processor.GenerateExamTicketProcessor; +import life.mosu.mosuserver.application.examticket.processor.GetMemberExamTicketInfoProcessor; +import life.mosu.mosuserver.application.examticket.processor.GetPartnerExamTicketInfoProcessor; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketFileResponse; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketIssueResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class ExamTicketService { + + private final GetPartnerExamTicketInfoProcessor getPartnerExamTicketInfoProcessor; + private final GetMemberExamTicketInfoProcessor getMemberExamTicketInfoProcessor; + private final GenerateExamTicketProcessor generateExamTicketProcessor; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public ExamTicketFileResponse getPartnerExamTicket(String orderId) { + ExamTicketIssueResponse data = getPartnerExamTicketInfoProcessor.process(orderId); + return generateExamTicketProcessor.process(data); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public ExamTicketFileResponse getMemberExamTicket(Long examApplicationId) { + ExamTicketIssueResponse data = getMemberExamTicketInfoProcessor.process(examApplicationId); + return generateExamTicketProcessor.process(data); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/application/examticket/processor/GenerateExamTicketProcessor.java b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GenerateExamTicketProcessor.java new file mode 100644 index 00000000..a46c0468 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GenerateExamTicketProcessor.java @@ -0,0 +1,193 @@ +package life.mosu.mosuserver.application.examticket.processor; + +import jakarta.annotation.PostConstruct; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketFileResponse; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketIssueResponse; +import lombok.RequiredArgsConstructor; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GenerateExamTicketProcessor implements StepProcessor { + + private static final String TEMPLATE_CLASSPATH = "static/exam-ticket.pdf"; + private static final String FONT_CLASSPATH = "fonts/NotoSansKR-Regular.ttf"; + private static final String DEFAULT_PROFILE_CLASSPATH = "static/default-profile.png"; + private static final DateTimeFormatter BIRTH_FMT = DateTimeFormatter.ofPattern("yy.MM.dd"); + + private static final float PHOTO_BOX_X = 46f; + private static final float PHOTO_BOX_Y = 660f; + private static final float PHOTO_BOX_W = 86f; + private static final float PHOTO_BOX_H = 133f; + + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 10000; + + private byte[] templatePdf; + private byte[] fontBytes; + private byte[] defaultProfilePng; + + @PostConstruct + void init() { + this.templatePdf = readAll(TEMPLATE_CLASSPATH); + this.fontBytes = readAll(FONT_CLASSPATH); + this.defaultProfilePng = readAll(DEFAULT_PROFILE_CLASSPATH); + } + + @Override + public ExamTicketFileResponse process(ExamTicketIssueResponse request) { + try (PDDocument doc = Loader.loadPDF(templatePdf); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + try (PDPageContentStream cs = new PDPageContentStream( + doc, doc.getPage(0), PDPageContentStream.AppendMode.APPEND, true)) { + + PDType0Font font = PDType0Font.load(doc, new ByteArrayInputStream(fontBytes)); + + // 사진 + PDImageXObject img = loadImageFromUrl(doc, request.examTicketImageUrl()); + if (img == null) img = loadDefaultProfile(doc); + drawImageToBox(cs, img, PHOTO_BOX_X, PHOTO_BOX_Y, PHOTO_BOX_W, PHOTO_BOX_H); + + // 좌측 + drawText(cs, font, 10, 198, 783, nz(request.examNumber())); + drawText(cs, font, 11, 174, 734, formatName(request.userName())); + drawText(cs, font, 10, 174, 667, formatBirth(request.birth())); + + // 우측 + drawText(cs, font, 9, 481, 812, nz(request.examNumber())); + drawText(cs, font, 9, 481, 801, nz(request.userName())); + drawText(cs, font, 9, 481, 746, firstOrEmpty(request.subjects())); + drawText(cs, font, 9, 481, 736, lastOrEmpty(request.subjects())); + drawText(cs, font, 9, 481, 714, nz(request.schoolName())); + } + + doc.save(out); + return new ExamTicketFileResponse(out.toByteArray(), buildFilename(request), "application/pdf"); + + } catch (Exception e) { + throw new RuntimeException("Generate exam-ticket PDF failed", e); + } + } + + + private byte[] readAll(String classpath) { + try (InputStream in = new ClassPathResource(classpath).getInputStream()) { + return in.readAllBytes(); + } catch (IOException e) { + throw new RuntimeException("Resource not found: " + classpath, e); + } + } + + private void drawText(PDPageContentStream cs, PDType0Font font, int size, + float x, float y, String text) { + try { + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(x, y); + cs.showText(text == null ? "" : text); + cs.endText(); + } catch (IOException e) { + throw new RuntimeException("Failed to draw text", e); + } + } + + private PDImageXObject loadDefaultProfile(PDDocument doc) { + try { + BufferedImage bi = ImageIO.read(new ByteArrayInputStream(defaultProfilePng)); + return LosslessFactory.createFromImage(doc, bi); + } catch (IOException e) { + throw new RuntimeException("Failed to load default profile image", e); + } + } + + private PDImageXObject loadImageFromUrl(PDDocument doc, String url) { + if (url == null || url.isBlank()) return null; + HttpURLConnection c = null; + try { + c = (HttpURLConnection) new URL(url).openConnection(); + c.setConnectTimeout(CONNECT_TIMEOUT_MS); + c.setReadTimeout(READ_TIMEOUT_MS); + c.setInstanceFollowRedirects(true); + + try (InputStream in = c.getInputStream()) { + String ct = String.valueOf(c.getContentType()).toLowerCase(); + if (ct.startsWith("image/jpeg")) return JPEGFactory.createFromStream(doc, in); + BufferedImage bi = ImageIO.read(in); + return (bi != null) ? LosslessFactory.createFromImage(doc, bi) : null; + } + } catch (Exception e) { + return null; + } finally { + if (c != null) c.disconnect(); + } + } + + private void drawImageToBox(PDPageContentStream cs, PDImageXObject img, + float boxX, float boxY, float boxW, float boxH) { + try { + int wPx = img.getWidth(); + int hPx = img.getHeight(); + + float scale = Math.min(boxW / wPx, boxH / hPx); + float drawW = wPx * scale; + float drawH = hPx * scale; + + float drawX = boxX + (boxW - drawW) / 2f; + float drawY = boxY + (boxH - drawH) / 2f; + + cs.drawImage(img, drawX, drawY, drawW, drawH); + } catch (Exception e) { + throw new RuntimeException("draw image failed", e); + } + } + + private String buildFilename(ExamTicketIssueResponse response) { + String name = response.userName() == null ? "" : response.userName().trim(); + String phone = response.phoneNumber() == null ? "" : response.phoneNumber().replaceAll("\\D", ""); + String base = (name + phone).isBlank() ? "exam-ticket" : (name + phone); + return base + ".pdf"; + } + + private String nz(String s) { return s == null ? "" : s; } + + private String firstOrEmpty(List list) { + return (list == null || list.isEmpty()) ? "" : nz(list.get(0)); + } + + private String lastOrEmpty(List list) { + return (list == null || list.isEmpty()) ? "" : nz(list.get(list.size() - 1)); + } + + private String formatBirth(LocalDate birth) { + return (birth == null) ? "" : birth.format(BIRTH_FMT); + } + + private String formatName(String name) { + if (name == null) return ""; + return (name.length() == 2) + ? String.join(" ", name.split("")) + : String.join(" ", name.split("")); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java new file mode 100644 index 00000000..b5c0dbd1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java @@ -0,0 +1,54 @@ +package life.mosu.mosuserver.application.examticket.processor; + +import life.mosu.mosuserver.domain.application.entity.Subject; +import life.mosu.mosuserver.domain.examapplication.entity.ExamSubjectJpaEntity; +import life.mosu.mosuserver.domain.examapplication.projection.ExamTicketIssueProjection; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; +import life.mosu.mosuserver.domain.examapplication.repository.ExamSubjectJpaRepository; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.infra.persistence.s3.S3Service; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketIssueResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GetMemberExamTicketInfoProcessor implements StepProcessor { + + private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final ExamSubjectJpaRepository examSubjectJpaRepository; + private final S3Service s3Service; + + @Override + public ExamTicketIssueResponse process(Long examApplicationId) { + ExamTicketIssueProjection examTicketInfo = examApplicationJpaRepository.findMemberExamTicketIssueProjectionByExamApplicationId(examApplicationId).orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_TICKET_INFO_NOT_FOUND)); + List examSubjects = examSubjectJpaRepository.findByExamApplicationId( + examApplicationId); + + List subjects = examSubjects.stream() + .map(ExamSubjectJpaEntity::getSubject) + .sorted(Comparator.comparingInt(Subject::ordinal)) + .map(Subject::getSubjectName) + .toList(); + + String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo); + + return ExamTicketIssueResponse.of(examTicketImgUrl, examTicketInfo.userName(), + examTicketInfo.birth(), + examTicketInfo.examNumber(), subjects, examTicketInfo.schoolName(), examTicketInfo.phoneNumber()); + + } + + private String getExamTicketImgUrl(ExamTicketIssueProjection examTicketInfo) { + String s3Key = examTicketInfo.s3Key(); + if (s3Key == null || s3Key.isBlank()) { + return null; + } + return s3Service.getPreSignedUrl(s3Key); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java new file mode 100644 index 00000000..5c301df4 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java @@ -0,0 +1,72 @@ +package life.mosu.mosuserver.application.examticket.processor; + +import life.mosu.mosuserver.domain.application.entity.Subject; +import life.mosu.mosuserver.domain.examapplication.entity.ExamSubjectJpaEntity; +import life.mosu.mosuserver.domain.examapplication.projection.ExamTicketIssueProjection; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; +import life.mosu.mosuserver.domain.examapplication.repository.ExamSubjectJpaRepository; +import life.mosu.mosuserver.domain.virtualaccount.DepositStatus; +import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaEntity; +import life.mosu.mosuserver.domain.virtualaccount.VirtualAccountLogJpaRepository; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.global.util.PhoneNumberUtil; +import life.mosu.mosuserver.infra.persistence.s3.S3Service; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketIssueResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Comparator; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GetPartnerExamTicketInfoProcessor implements + StepProcessor { + + private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final VirtualAccountLogJpaRepository virtualAccountLogJpaRepository; + private final ExamSubjectJpaRepository examSubjectJpaRepository; + private final S3Service s3Service; + + @Override + public ExamTicketIssueResponse process(String orderId) { + VirtualAccountLogJpaEntity virtualAccountLog = virtualAccountLogJpaRepository.findByOrderIdAndDepositStatus( + orderId, DepositStatus.DONE).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.VIRTUAL_ACCOUNT_LOG_NOT_FOUND)); + + Long applicationId = virtualAccountLog.getApplicationId(); + + ExamTicketIssueProjection examTicketInfo = examApplicationJpaRepository.findPartnerExamTicketIssueProjectionByApplicationId( + applicationId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.EXAM_TICKET_INFO_NOT_FOUND)); + + Long examApplicationId = examTicketInfo.examApplicationId(); + + List examSubjects = examSubjectJpaRepository.findByExamApplicationId( + examApplicationId); + + List subjects = examSubjects.stream() + .map(ExamSubjectJpaEntity::getSubject) + .sorted(Comparator.comparingInt(Subject::ordinal)) + .map(Subject::getSubjectName) + .toList(); + + String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo); + String phoneNumber = PhoneNumberUtil.removePrefix(examTicketInfo.phoneNumber()); + + return ExamTicketIssueResponse.of(examTicketImgUrl, examTicketInfo.userName(), + examTicketInfo.birth(), + examTicketInfo.examNumber(), subjects, examTicketInfo.schoolName(), phoneNumber); + + } + + private String getExamTicketImgUrl(ExamTicketIssueProjection examTicketInfo) { + String s3Key = examTicketInfo.s3Key(); + if (s3Key == null || s3Key.isBlank()) { + return null; + } + return s3Service.getPreSignedUrl(s3Key); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/application/entity/ApplicationJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/application/entity/ApplicationJpaEntity.java index 8e99d810..06ad0cc6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/application/entity/ApplicationJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/application/entity/ApplicationJpaEntity.java @@ -1,13 +1,6 @@ package life.mosu.mosuserver.domain.application.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import life.mosu.mosuserver.domain.base.BaseDeleteEntity; import lombok.AccessLevel; import lombok.Builder; diff --git a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java index b5510f9a..cd364215 100644 --- a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaRepository.java @@ -62,4 +62,8 @@ Optional countApplicationsByExamIdGroupedByExamId( List findByIdIn(List examIds); List findByArea(Area area); + + List findAllByExamDate(LocalDate examDate); + + ExamJpaEntity findByExamDateAndSchoolName(LocalDate examDate, String schoolName); } diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/entity/ExamApplicationJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/entity/ExamApplicationJpaEntity.java index f287d066..75e4edc2 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/entity/ExamApplicationJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/entity/ExamApplicationJpaEntity.java @@ -1,11 +1,6 @@ package life.mosu.mosuserver.domain.examapplication.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; import life.mosu.mosuserver.domain.base.BaseDeleteEntity; import lombok.AccessLevel; import lombok.Builder; @@ -39,12 +34,16 @@ public class ExamApplicationJpaEntity extends BaseDeleteEntity { @Column(name = "exam_number") private String examNumber; + @Column(name = "test_paper_checked") + private Boolean isTestPaperChecked = Boolean.FALSE; + @Builder public ExamApplicationJpaEntity( Long applicationId, Long userId, Long examId, - Boolean isLunchChecked) { + Boolean isLunchChecked + ) { this.applicationId = applicationId; this.userId = userId; this.examId = examId; @@ -63,7 +62,12 @@ public static ExamApplicationJpaEntity create( .isLunchChecked(isLunchChecked) .build(); } + public void grantExamNumber(String examNumber) { this.examNumber = examNumber; } + + public void setTestPaperChecked() { + this.isTestPaperChecked = Boolean.TRUE; + } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamApplicationInfoProjection.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamApplicationInfoProjection.java index dce2b348..3d64c822 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamApplicationInfoProjection.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamApplicationInfoProjection.java @@ -1,10 +1,11 @@ package life.mosu.mosuserver.domain.examapplication.projection; -import java.time.LocalDate; import life.mosu.mosuserver.domain.exam.entity.AddressJpaVO; import life.mosu.mosuserver.domain.payment.entity.PaymentAmountVO; import life.mosu.mosuserver.domain.payment.entity.PaymentMethod; +import java.time.LocalDate; + public record ExamApplicationInfoProjection( Long examApplicationId, String paymentKey, @@ -12,6 +13,7 @@ public record ExamApplicationInfoProjection( String schoolName, AddressJpaVO address, Boolean isLunchChecked, +// Boolean isTeacherChecked, String lunchName, PaymentAmountVO paymentAmount, PaymentMethod paymentMethod diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamTicketIssueProjection.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamTicketIssueProjection.java new file mode 100644 index 00000000..4fae9deb --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/ExamTicketIssueProjection.java @@ -0,0 +1,16 @@ +package life.mosu.mosuserver.domain.examapplication.projection; + +import java.time.LocalDate; + +public record ExamTicketIssueProjection( + String s3Key, + String userName, + LocalDate birth, + String examNumber, + String schoolName, + LocalDate examDate, + Long examApplicationId, + String phoneNumber +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java index a4699c70..06a4982f 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java @@ -1,19 +1,17 @@ package life.mosu.mosuserver.domain.examapplication.repository; import io.lettuce.core.dynamic.annotation.Param; -import java.util.List; -import java.util.Optional; +import life.mosu.mosuserver.domain.application.entity.Subject; import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity; -import life.mosu.mosuserver.domain.examapplication.projection.ExamApplicationInfoProjection; -import life.mosu.mosuserver.domain.examapplication.projection.ExamApplicationNotifyProjection; -import life.mosu.mosuserver.domain.examapplication.projection.ExamInfoProjection; -import life.mosu.mosuserver.domain.examapplication.projection.ExamInfoWithExamNumberProjection; -import life.mosu.mosuserver.domain.examapplication.projection.ExamTicketInfoProjection; +import life.mosu.mosuserver.domain.examapplication.projection.*; import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationWithStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import java.util.List; +import java.util.Optional; + public interface ExamApplicationJpaRepository extends JpaRepository { @@ -224,4 +222,82 @@ select exists ( ) """) boolean existsPaymentDoneByUserId(@Param("userId") Long userId); + + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.projection.ExamTicketIssueProjection( + et.s3Key, + u.name, + u.birth, + ea.examNumber, + e.schoolName, + e.examDate, + ea.id, + u.phoneNumber + ) + FROM ExamApplicationJpaEntity ea + LEFT JOIN ApplicationJpaEntity a on a.id = ea.applicationId + LEFT JOIN ExamJpaEntity e on ea.examId = e.id + LEFT JOIN ExamTicketImageJpaEntity et on et.applicationId = a.id + LEFT JOIN UserJpaEntity u on a.userId = u.id + WHERE a.id = :applicationId + """) + Optional findPartnerExamTicketIssueProjectionByApplicationId( + @Param("applicationId") Long applicationId); + + @Query(""" + SELECT ea + FROM ExamApplicationJpaEntity ea + LEFT JOIN ApplicationJpaEntity a ON ea.applicationId = a.id + LEFT JOIN PaymentJpaEntity p ON ea.id = p.examApplicationId + LEFT JOIN VirtualAccountLogJpaEntity v ON a.id = v.applicationId + LEFT JOIN ExamJpaEntity e ON ea.examId = e.id + LEFT JOIN ExamSubjectJpaEntity es ON es.examApplicationId = ea.id + WHERE ea.examId = :examId + AND (p.paymentStatus = 'DONE' OR v.depositStatus = 'DONE') + GROUP BY ea.id, ea.isTestPaperChecked + ORDER BY + CASE WHEN ea.isTestPaperChecked = TRUE THEN 0 ELSE 3 END, + CASE + WHEN SUM(CASE WHEN es.subject IN :socialSubjects THEN 1 ELSE 0 END) = 2 + AND SUM(CASE WHEN es.subject IN :scienceSubjects THEN 1 ELSE 0 END) = 0 THEN 0 + + WHEN SUM(CASE WHEN es.subject IN :socialSubjects THEN 1 ELSE 0 END) = 1 + AND SUM(CASE WHEN es.subject IN :scienceSubjects THEN 1 ELSE 0 END) = 1 THEN 1 + + WHEN SUM(CASE WHEN es.subject IN :socialSubjects THEN 1 ELSE 0 END) = 0 + AND SUM(CASE WHEN es.subject IN :scienceSubjects THEN 1 ELSE 0 END) = 2 THEN 2 + + ELSE 4 + END, + function('rand', 1) + """) + List findDoneAndSortByTestPaperGroup( + @Param("examId") Long examId, + @Param("socialSubjects") List socialSubjects, + @Param("scienceSubjects") List scienceSubjects + ); + + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.projection.ExamTicketIssueProjection( + et.s3Key, + pr.userName, + pr.birth, + ea.examNumber, + e.schoolName, + e.examDate, + ea.id, + pr.phoneNumber + ) + FROM ExamApplicationJpaEntity ea + LEFT JOIN ApplicationJpaEntity a on a.id = ea.applicationId + LEFT JOIN ExamJpaEntity e on ea.examId = e.id + LEFT JOIN ExamTicketImageJpaEntity et on et.applicationId = a.id + LEFT JOIN UserJpaEntity u on a.userId = u.id + LEFT JOIN ProfileJpaEntity pr on pr.userId = u.id + LEFT JOIN PaymentJpaEntity p on p.examApplicationId = ea.id + WHERE ea.id = :examApplicationId + AND p.paymentStatus = 'DONE' + """) + Optional findMemberExamTicketIssueProjectionByExamApplicationId(@Param("examApplicationId") Long examApplicationId); + } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java deleted file mode 100644 index 9c578d8c..00000000 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java +++ /dev/null @@ -1,22 +0,0 @@ -package life.mosu.mosuserver.domain.examapplication.service; - -import java.util.List; -import life.mosu.mosuserver.domain.examapplication.entity.ExamApplicationJpaEntity; -import life.mosu.mosuserver.global.support.NumberGenerator; -import org.springframework.stereotype.Component; - -@Component -public class ExamNumberGenerationService implements NumberGenerator { - - public void grantTo(List examApplicationEntities) { - examApplicationEntities.forEach(examApplicationEntity -> { - String examNumber = generate(); - examApplicationEntity.grantExamNumber(examNumber); - }); - } - - @Override - public String generate() { - return ""; - } -} diff --git a/src/main/java/life/mosu/mosuserver/domain/virtualaccount/VirtualAccountLogJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/virtualaccount/VirtualAccountLogJpaRepository.java index a7f7add3..04d4b17d 100644 --- a/src/main/java/life/mosu/mosuserver/domain/virtualaccount/VirtualAccountLogJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/virtualaccount/VirtualAccountLogJpaRepository.java @@ -8,4 +8,6 @@ public interface VirtualAccountLogJpaRepository extends Optional findByOrderId(String orderId); + Optional findByOrderIdAndDepositStatus(String orderId, + DepositStatus depositStatus); } diff --git a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java index 948e759e..3a188474 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -64,6 +64,7 @@ public enum ErrorCode { // 수험표 관련 에러 EXAM_TICKET_NOT_OPEN(HttpStatus.BAD_REQUEST, "수험표 조회 기간이 아닙니다.", CriticalLevel.LOW), EXAM_RESOURCE_ACCESS_DENIED(HttpStatus.BAD_REQUEST, "수험표 접근을 허용할 수 없습니다.", CriticalLevel.LOW), + EXAM_TICKET_INFO_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 수험표 정보를 찾을 수 없습니다.", CriticalLevel.LOW), EXAM_QUOTA_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 시험의 신청 정원을 찾을 수 없습니다.", CriticalLevel.HIGH), EXAM_QUOTA_EXCEEDED(HttpStatus.CONFLICT, "해당 시험의 신청 정원이 초과되었습니다.", CriticalLevel.LOW), EXAM_QUOTA_ZERO_OR_NEGATIVE(HttpStatus.CONFLICT, "해당 시험의 신청 정원이 0이거나 음수입니다.", diff --git a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java index c061a7bd..0979442e 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java @@ -1,13 +1,14 @@ package life.mosu.mosuserver.global.filter; import jakarta.servlet.http.HttpServletRequest; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + @Getter @Slf4j @RequiredArgsConstructor @@ -51,7 +52,10 @@ public enum Whitelist { USER_FIND_PASSWORD("/api/v1/user/me/find-password", WhitelistMethod.POST), APPLICATION_GUEST("/api/v1/applications/guest", WhitelistMethod.ALL), - APPLICATION_PAID("/api/v1/applications/schools/paid-count",WhitelistMethod.ALL); + APPLICATION_PAID("/api/v1/applications/schools/paid-count",WhitelistMethod.ALL), + EXAM_TICKET_GUEST("/api/v1/exam-ticket", WhitelistMethod.GET), + ADMIN_IMPORT_CSV("/api/v1/admin/applications/import", WhitelistMethod.ALL); + private static final List AUTH_REQUIRED_EXCEPTIONS = List.of( new ExceptionRule("/api/v1/exam-application", WhitelistMethod.GET) ); diff --git a/src/main/java/life/mosu/mosuserver/global/util/PhoneNumberUtil.java b/src/main/java/life/mosu/mosuserver/global/util/PhoneNumberUtil.java index 195b1da6..8ca66842 100644 --- a/src/main/java/life/mosu/mosuserver/global/util/PhoneNumberUtil.java +++ b/src/main/java/life/mosu/mosuserver/global/util/PhoneNumberUtil.java @@ -23,4 +23,11 @@ public static String formatGuestPhoneNumber(String phoneNumber) { public static String formatPhoneNumber(String phoneNumber) { return String.format("U%s", phoneNumber); } + + public static String removePrefix(String phoneNumber) { + if (phoneNumber == null) { + return null; + } + return phoneNumber.replaceFirst("^[GgUu]", ""); + } } diff --git a/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java index c9ff7922..8866c7a8 100644 --- a/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java +++ b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java @@ -1,17 +1,9 @@ package life.mosu.mosuserver.infra.config; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; import life.mosu.mosuserver.infra.cron.annotation.CronJob; import life.mosu.mosuserver.infra.cron.support.AutowiringSpringBeanJobFactory; import lombok.RequiredArgsConstructor; -import org.quartz.CronScheduleBuilder; -import org.quartz.Job; -import org.quartz.JobBuilder; -import org.quartz.JobDetail; -import org.quartz.Trigger; -import org.quartz.TriggerBuilder; +import org.quartz.*; import org.springframework.aop.support.AopUtils; import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; @@ -20,6 +12,10 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + @Configuration @RequiredArgsConstructor public class QuartzAutoRegisterConfig { diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java index 01231507..d9a5000a 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java @@ -1,10 +1,11 @@ package life.mosu.mosuserver.infra.cron.annotation; +import org.springframework.stereotype.Component; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import org.springframework.stereotype.Component; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java index c77946e8..abf8f2e9 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java @@ -1,6 +1,5 @@ package life.mosu.mosuserver.infra.cron.job; -import java.util.List; import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; import life.mosu.mosuserver.infra.cron.annotation.CronJob; import lombok.RequiredArgsConstructor; @@ -9,6 +8,8 @@ import org.quartz.Job; import org.quartz.JobExecutionContext; +import java.util.List; + @Slf4j @CronJob( cron = "0 0 4 * * ?", // 매일 새벽 2시에 실행 diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java new file mode 100644 index 00000000..7ab10fb5 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.infra.cron.job; + +import life.mosu.mosuserver.application.examapplication.cron.ExamNumberGeneratorExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +import java.time.LocalDate; + +@Slf4j +@CronJob(cron = "0 0 9 13 10 ?", name = "examNumberGeneratorJob_20251019") +@DisallowConcurrentExecution +@RequiredArgsConstructor +public class ExamNumberGenerationJobRound1 implements Job { + private final ExamNumberGeneratorExecutor examNumberGeneratorExecutor; + + @Override + public void execute(JobExecutionContext context) { + LocalDate examDate = LocalDate.of(2025, 10, 19); + + try { + log.info("Starting exam number generation, examDate={}", examDate); + examNumberGeneratorExecutor.generate(examDate); + log.info("Finishing exam number generation"); + } catch (Exception e) { + log.error("Generating exam number failed", e); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound2.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound2.java new file mode 100644 index 00000000..414f9a9a --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound2.java @@ -0,0 +1,33 @@ +package life.mosu.mosuserver.infra.cron.job; + +import life.mosu.mosuserver.application.examapplication.cron.ExamNumberGeneratorExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +import java.time.LocalDate; + +@Slf4j +@CronJob(cron = "0 0 6 20 10 ?", name = "examNumberGeneratorJob_20251026") +@DisallowConcurrentExecution +@RequiredArgsConstructor +public class ExamNumberGenerationJobRound2 implements Job { + private final ExamNumberGeneratorExecutor examNumberGeneratorExecutor; + + @Override + public void execute(JobExecutionContext context) { + LocalDate examDate = LocalDate.of(2025, 10, 26); + + try { + log.info("Starting exam number generation, examDate={}", examDate); + examNumberGeneratorExecutor.generate(examDate); + log.info("Finishing exam number generation"); + } catch (Exception e) { + log.error("Generating exam number failed", e); + } + } + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/notify/LunaSoftNotifier.java b/src/main/java/life/mosu/mosuserver/infra/notify/LunaSoftNotifier.java index f06e2c4e..0b4814e0 100644 --- a/src/main/java/life/mosu/mosuserver/infra/notify/LunaSoftNotifier.java +++ b/src/main/java/life/mosu/mosuserver/infra/notify/LunaSoftNotifier.java @@ -2,15 +2,15 @@ import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import java.util.Map; import life.mosu.mosuserver.infra.notify.dto.luna.LunaNotifyEventRequest; import life.mosu.mosuserver.infra.notify.property.NotifyProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.scheduler.Schedulers; + +import java.util.List; +import java.util.Map; @Component @RequiredArgsConstructor @@ -22,17 +22,17 @@ public class LunaSoftNotifier implements NotifyClientAdapter log.debug("알림톡 응답 성공")) - .doOnError(error -> log.error("알림톡 전송 실패", error)) - .subscribe(); +// LunaNotifyRequest lunaRequest = createLunaNotifyRequest(request); +// +// webClient.post() +// .uri(properties.getApi().getBaseUrl()) +// .bodyValue(lunaRequest) +// .retrieve() +// .bodyToMono(String.class) +// .publishOn(Schedulers.boundedElastic()) +// .doOnSuccess(response -> log.debug("알림톡 응답 성공")) +// .doOnError(error -> log.error("알림톡 전송 실패", error)) +// .subscribe(); } private LunaNotifyRequest createLunaNotifyRequest(LunaNotifyEventRequest request) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminApplicationImportController.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminApplicationImportController.java new file mode 100644 index 00000000..b04fc2d6 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminApplicationImportController.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.presentation.admin; + +import life.mosu.mosuserver.application.admin.AdminApplicationImportService; +import life.mosu.mosuserver.application.admin.dto.ImportResultDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/admin/applications/import") +public class AdminApplicationImportController { + + private final AdminApplicationImportService adminApplicationImportService; + + @GetMapping + public String showUploadPage() { + return "admin/import"; + } + + @PostMapping("/guests") + public String importGuests(@RequestParam("file") MultipartFile file, Model model) { + ImportResultDto result = adminApplicationImportService.importGuestApplications(file); + model.addAttribute("result", result); + return "admin/import-result"; + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java index aa2f741a..6ff5f44b 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java @@ -12,6 +12,7 @@ public record ExamApplicationResponse( LocalDate examDate, Set subjects, String lunchName +// Boolean isTestPaperChecked ) { public static ExamApplicationResponse of( @@ -23,6 +24,7 @@ public static ExamApplicationResponse of( LocalDate examDate, Set subjects, String lunchName +// Boolean isTestPaperChecked ) { return new ExamApplicationResponse( examApplicationId, @@ -33,6 +35,7 @@ public static ExamApplicationResponse of( examDate, subjects, lunchName +// isTestPaperChecked ); } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java index 6600cac1..4f71c56b 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java @@ -1,8 +1,9 @@ package life.mosu.mosuserver.presentation.examapplication.dto; +import life.mosu.mosuserver.presentation.common.AddressResponse; + import java.time.LocalDate; import java.util.Set; -import life.mosu.mosuserver.presentation.common.AddressResponse; public record ExamApplicationInfoResponse( Long examApplicationId, @@ -12,6 +13,7 @@ public record ExamApplicationInfoResponse( AddressResponse address, Set subjects, String lunchName, +// Boolean isTestPaperChecked, Integer paymentAmount, Integer discountAmount, String paymentMethod @@ -25,6 +27,7 @@ public static ExamApplicationInfoResponse of( AddressResponse address, Set subjects, String lunchName, +// Boolean isTestPaperChecked, Integer paymentAmount, Integer discountAmount, String paymentMethod @@ -37,6 +40,7 @@ public static ExamApplicationInfoResponse of( address, subjects, lunchName, +// isTestPaperChecked, paymentAmount, discountAmount, paymentMethod diff --git a/src/main/java/life/mosu/mosuserver/presentation/examticket/ExamTicketController.java b/src/main/java/life/mosu/mosuserver/presentation/examticket/ExamTicketController.java new file mode 100644 index 00000000..1347696d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/examticket/ExamTicketController.java @@ -0,0 +1,51 @@ +package life.mosu.mosuserver.presentation.examticket; + +import life.mosu.mosuserver.application.examticket.ExamTicketService; +import life.mosu.mosuserver.presentation.examticket.dto.ExamTicketFileResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; + +@RestController +@RequestMapping("/exam-ticket") +@RequiredArgsConstructor +public class ExamTicketController { + + private final ExamTicketService examTicketService; + + @GetMapping(value = "/partner/{orderId}", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity getPartnerExamTicket(@PathVariable String orderId) { + ExamTicketFileResponse file = examTicketService.getPartnerExamTicket(orderId); + return buildPdfResponse(file); + } + + @GetMapping(value = "/member/{examApplicationId}", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity getMemberExamTicket(@PathVariable Long examApplicationId) { + ExamTicketFileResponse file = examTicketService.getMemberExamTicket(examApplicationId); + return buildPdfResponse(file); + } + + private ResponseEntity buildPdfResponse(ExamTicketFileResponse file) { + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_TYPE, file.contentType()) + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionInlineUtf8(file.filename())) + .body(file.bytes()); + } + + private String contentDispositionInlineUtf8(String filename) { + String asciiFallback = Normalizer.normalize(filename, Normalizer.Form.NFKD) + .replaceAll("\\p{M}+", "") + .replaceAll("[^A-Za-z0-9._-]", "_"); + String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + return "inline; filename=\"" + asciiFallback + "\"; filename*=UTF-8''" + encoded; + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketFileResponse.java b/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketFileResponse.java new file mode 100644 index 00000000..b653e987 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketFileResponse.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.presentation.examticket.dto; + +public record ExamTicketFileResponse ( + byte[] bytes, + String filename, + String contentType +) { +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketIssueResponse.java b/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketIssueResponse.java new file mode 100644 index 00000000..7b286da5 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/examticket/dto/ExamTicketIssueResponse.java @@ -0,0 +1,35 @@ +package life.mosu.mosuserver.presentation.examticket.dto; + +import java.time.LocalDate; +import java.util.List; + +public record ExamTicketIssueResponse( + String examTicketImageUrl, + String userName, + LocalDate birth, + String examNumber, + List subjects, + String schoolName, + String phoneNumber +) { + + public static ExamTicketIssueResponse of( + String examTicketImageUrl, + String userName, + LocalDate birth, + String examNumber, + List subjects, + String schoolName, + String phoneNumber + ) { + return new ExamTicketIssueResponse( + examTicketImageUrl, + userName, + birth, + examNumber, + subjects, + schoolName, + phoneNumber + ); + } +} \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__add_test_paper_checked.sql b/src/main/resources/db/migration/V4__add_test_paper_checked.sql new file mode 100644 index 00000000..56da5da4 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_test_paper_checked.sql @@ -0,0 +1,2 @@ +ALTER TABLE exam_application + ADD COLUMN test_paper_checked BIT(1) NULL DEFAULT b'0'; \ No newline at end of file diff --git a/src/main/resources/fonts/NanumGothic-Regular.ttf b/src/main/resources/fonts/NanumGothic-Regular.ttf new file mode 100644 index 00000000..e3c67f45 Binary files /dev/null and b/src/main/resources/fonts/NanumGothic-Regular.ttf differ diff --git a/src/main/resources/fonts/NotoSansKR-Black.ttf b/src/main/resources/fonts/NotoSansKR-Black.ttf new file mode 100644 index 00000000..24ca8133 Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Black.ttf differ diff --git a/src/main/resources/fonts/NotoSansKR-Light.ttf b/src/main/resources/fonts/NotoSansKR-Light.ttf new file mode 100644 index 00000000..3a8013c7 Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Light.ttf differ diff --git a/src/main/resources/fonts/NotoSansKR-Medium.ttf b/src/main/resources/fonts/NotoSansKR-Medium.ttf new file mode 100644 index 00000000..0d57152b Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Medium.ttf differ diff --git a/src/main/resources/fonts/NotoSansKR-Regular.ttf b/src/main/resources/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..dee35df7 Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Regular.ttf differ diff --git a/src/main/resources/fonts/NotoSansKR-Thin.ttf b/src/main/resources/fonts/NotoSansKR-Thin.ttf new file mode 100644 index 00000000..27e02fb2 Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Thin.ttf differ diff --git a/src/main/resources/static/default-profile.png b/src/main/resources/static/default-profile.png new file mode 100644 index 00000000..48329b7f Binary files /dev/null and b/src/main/resources/static/default-profile.png differ diff --git a/src/main/resources/static/exam-ticket.pdf b/src/main/resources/static/exam-ticket.pdf new file mode 100644 index 00000000..642eaef8 Binary files /dev/null and b/src/main/resources/static/exam-ticket.pdf differ diff --git a/src/main/resources/templates/admin/import-result.html b/src/main/resources/templates/admin/import-result.html new file mode 100644 index 00000000..b7e73360 --- /dev/null +++ b/src/main/resources/templates/admin/import-result.html @@ -0,0 +1,160 @@ + + + + CSV Import 결과 + + + + +
+

CSV Import 결과

+ + +
+

총 처리 시도 행: 100

+ +

성공적으로 처리 시도된 행: 98

+

오류로 인해 실패한 행: 2

+
+ +
+ ✅ 데이터 오류가 발견되지 않아 **총 100건의 데이터가 모두 성공적으로 반영되었습니다.** +
+ +
+
+ ❌ 데이터 오류가 발견되어 **전체 (100건) 작업이 롤백되었습니다.** +
+ + + + + + + + + + + +
+ + 업로드 페이지로 돌아가기 +
+ + diff --git a/src/main/resources/templates/admin/import.html b/src/main/resources/templates/admin/import.html new file mode 100644 index 00000000..c4be0ae1 --- /dev/null +++ b/src/main/resources/templates/admin/import.html @@ -0,0 +1,151 @@ + + + + CSV Import + + + + + +
+

신청 정보 CSV 일괄 업로드

+ + +
+

비회원 신청 데이터 업로드

+
+ + + + +
+
+ +
+ + +