Skip to content
Merged

prod #386

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ public class GetMemberExamTicketInfoProcessor implements StepProcessor<Long, Exa
@Override
public ExamTicketIssueResponse process(Long examApplicationId) {
ExamTicketIssueProjection examTicketInfo = examApplicationJpaRepository.findMemberExamTicketIssueProjectionByExamApplicationId(examApplicationId).orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_TICKET_INFO_NOT_FOUND));
List<ExamSubjectJpaEntity> examSubjects = examSubjectJpaRepository.findByExamApplicationId(
examApplicationId);

List<String> subjects = examSubjects.stream()
List<String> 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();
Comment on lines +31 to 41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

과목 목록을 가져와 포맷팅하는 이 로직은 GetPartnerExamTicketInfoProcessor와 새로 추가된 TimeTableService에도 중복되어 있습니다. 코드 중복을 피하고 유지보수성을 높이기 위해 이 로직을 중앙에서 관리하는 유틸리티 메서드나 서비스로 분리하는 것이 좋습니다.


String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ public ExamTicketIssueResponse process(String orderId) {

Long examApplicationId = examTicketInfo.examApplicationId();

List<ExamSubjectJpaEntity> examSubjects = examSubjectJpaRepository.findByExamApplicationId(
examApplicationId);

List<String> subjects = examSubjects.stream()
List<String> 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();
Comment on lines +47 to 57

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

과목 목록을 가져와 포맷팅하는 이 로직은 GetMemberExamTicketInfoProcessor와 새로 추가된 TimeTableService에도 중복되어 있습니다. 중복을 줄이고 향후 변경을 용이하게 하기 위해 이 로직을 공통 유틸리티로 리팩토링해야 합니다.


String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TimeTableInfoResponse> entries = examApplicationJpaRepository.findMemberTimeTable(examDate)
.stream()
.map(info -> {
Long examApplicationId = info.examApplicationId();
List<String> 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<TimeTableInfoResponse> entries = examApplicationJpaRepository.findPartnerTimeTable(examDate)
.stream()
.map(info -> {
Long examApplicationId = info.examApplicationId();
List<String> subjects = getSubjects(examApplicationId);

return TimeTableInfoResponse.of(
info.examNumber(),
info.userName(),
subjects,
info.schoolName()
);
})
.toList();

return generateTimeTableProcessor.process(entries);
}

private List<String> 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();
}
Comment on lines +67 to +79

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getSubjects 메서드의 로직은 GetMemberExamTicketInfoProcessorGetPartnerExamTicketInfoProcessor에도 중복되어 있습니다. 코드 중복은 유지보수를 어렵게 만들 수 있으므로, 이 로직을 별도의 유틸리티 클래스나 공통 서비스로 추출하여 재사용하는 것을 고려해 보세요. 예를 들어, Subject 이름을 포맷팅하는 책임을 가진 컴포넌트를 만들 수 있습니다.

}
Original file line number Diff line number Diff line change
@@ -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<List<TimeTableInfoResponse>, 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<TimeTableInfoResponse> 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);
}
Comment on lines +130 to +132

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

catch (Exception e)를 사용하여 모든 예외를 잡는 것은 잠재적인 버그를 숨길 수 있습니다. PDFBox 작업에서 주로 발생하는 IOException과 같이 더 구체적인 예외를 처리하는 것이 좋습니다. 이렇게 하면 코드의 안정성이 향상되고 오류의 원인을 더 쉽게 파악할 수 있습니다.

Suggested change
} catch (Exception e) {
throw new RuntimeException("Generate time-table PDF failed", e);
}
} catch (java.io.IOException 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);
}
Comment on lines +138 to +140

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

readAll 메서드에서도 catch (Exception e)를 사용하고 있습니다. 리소스 로딩 시에는 IOException이 발생할 가능성이 높으므로, 이를 명시적으로 처리하는 것이 좋습니다. 예외를 RuntimeException으로 다시 던지더라도, 더 구체적인 예외를 잡는 것이 코드의 의도를 명확하게 합니다.

Suggested change
} catch (Exception e) {
throw new RuntimeException("Resource not found: " + classpath, e);
}
} catch (java.io.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 (Exception e) {
throw new RuntimeException("Failed to draw text", e);
}
Comment on lines +151 to +153

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

drawText 메서드에서도 광범위한 Exception을 잡고 있습니다. PDPageContentStream의 텍스트 관련 작업은 IOException을 던질 수 있으므로, 이를 구체적으로 처리하는 것이 좋습니다. 이는 예외 처리의 정밀도를 높여줍니다.

Suggested change
} catch (Exception e) {
throw new RuntimeException("Failed to draw text", e);
}
} catch (java.io.IOException e) {
throw new RuntimeException("Failed to draw text", e);
}

}

private static String nz(String s) { return s == null ? "" : s; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package life.mosu.mosuserver.domain.examapplication.projection;

public record TimeTableInfoProjection (
Long examApplicationId,
String examNumber,
String userName,
String schoolName
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -300,4 +301,40 @@ List<ExamApplicationJpaEntity> findDoneAndSortByTestPaperGroup(
""")
Optional<ExamTicketIssueProjection> 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
Comment on lines +312 to +315

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

PaymentJpaEntityExamJpaEntity에 대해 LEFT JOIN을 사용하고 있지만, WHERE 절의 p.paymentStatus = 'DONE'e.examDate = :examDate 조건 때문에 사실상 INNER JOIN처럼 동작합니다. JOIN으로 명시적으로 변경하면 쿼리의 의도가 더 명확해지고, 데이터베이스 옵티마이저가 더 효율적인 실행 계획을 세우는 데 도움이 될 수 있습니다.

Suggested change
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
JOIN PaymentJpaEntity p on p.examApplicationId = ea.id
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<TimeTableInfoProjection> 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<TimeTableInfoProjection> findPartnerTimeTable(@Param("examDate")LocalDate examDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExceptionRule> AUTH_REQUIRED_EXCEPTIONS = List.of(
new ExceptionRule("/api/v1/exam-application", WhitelistMethod.GET)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading