Skip to content

Commit 987524b

Browse files
authored
Merge pull request #386 from mosu-dev/develop
prod
2 parents 2c0c60c + 8a41fbf commit 987524b

File tree

12 files changed

+396
-11
lines changed

12 files changed

+396
-11
lines changed

src/main/java/life/mosu/mosuserver/application/examticket/processor/GetMemberExamTicketInfoProcessor.java

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ public class GetMemberExamTicketInfoProcessor implements StepProcessor<Long, Exa
2727
@Override
2828
public ExamTicketIssueResponse process(Long examApplicationId) {
2929
ExamTicketIssueProjection examTicketInfo = examApplicationJpaRepository.findMemberExamTicketIssueProjectionByExamApplicationId(examApplicationId).orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_TICKET_INFO_NOT_FOUND));
30-
List<ExamSubjectJpaEntity> examSubjects = examSubjectJpaRepository.findByExamApplicationId(
31-
examApplicationId);
3230

33-
List<String> subjects = examSubjects.stream()
31+
List<String> subjects = examSubjectJpaRepository.findByExamApplicationId(examApplicationId)
32+
.stream()
3433
.map(ExamSubjectJpaEntity::getSubject)
3534
.sorted(Comparator.comparingInt(Subject::ordinal))
36-
.map(Subject::getSubjectName)
35+
.map(subject -> {
36+
if (subject == Subject.SOCIETY_AND_CULTURE) {
37+
return "사회·문화";
38+
}
39+
return subject.getSubjectName();
40+
})
3741
.toList();
3842

3943
String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo);

src/main/java/life/mosu/mosuserver/application/examticket/processor/GetPartnerExamTicketInfoProcessor.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,16 @@ public ExamTicketIssueResponse process(String orderId) {
4444

4545
Long examApplicationId = examTicketInfo.examApplicationId();
4646

47-
List<ExamSubjectJpaEntity> examSubjects = examSubjectJpaRepository.findByExamApplicationId(
48-
examApplicationId);
49-
50-
List<String> subjects = examSubjects.stream()
47+
List<String> subjects = examSubjectJpaRepository.findByExamApplicationId(examApplicationId)
48+
.stream()
5149
.map(ExamSubjectJpaEntity::getSubject)
5250
.sorted(Comparator.comparingInt(Subject::ordinal))
53-
.map(Subject::getSubjectName)
51+
.map(subject -> {
52+
if (subject == Subject.SOCIETY_AND_CULTURE) {
53+
return "사회·문화";
54+
}
55+
return subject.getSubjectName();
56+
})
5457
.toList();
5558

5659
String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package life.mosu.mosuserver.application.timetable;
2+
3+
import life.mosu.mosuserver.application.timetable.processor.GenerateTimeTableProcessor;
4+
import life.mosu.mosuserver.domain.application.entity.Subject;
5+
import life.mosu.mosuserver.domain.examapplication.entity.ExamSubjectJpaEntity;
6+
import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository;
7+
import life.mosu.mosuserver.domain.examapplication.repository.ExamSubjectJpaRepository;
8+
import life.mosu.mosuserver.presentation.timetable.dto.TimeTableFileResponse;
9+
import life.mosu.mosuserver.presentation.timetable.dto.TimeTableInfoResponse;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Service;
12+
import org.springframework.transaction.annotation.Propagation;
13+
import org.springframework.transaction.annotation.Transactional;
14+
15+
import java.time.LocalDate;
16+
import java.util.Comparator;
17+
import java.util.List;
18+
19+
@Service
20+
@RequiredArgsConstructor
21+
public class TimeTableService {
22+
23+
private final ExamApplicationJpaRepository examApplicationJpaRepository;
24+
private final ExamSubjectJpaRepository examSubjectJpaRepository;
25+
private final GenerateTimeTableProcessor generateTimeTableProcessor;
26+
27+
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
28+
public TimeTableFileResponse getMemberTimeTables(LocalDate examDate) {
29+
List<TimeTableInfoResponse> entries = examApplicationJpaRepository.findMemberTimeTable(examDate)
30+
.stream()
31+
.map(info -> {
32+
Long examApplicationId = info.examApplicationId();
33+
List<String> subjects = getSubjects(examApplicationId);
34+
35+
return TimeTableInfoResponse.of(
36+
info.examNumber(),
37+
info.userName(),
38+
subjects,
39+
info.schoolName()
40+
);
41+
})
42+
.toList();
43+
44+
return generateTimeTableProcessor.process(entries);
45+
}
46+
47+
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
48+
public TimeTableFileResponse getPartnerTimeTables(LocalDate examDate) {
49+
List<TimeTableInfoResponse> entries = examApplicationJpaRepository.findPartnerTimeTable(examDate)
50+
.stream()
51+
.map(info -> {
52+
Long examApplicationId = info.examApplicationId();
53+
List<String> subjects = getSubjects(examApplicationId);
54+
55+
return TimeTableInfoResponse.of(
56+
info.examNumber(),
57+
info.userName(),
58+
subjects,
59+
info.schoolName()
60+
);
61+
})
62+
.toList();
63+
64+
return generateTimeTableProcessor.process(entries);
65+
}
66+
67+
private List<String> getSubjects(Long examApplicationId) {
68+
return examSubjectJpaRepository.findByExamApplicationId(examApplicationId)
69+
.stream()
70+
.map(ExamSubjectJpaEntity::getSubject)
71+
.sorted(Comparator.comparingInt(Subject::ordinal))
72+
.map(subject -> {
73+
if (subject == Subject.SOCIETY_AND_CULTURE) {
74+
return "사회·문화";
75+
}
76+
return subject.getSubjectName();
77+
})
78+
.toList();
79+
}
80+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package life.mosu.mosuserver.application.timetable.processor;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import life.mosu.mosuserver.global.processor.StepProcessor;
5+
import life.mosu.mosuserver.presentation.timetable.dto.TimeTableFileResponse;
6+
import life.mosu.mosuserver.presentation.timetable.dto.TimeTableInfoResponse;
7+
import lombok.RequiredArgsConstructor;
8+
import org.apache.pdfbox.Loader;
9+
import org.apache.pdfbox.pdmodel.PDDocument;
10+
import org.apache.pdfbox.pdmodel.PDPage;
11+
import org.apache.pdfbox.pdmodel.PDPageContentStream;
12+
import org.apache.pdfbox.pdmodel.font.PDType0Font;
13+
import org.springframework.core.io.ClassPathResource;
14+
import org.springframework.stereotype.Component;
15+
16+
import java.io.ByteArrayInputStream;
17+
import java.io.ByteArrayOutputStream;
18+
import java.io.InputStream;
19+
import java.util.List;
20+
21+
@Component
22+
@RequiredArgsConstructor
23+
public class GenerateTimeTableProcessor implements StepProcessor<List<TimeTableInfoResponse>, TimeTableFileResponse> {
24+
25+
private static final String TEMPLATE_CLASSPATH = "static/time-table.pdf";
26+
private static final String FONT_CLASSPATH = "fonts/NotoSansKR-Regular.ttf";
27+
28+
private byte[] templatePdf;
29+
private byte[] fontBytes;
30+
31+
private static final int FONT_SIZE = 14;
32+
33+
private static final int ROWS = 3;
34+
private static final int COLS = 2;
35+
private static final int PER_PAGE = ROWS * COLS;
36+
37+
// 칸 간격
38+
private static final float CELL_DX = 288f;
39+
private static final float CELL_DY = 264f;
40+
41+
// 첫 번째 칸 기준 좌표
42+
private static final float FIRST_EXAM_NUMBER_X = 116f;
43+
private static final float FIRST_EXAM_NUMBER_Y = 771f;
44+
45+
private static final float FIRST_NAME_X = 116f;
46+
private static final float FIRST_NAME_Y = 752f;
47+
48+
private static final float FIRST_SUBJECT1_X = 137f;
49+
private static final float FIRST_SUBJECT1_Y = 653f;
50+
51+
private static final float FIRST_SUBJECT2_X = 137f;
52+
private static final float FIRST_SUBJECT2_Y = 633f;
53+
54+
private static final float FIRST_SCHOOL_X = 116f;
55+
private static final float FIRST_SCHOOL_Y = 595f;
56+
57+
@PostConstruct
58+
void init() {
59+
this.templatePdf = readAll(TEMPLATE_CLASSPATH);
60+
this.fontBytes = readAll(FONT_CLASSPATH);
61+
}
62+
63+
@Override
64+
public TimeTableFileResponse process(List<TimeTableInfoResponse> list) {
65+
try (PDDocument templateDoc = Loader.loadPDF(templatePdf);
66+
PDDocument outDoc = new PDDocument();
67+
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
68+
69+
PDType0Font font = PDType0Font.load(outDoc, new ByteArrayInputStream(fontBytes));
70+
71+
int total = list.size();
72+
int pageCount = (total + PER_PAGE - 1) / PER_PAGE;
73+
74+
for (int p = 0; p < pageCount; p++) {
75+
PDPage page = outDoc.importPage(templateDoc.getPage(0));
76+
77+
try (PDPageContentStream cs = new PDPageContentStream(
78+
outDoc, page, PDPageContentStream.AppendMode.APPEND, true)) {
79+
80+
for (int slot = 0; slot < PER_PAGE; slot++) {
81+
int idx = p * PER_PAGE + slot;
82+
if (idx >= total) break;
83+
84+
TimeTableInfoResponse e = list.get(idx);
85+
86+
int row = slot / COLS; // 0,1,2
87+
int col = slot % COLS; // 0,1
88+
89+
float dx = col * CELL_DX;
90+
float dy = row * CELL_DY;
91+
92+
// 수험번호
93+
drawText(cs, font, FONT_SIZE,
94+
FIRST_EXAM_NUMBER_X + dx,
95+
FIRST_EXAM_NUMBER_Y - dy,
96+
e.examNumber());
97+
98+
// 성명
99+
drawText(cs, font, FONT_SIZE,
100+
FIRST_NAME_X + dx,
101+
FIRST_NAME_Y - dy,
102+
e.userName());
103+
104+
// 탐구 과목
105+
String sub1 = (e.subjects() != null && e.subjects().size() > 0) ? nz(e.subjects().get(0)) : "";
106+
String sub2 = (e.subjects() != null && e.subjects().size() > 1) ? nz(e.subjects().get(1)) : "";
107+
108+
drawText(cs, font, FONT_SIZE,
109+
FIRST_SUBJECT1_X + dx,
110+
FIRST_SUBJECT1_Y - dy,
111+
sub1);
112+
113+
drawText(cs, font, FONT_SIZE,
114+
FIRST_SUBJECT2_X + dx,
115+
FIRST_SUBJECT2_Y - dy,
116+
sub2);
117+
118+
// 학교명
119+
drawText(cs, font, FONT_SIZE,
120+
FIRST_SCHOOL_X + dx,
121+
FIRST_SCHOOL_Y - dy,
122+
e.schoolName());
123+
}
124+
}
125+
}
126+
127+
outDoc.save(out);
128+
return new TimeTableFileResponse(out.toByteArray(), "time-tables.pdf", "application/pdf");
129+
130+
} catch (Exception e) {
131+
throw new RuntimeException("Generate time-table PDF failed", e);
132+
}
133+
}
134+
135+
private byte[] readAll(String classpath) {
136+
try (InputStream in = new ClassPathResource(classpath).getInputStream()) {
137+
return in.readAllBytes();
138+
} catch (Exception e) {
139+
throw new RuntimeException("Resource not found: " + classpath, e);
140+
}
141+
}
142+
143+
private void drawText(PDPageContentStream cs, PDType0Font font, int size,
144+
float x, float y, String text) {
145+
try {
146+
cs.beginText();
147+
cs.setFont(font, size);
148+
cs.newLineAtOffset(x, y);
149+
cs.showText(text == null ? "" : text);
150+
cs.endText();
151+
} catch (Exception e) {
152+
throw new RuntimeException("Failed to draw text", e);
153+
}
154+
}
155+
156+
private static String nz(String s) { return s == null ? "" : s; }
157+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package life.mosu.mosuserver.domain.examapplication.projection;
2+
3+
public record TimeTableInfoProjection (
4+
Long examApplicationId,
5+
String examNumber,
6+
String userName,
7+
String schoolName
8+
) {
9+
}

src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.springframework.data.jpa.repository.Modifying;
1010
import org.springframework.data.jpa.repository.Query;
1111

12+
import java.time.LocalDate;
1213
import java.util.List;
1314
import java.util.Optional;
1415

@@ -300,4 +301,40 @@ List<ExamApplicationJpaEntity> findDoneAndSortByTestPaperGroup(
300301
""")
301302
Optional<ExamTicketIssueProjection> findMemberExamTicketIssueProjectionByExamApplicationId(@Param("examApplicationId") Long examApplicationId);
302303

304+
@Query("""
305+
SELECT new life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection(
306+
ea.id,
307+
ea.examNumber,
308+
pr.userName,
309+
e.schoolName
310+
)
311+
FROM ExamApplicationJpaEntity ea
312+
LEFT JOIN PaymentJpaEntity p on p.examApplicationId = ea.id
313+
LEFT JOIN ExamJpaEntity e on ea.examId = e.id
314+
LEFT JOIN UserJpaEntity u on ea.userId = u.id
315+
LEFT JOIN ProfileJpaEntity pr on pr.userId = u.id
316+
WHERE p.paymentStatus = 'DONE'
317+
AND e.examDate = :examDate
318+
ORDER BY ea.examNumber
319+
""")
320+
List<TimeTableInfoProjection> findMemberTimeTable(@Param("examDate")LocalDate examDate);
321+
322+
323+
@Query("""
324+
SELECT new life.mosu.mosuserver.domain.examapplication.projection.TimeTableInfoProjection(
325+
ea.id,
326+
ea.examNumber,
327+
u.name,
328+
e.schoolName
329+
)
330+
FROM ExamApplicationJpaEntity ea
331+
JOIN ApplicationJpaEntity a on a.id = ea.applicationId
332+
JOIN UserJpaEntity u on a.userId = u.id
333+
JOIN VirtualAccountLogJpaEntity v on v.applicationId = a.id
334+
JOIN ExamJpaEntity e on ea.examId = e.id
335+
WHERE v.depositStatus = 'DONE'
336+
AND e.examDate = :examDate
337+
ORDER BY ea.examNumber
338+
""")
339+
List<TimeTableInfoProjection> findPartnerTimeTable(@Param("examDate")LocalDate examDate);
303340
}

src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ public enum Whitelist {
5454
APPLICATION_GUEST("/api/v1/applications/guest", WhitelistMethod.ALL),
5555
APPLICATION_PAID("/api/v1/applications/schools/paid-count",WhitelistMethod.ALL),
5656
EXAM_TICKET_GUEST("/api/v1/exam-ticket", WhitelistMethod.GET),
57-
ADMIN_IMPORT_CSV("/api/v1/admin/applications/import", WhitelistMethod.ALL);
57+
ADMIN_IMPORT_CSV("/api/v1/admin/applications/import", WhitelistMethod.ALL),
58+
TIME_TABLE("/api/v1/time-table", WhitelistMethod.GET);
5859

5960
private static final List<ExceptionRule> AUTH_REQUIRED_EXCEPTIONS = List.of(
6061
new ExceptionRule("/api/v1/exam-application", WhitelistMethod.GET)

src/main/java/life/mosu/mosuserver/infra/cron/job/ExamNumberGenerationJobRound1.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import java.time.LocalDate;
1212

1313
@Slf4j
14-
@CronJob(cron = "0 0 10 13 10 ?", name = "examNumberGeneratorJob_20251019")
14+
@CronJob(cron = "0 30 19 13 10 ?", name = "examNumberGeneratorJob_20251019")
1515
@DisallowConcurrentExecution
1616
@RequiredArgsConstructor
1717
public class ExamNumberGenerationJobRound1 implements Job {

0 commit comments

Comments
 (0)