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 index b5c0dbd1..98b5690f 100644 --- a/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java +++ b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java @@ -27,13 +27,17 @@ public class GetMemberExamTicketInfoProcessor implements StepProcessor new CustomRuntimeException(ErrorCode.EXAM_TICKET_INFO_NOT_FOUND)); - List examSubjects = examSubjectJpaRepository.findByExamApplicationId( - examApplicationId); - List subjects = examSubjects.stream() + List subjects = examSubjectJpaRepository.findByExamApplicationId(examApplicationId) + .stream() .map(ExamSubjectJpaEntity::getSubject) .sorted(Comparator.comparingInt(Subject::ordinal)) - .map(Subject::getSubjectName) + .map(subject -> { + if (subject == Subject.SOCIETY_AND_CULTURE) { + return "사회·문화"; + } + return subject.getSubjectName(); + }) .toList(); String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo); 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 index 5c301df4..e6797b1a 100644 --- a/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java +++ b/src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java @@ -44,13 +44,16 @@ public ExamTicketIssueResponse process(String orderId) { Long examApplicationId = examTicketInfo.examApplicationId(); - List examSubjects = examSubjectJpaRepository.findByExamApplicationId( - examApplicationId); - - List subjects = examSubjects.stream() + List subjects = examSubjectJpaRepository.findByExamApplicationId(examApplicationId) + .stream() .map(ExamSubjectJpaEntity::getSubject) .sorted(Comparator.comparingInt(Subject::ordinal)) - .map(Subject::getSubjectName) + .map(subject -> { + if (subject == Subject.SOCIETY_AND_CULTURE) { + return "사회·문화"; + } + return subject.getSubjectName(); + }) .toList(); String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo); diff --git a/src/main/java/life/mosu/mosuserver/application/timetable/TimeTableService.java b/src/main/java/life/mosu/mosuserver/application/timetable/TimeTableService.java new file mode 100644 index 00000000..9cac8359 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/timetable/TimeTableService.java @@ -0,0 +1,80 @@ +package life.mosu.mosuserver.application.timetable; + +import life.mosu.mosuserver.application.timetable.processor.GenerateTimeTableProcessor; +import life.mosu.mosuserver.domain.application.entity.Subject; +import life.mosu.mosuserver.domain.examapplication.entity.ExamSubjectJpaEntity; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; +import life.mosu.mosuserver.domain.examapplication.repository.ExamSubjectJpaRepository; +import life.mosu.mosuserver.presentation.timetable.dto.TimeTableFileResponse; +import life.mosu.mosuserver.presentation.timetable.dto.TimeTableInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TimeTableService { + + private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final ExamSubjectJpaRepository examSubjectJpaRepository; + private final GenerateTimeTableProcessor generateTimeTableProcessor; + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public TimeTableFileResponse getMemberTimeTables(LocalDate examDate) { + List entries = examApplicationJpaRepository.findMemberTimeTable(examDate) + .stream() + .map(info -> { + Long examApplicationId = info.examApplicationId(); + List subjects = getSubjects(examApplicationId); + + return TimeTableInfoResponse.of( + info.examNumber(), + info.userName(), + subjects, + info.schoolName() + ); + }) + .toList(); + + return generateTimeTableProcessor.process(entries); + } + + @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) + public TimeTableFileResponse getPartnerTimeTables(LocalDate examDate) { + List entries = examApplicationJpaRepository.findPartnerTimeTable(examDate) + .stream() + .map(info -> { + Long examApplicationId = info.examApplicationId(); + List subjects = getSubjects(examApplicationId); + + return TimeTableInfoResponse.of( + info.examNumber(), + info.userName(), + subjects, + info.schoolName() + ); + }) + .toList(); + + return generateTimeTableProcessor.process(entries); + } + + private List getSubjects(Long examApplicationId) { + return examSubjectJpaRepository.findByExamApplicationId(examApplicationId) + .stream() + .map(ExamSubjectJpaEntity::getSubject) + .sorted(Comparator.comparingInt(Subject::ordinal)) + .map(subject -> { + if (subject == Subject.SOCIETY_AND_CULTURE) { + return "사회·문화"; + } + return subject.getSubjectName(); + }) + .toList(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/timetable/processor/GenerateTimeTableProcessor.java b/src/main/java/life/mosu/mosuserver/application/timetable/processor/GenerateTimeTableProcessor.java new file mode 100644 index 00000000..eea851ae --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/timetable/processor/GenerateTimeTableProcessor.java @@ -0,0 +1,157 @@ +package life.mosu.mosuserver.application.timetable.processor; + +import jakarta.annotation.PostConstruct; +import life.mosu.mosuserver.global.processor.StepProcessor; +import life.mosu.mosuserver.presentation.timetable.dto.TimeTableFileResponse; +import life.mosu.mosuserver.presentation.timetable.dto.TimeTableInfoResponse; +import lombok.RequiredArgsConstructor; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class GenerateTimeTableProcessor implements StepProcessor, TimeTableFileResponse> { + + private static final String TEMPLATE_CLASSPATH = "static/time-table.pdf"; + private static final String FONT_CLASSPATH = "fonts/NotoSansKR-Regular.ttf"; + + private byte[] templatePdf; + private byte[] fontBytes; + + private static final int FONT_SIZE = 14; + + private static final int ROWS = 3; + private static final int COLS = 2; + private static final int PER_PAGE = ROWS * COLS; + + // 칸 간격 + private static final float CELL_DX = 288f; + private static final float CELL_DY = 264f; + + // 첫 번째 칸 기준 좌표 + private static final float FIRST_EXAM_NUMBER_X = 116f; + private static final float FIRST_EXAM_NUMBER_Y = 771f; + + private static final float FIRST_NAME_X = 116f; + private static final float FIRST_NAME_Y = 752f; + + private static final float FIRST_SUBJECT1_X = 137f; + private static final float FIRST_SUBJECT1_Y = 653f; + + private static final float FIRST_SUBJECT2_X = 137f; + private static final float FIRST_SUBJECT2_Y = 633f; + + private static final float FIRST_SCHOOL_X = 116f; + private static final float FIRST_SCHOOL_Y = 595f; + + @PostConstruct + void init() { + this.templatePdf = readAll(TEMPLATE_CLASSPATH); + this.fontBytes = readAll(FONT_CLASSPATH); + } + + @Override + public TimeTableFileResponse process(List list) { + try (PDDocument templateDoc = Loader.loadPDF(templatePdf); + PDDocument outDoc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + + PDType0Font font = PDType0Font.load(outDoc, new ByteArrayInputStream(fontBytes)); + + int total = list.size(); + int pageCount = (total + PER_PAGE - 1) / PER_PAGE; + + for (int p = 0; p < pageCount; p++) { + PDPage page = outDoc.importPage(templateDoc.getPage(0)); + + try (PDPageContentStream cs = new PDPageContentStream( + outDoc, page, PDPageContentStream.AppendMode.APPEND, true)) { + + for (int slot = 0; slot < PER_PAGE; slot++) { + int idx = p * PER_PAGE + slot; + if (idx >= total) break; + + TimeTableInfoResponse e = list.get(idx); + + int row = slot / COLS; // 0,1,2 + int col = slot % COLS; // 0,1 + + float dx = col * CELL_DX; + float dy = row * CELL_DY; + + // 수험번호 + drawText(cs, font, FONT_SIZE, + FIRST_EXAM_NUMBER_X + dx, + FIRST_EXAM_NUMBER_Y - dy, + e.examNumber()); + + // 성명 + drawText(cs, font, FONT_SIZE, + FIRST_NAME_X + dx, + FIRST_NAME_Y - dy, + e.userName()); + + // 탐구 과목 + String sub1 = (e.subjects() != null && e.subjects().size() > 0) ? nz(e.subjects().get(0)) : ""; + String sub2 = (e.subjects() != null && e.subjects().size() > 1) ? nz(e.subjects().get(1)) : ""; + + drawText(cs, font, FONT_SIZE, + FIRST_SUBJECT1_X + dx, + FIRST_SUBJECT1_Y - dy, + sub1); + + drawText(cs, font, FONT_SIZE, + FIRST_SUBJECT2_X + dx, + FIRST_SUBJECT2_Y - dy, + sub2); + + // 학교명 + drawText(cs, font, FONT_SIZE, + FIRST_SCHOOL_X + dx, + FIRST_SCHOOL_Y - dy, + e.schoolName()); + } + } + } + + outDoc.save(out); + return new TimeTableFileResponse(out.toByteArray(), "time-tables.pdf", "application/pdf"); + + } catch (Exception e) { + throw new RuntimeException("Generate time-table PDF failed", e); + } + } + + private byte[] readAll(String classpath) { + try (InputStream in = new ClassPathResource(classpath).getInputStream()) { + return in.readAllBytes(); + } catch (Exception 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 (Exception e) { + throw new RuntimeException("Failed to draw text", e); + } + } + + private static String nz(String s) { return s == null ? "" : s; } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/TimeTableInfoProjection.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/TimeTableInfoProjection.java new file mode 100644 index 00000000..f1873df7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/projection/TimeTableInfoProjection.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.domain.examapplication.projection; + +public record TimeTableInfoProjection ( + Long examApplicationId, + String examNumber, + String userName, + String schoolName +) { +} \ No newline at end of file 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 06a4982f..dff61bf7 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 @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -300,4 +301,40 @@ List findDoneAndSortByTestPaperGroup( """) Optional findMemberExamTicketIssueProjectionByExamApplicationId(@Param("examApplicationId") Long examApplicationId); + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection( + ea.id, + ea.examNumber, + pr.userName, + e.schoolName + ) + FROM ExamApplicationJpaEntity ea + LEFT JOIN PaymentJpaEntity p on p.examApplicationId = ea.id + LEFT JOIN ExamJpaEntity e on ea.examId = e.id + LEFT JOIN UserJpaEntity u on ea.userId = u.id + LEFT JOIN ProfileJpaEntity pr on pr.userId = u.id + WHERE p.paymentStatus = 'DONE' + AND e.examDate = :examDate + ORDER BY ea.examNumber + """) + List findMemberTimeTable(@Param("examDate")LocalDate examDate); + + + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection( + ea.id, + ea.examNumber, + u.name, + e.schoolName + ) + FROM ExamApplicationJpaEntity ea + JOIN ApplicationJpaEntity a on a.id = ea.applicationId + JOIN UserJpaEntity u on a.userId = u.id + JOIN VirtualAccountLogJpaEntity v on v.applicationId = a.id + JOIN ExamJpaEntity e on ea.examId = e.id + WHERE v.depositStatus = 'DONE' + AND e.examDate = :examDate + ORDER BY ea.examNumber + """) + List findPartnerTimeTable(@Param("examDate")LocalDate examDate); } \ No newline at end of file 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 0979442e..e74234a0 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java @@ -54,7 +54,8 @@ public enum Whitelist { APPLICATION_GUEST("/api/v1/applications/guest", 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); + ADMIN_IMPORT_CSV("/api/v1/admin/applications/import", WhitelistMethod.ALL), + TIME_TABLE("/api/v1/time-table", WhitelistMethod.GET); 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/infra/cron/job/ExamNumberGenerationJobRound1.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java index 63f45f98..ef1a3945 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java @@ -11,7 +11,7 @@ import java.time.LocalDate; @Slf4j -@CronJob(cron = "0 0 10 13 10 ?", name = "examNumberGeneratorJob_20251019") +@CronJob(cron = "0 30 19 13 10 ?", name = "examNumberGeneratorJob_20251019") @DisallowConcurrentExecution @RequiredArgsConstructor public class ExamNumberGenerationJobRound1 implements Job { diff --git a/src/main/java/life/mosu/mosuserver/presentation/timetable/TimeTableController.java b/src/main/java/life/mosu/mosuserver/presentation/timetable/TimeTableController.java new file mode 100644 index 00000000..33050cc8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/timetable/TimeTableController.java @@ -0,0 +1,60 @@ +package life.mosu.mosuserver.presentation.timetable; + +import life.mosu.mosuserver.application.timetable.TimeTableService; +import life.mosu.mosuserver.presentation.timetable.dto.TimeTableFileResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.time.LocalDate; + +@RestController +@RequestMapping("/time-table") +@RequiredArgsConstructor +public class TimeTableController { + + private final TimeTableService timeTableService; + + @GetMapping(value = "/member", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity getMemberTimeTables( + @RequestParam(name = "examDate") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate examDate + ) { + TimeTableFileResponse file = timeTableService.getMemberTimeTables(examDate); + return buildPdfResponse(file); + } + + @GetMapping(value = "/partner", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity getPartnerTimeTables( + @RequestParam(name = "examDate") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate examDate + ) { + TimeTableFileResponse file = timeTableService.getPartnerTimeTables(examDate); + return buildPdfResponse(file); + } + + private ResponseEntity buildPdfResponse(TimeTableFileResponse 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/timetable/dto/TimeTableFileResponse.java b/src/main/java/life/mosu/mosuserver/presentation/timetable/dto/TimeTableFileResponse.java new file mode 100644 index 00000000..ddb6a929 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/timetable/dto/TimeTableFileResponse.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.presentation.timetable.dto; + +public record TimeTableFileResponse ( + byte[] bytes, + String filename, + String contentType +) { +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/timetable/dto/TimeTableInfoResponse.java b/src/main/java/life/mosu/mosuserver/presentation/timetable/dto/TimeTableInfoResponse.java new file mode 100644 index 00000000..2c545743 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/timetable/dto/TimeTableInfoResponse.java @@ -0,0 +1,26 @@ +package life.mosu.mosuserver.presentation.timetable.dto; + +import java.util.List; + +public record TimeTableInfoResponse ( + String examNumber, + String userName, + List subjects, + String schoolName +) { + + public static TimeTableInfoResponse of( + String examNumber, + String userName, + List subjects, + String schoolName + ) { + return new TimeTableInfoResponse( + examNumber, + userName, + subjects, + schoolName + ); + } + +} \ No newline at end of file diff --git a/src/main/resources/static/time-table.pdf b/src/main/resources/static/time-table.pdf new file mode 100644 index 00000000..0305fdc8 Binary files /dev/null and b/src/main/resources/static/time-table.pdf differ