Skip to content
Merged
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();
})
Comment on lines +35 to +40

Choose a reason for hiding this comment

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

medium

이 과목명 변환 로직은 GetPartnerExamTicketInfoProcessor, TimeTableService 등 여러 곳에서 중복되고 있습니다. 코드 중복은 향후 유지보수를 어렵게 만듭니다. 이 로직을 Subject enum 내부에 getDisplayName()과 같은 공통 메서드로 추출하여 중앙에서 관리하는 것을 강력히 권장합니다.

.toList();

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();
})
Comment on lines +51 to +56

Choose a reason for hiding this comment

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

medium

이 과목명 변환 로직은 GetMemberExamTicketInfoProcessor, TimeTableService 등 여러 곳에서 중복되고 있습니다. 코드 중복은 향후 유지보수를 어렵게 만듭니다. 이 로직을 Subject enum 내부에 getDisplayName()과 같은 공통 메서드로 추출하여 중앙에서 관리하는 것을 강력히 권장합니다.

.toList();

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);
Comment on lines +29 to +44
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

N+1 query problem: consider batch-fetching subjects.

For each TimeTableInfoProjection, getSubjects(examApplicationId) executes a separate database query. If there are N exam applications for a given date, this results in 1 query for the projections + N queries for subjects (classic N+1 problem).

Recommended solution:

Fetch all subjects for the exam applications in a single query using an IN clause:

@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public TimeTableFileResponse getMemberTimeTables(LocalDate examDate) {
    List<TimeTableInfoProjection> projections = examApplicationJpaRepository.findMemberTimeTable(examDate);
    
    // Batch fetch all subjects
    List<Long> examApplicationIds = projections.stream()
        .map(TimeTableInfoProjection::examApplicationId)
        .toList();
    
    Map<Long, List<String>> subjectsByApplicationId = examSubjectJpaRepository
        .findByExamApplicationIdIn(examApplicationIds)
        .stream()
        .collect(Collectors.groupingBy(
            ExamSubjectJpaEntity::getExamApplicationId,
            Collectors.mapping(
                entity -> {
                    Subject subject = entity.getSubject();
                    if (subject == Subject.SOCIETY_AND_CULTURE) {
                        return "사회·문화";
                    }
                    return subject.getSubjectName();
                },
                Collectors.toList()
            )
        ));
    
    // Sort subjects for each application
    subjectsByApplicationId.replaceAll((id, subjects) -> 
        subjects.stream()
            .sorted(/* by original Subject ordinal - you'll need to preserve that info */)
            .toList()
    );
    
    List<TimeTableInfoResponse> entries = projections.stream()
        .map(info -> TimeTableInfoResponse.of(
            info.examNumber(),
            info.userName(),
            subjectsByApplicationId.getOrDefault(info.examApplicationId(), List.of()),
            info.schoolName()
        ))
        .toList();

    return generateTimeTableProcessor.process(entries);
}

Note: You'll need to add a findByExamApplicationIdIn(List<Long>) method to ExamSubjectJpaRepository. For the sorting to work correctly with batch fetching, you may need to preserve ordinal information or fetch the subjects in a way that allows reconstruction of the original order.

}

@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);
Comment on lines +49 to +64
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Apply the same N+1 query fix here.

This method has the same N+1 query problem as getMemberTimeTables. Apply the batch-fetching pattern described in the previous comment.

🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/timetable/TimeTableService.java
around lines 49 to 64, this method causes an N+1 query by calling
getSubjects(examApplicationId) inside the stream map; change it to batch-fetch
subjects for all examApplicationIds at once: collect all examApplicationId
values from the repository result into a list/set, call the repository method
that returns all subjects for those ids in one query, build a Map<Long,
List<String>> keyed by examApplicationId, then replace the per-item getSubjects
call with a lookup into that map when constructing each TimeTableInfoResponse so
the stream mapping uses the pre-fetched subject lists.

}
Comment on lines +28 to +65

Choose a reason for hiding this comment

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

medium

getMemberTimeTablesgetPartnerTimeTables 메서드는 데이터 조회 부분을 제외하고는 로직이 거의 동일합니다. 코드 중복을 줄이고 재사용성을 높이기 위해 공통 로직을 별도의 private 메서드로 추출하는 것이 좋습니다. 또한, TimeTableInfoResponse의 불필요한 of 메서드 대신 생성자를 직접 사용하도록 함께 수정하는 것을 제안합니다.

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public TimeTableFileResponse getMemberTimeTables(LocalDate examDate) {
        List<life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection> infos = examApplicationJpaRepository.findMemberTimeTable(examDate);
        return generateTimeTablesFor(infos);
    }

    @Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
    public TimeTableFileResponse getPartnerTimeTables(LocalDate examDate) {
        List<life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection> infos = examApplicationJpaRepository.findPartnerTimeTable(examDate);
        return generateTimeTablesFor(infos);
    }

    private TimeTableFileResponse generateTimeTablesFor(List<life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection> timeTableInfos) {
        List<TimeTableInfoResponse> entries = timeTableInfos.stream()
                .map(info -> {
                    Long examApplicationId = info.examApplicationId();
                    List<String> subjects = getSubjects(examApplicationId);

                    return new TimeTableInfoResponse(
                            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();
})
Comment on lines +72 to +77

Choose a reason for hiding this comment

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

medium

이 과목명 변환 로직은 GetMemberExamTicketInfoProcessor, GetPartnerExamTicketInfoProcessor 등 여러 곳에서 중복되고 있습니다. 코드 중복은 향후 유지보수를 어렵게 만듭니다. 이 로직을 Subject enum 내부에 getDisplayName()과 같은 공통 메서드로 추출하여 중앙에서 관리하는 것을 강력히 권장합니다.

.toList();
}
Comment on lines +67 to +79
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract duplicated subject mapping logic to eliminate code duplication.

The subject mapping logic (fetch → sort by ordinal → map SOCIETY_AND_CULTURE → convert to names) is duplicated in three places:

  1. GetMemberExamTicketInfoProcessor (lines 35-40)
  2. GetPartnerExamTicketInfoProcessor (lines 51-56)
  3. This method (lines 67-79)

This violates DRY and makes maintenance harder if the mapping rules change.

Consider extracting to a shared utility or helper:

@Component
public class SubjectMappingHelper {
    
    private final ExamSubjectJpaRepository examSubjectJpaRepository;
    
    public List<String> getLocalizedSubjectNames(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();
    }
}

Then inject and use this helper in all three locations:

List<String> subjects = subjectMappingHelper.getLocalizedSubjectNames(examApplicationId);

}
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)) {

Comment on lines +77 to +79
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify PDPageContentStream constructor vs. PDFBox version

Using Loader suggests PDFBox 3.x; in 3.x the PDPageContentStream constructors changed (may require a resetContext parameter). Ensure this signature matches your pdfbox version.


🏁 Script executed:

#!/bin/bash
# Check declared PDFBox version(s)
rg -nP --type-add 'gradle:*.gradle' --type-add 'maven:*.xml' --type gradle --type maven -C2 '(?i)pdfbox'

Length of output: 220


Use correct PDPageContentStream constructor for PDFBox 3.x
The project uses PDFBox 3.0.5, where the 4-arg constructor (PDDocument, PDPage, AppendMode, boolean) no longer exists. Update this to the 5-arg signature by adding the resetContext flag, for example:

try (PDPageContentStream cs = new PDPageContentStream(
    outDoc, page, PDPageContentStream.AppendMode.APPEND, true, /*resetContext*/ true)) {
    …
}
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/timetable/processor/GenerateTimeTableProcessor.java
around lines 77 to 79, the PDPageContentStream constructor call uses the removed
4-arg signature; update it to the 5-arg PDFBox 3.x signature by adding the
resetContext boolean (e.g., pass true or false as appropriate) so the call
becomes PDPageContentStream(outDoc, page, PDPageContentStream.AppendMode.APPEND,
true, true) and compile against PDFBox 3.0.5.

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)) : "";
Comment on lines +105 to +106

Choose a reason for hiding this comment

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

medium

과목 문자열을 가져오는 로직이 다소 장황하고, 불필요한 null 체크를 포함하고 있습니다. TimeTableServicegetSubjects 메서드는 항상 null이 아닌 List를 반환하므로 e.subjects() != null 체크는 필요하지 않습니다. 또한 nz 메서드 호출도 중복됩니다. 코드를 더 간결하게 개선할 수 있습니다.

Suggested change
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)) : "";
String sub1 = e.subjects().isEmpty() ? "" : e.subjects().get(0);
String sub2 = e.subjects().size() > 1 ? 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)는 너무 광범위한 예외 처리 방식입니다. IOException과 같이 예상되는 checked exception만 명시적으로 처리하고, RuntimeException은 그대로 전파하거나 더 구체적인 커스텀 예외로 감싸는 것이 좋습니다. 이렇게 하면 디버깅이 용이해지고 코드의 의도가 명확해집니다.

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

}
Comment on lines +63 to +133
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Empty input produces an invalid PDF (no pages)

If list is empty, outDoc has 0 pages; saving likely fails. Guard and return a valid PDF (e.g., the template) or raise a domain exception.

     @Override
     public TimeTableFileResponse process(List<TimeTableInfoResponse> list) {
+        if (list == null || list.isEmpty()) {
+            // Option A: return the template as a 1-page PDF
+            return new TimeTableFileResponse(templatePdf, "time-tables.pdf", "application/pdf");
+            // Option B (alternative): throw a domain exception to map to 204/404
+            // throw new NoTimeTableDataException();
+        }
         try (PDDocument templateDoc = Loader.loadPDF(templatePdf);
              PDDocument outDoc = new PDDocument();
              ByteArrayOutputStream out = new ByteArrayOutputStream()) {
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/timetable/processor/GenerateTimeTableProcessor.java
around lines 63-133, the method builds an outDoc from entries but when the input
list is empty outDoc has zero pages and saving fails; add an early guard for
empty list: if list.isEmpty() then either load the templatePdf into a byte[] and
return a TimeTableFileResponse built from that template (so a valid PDF with
pages is returned) or throw a specific domain exception (e.g.,
NoTimeTablesException) before creating outDoc; implement the chosen behavior
consistently (load template via Loader.loadPDF(templatePdf) and write it to a
ByteArrayOutputStream to produce the response, or throw the exception) and
ensure resources are closed properly.


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

catch (Exception e)는 너무 광범위한 예외 처리 방식입니다. IOException과 같이 리소스 로딩 시 발생할 수 있는 특정 예외를 처리하는 것이 좋습니다. 모든 Exception을 잡으면 예상치 못한 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을 던질 수 있으므로, 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
WHERE p.paymentStatus = 'DONE'
AND e.examDate = :examDate
ORDER BY ea.examNumber
""")
List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate")LocalDate examDate);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add spacing after annotation.

Missing space after @Param("examDate") before the parameter type.

Apply this diff:

-    List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate")LocalDate examDate);
+    List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate") LocalDate examDate);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate")LocalDate examDate);
List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate") LocalDate examDate);
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java
around line 320, the method signature lacks a space between the @Param
annotation and the parameter type; change it so there is a single space after
the annotation (i.e., replace '@Param("examDate")LocalDate examDate' with
'@Param("examDate") LocalDate examDate') to conform to formatting conventions.



@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);
Comment on lines +323 to +339
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent JOIN type between member and partner queries.

The findPartnerTimeTable query uses INNER JOINs while findMemberTimeTable (lines 304-320) uses LEFT JOINs. This inconsistency means:

  • Partner query: Excludes rows if Application, User, VirtualAccountLog, or Exam is missing
  • Member query: Includes rows even if Payment, Exam, User, or Profile is missing (subject to WHERE filters)

Verify that this difference is intentional. If both queries should handle missing data the same way, standardize the JOIN types.

Apply this diff if INNER JOINs are appropriate for both:

     @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
+            JOIN PaymentJpaEntity p on p.examApplicationId = ea.id
+            JOIN ExamJpaEntity e on ea.examId = e.id
+            JOIN UserJpaEntity u on ea.userId = u.id
+            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);

Or if LEFT JOINs are correct for both, update the partner query accordingly.

}
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);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify the security implications of public timetable access.

The TIME_TABLE whitelist entry allows unauthenticated GET access to /api/v1/time-table/* endpoints. Since timetables contain PII (user names, exam numbers, school names), confirm that public access is intentional and complies with your privacy requirements.

Run the following script to verify what endpoints will be publicly accessible:

Additionally, consider if these endpoints should require authentication or at minimum some form of access control (e.g., date-based restrictions, API keys for partners).


🏁 Script executed:

#!/bin/bash
# Description: Identify all timetable controller endpoints that will be publicly accessible

# Find TimeTableController and list its endpoints
ast-grep --pattern $'@RestController
$$$
class TimeTableController {
  $$$
}'

# Also search for @GetMapping, @PostMapping annotations in TimeTableController
rg -A 5 '@(Get|Post|Put|Delete)Mapping' --type java -g '**/TimeTableController.java'

Length of output: 1701


Restrict unauthenticated access to timetable endpoints.

The TIME_TABLE whitelist entry (Whitelist.java:58) currently allows unauthenticated GET access to /api/v1/time-table/member and /api/v1/time-table/partner, exposing PII in PDFs. Confirm if this is intentional; otherwise enforce authentication or partner-specific access control.

🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java around line
58, the TIME_TABLE whitelist entry currently permits unauthenticated GET access
to /api/v1/time-table (which also covers /member and /partner) and may expose
PII in generated PDFs; remove or narrow this whitelist entry and require
authentication (or role/partner-scoped authorization) for those endpoints
instead — either remove TIME_TABLE from the public whitelist and ensure the
security filter enforces authentication for /api/v1/time-table/**, or replace
the single permissive entry with explicit, authenticated-only entries and add
partner-specific access checks in the controller/service layer so only
authorized users (and partner accounts when applicable) can retrieve timetable
PDFs.


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
Loading