diff --git a/safety-cedu-service/pom.xml b/safety-cedu-service/pom.xml
index 9da2609a..00e5ad1a 100644
--- a/safety-cedu-service/pom.xml
+++ b/safety-cedu-service/pom.xml
@@ -43,6 +43,13 @@
easyexcel
+
+
+ com.github.librepdf
+ openpdf
+ 1.3.34
+
+
org.springframework.boot
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java b/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java
index 3b43b48a..d791468c 100644
--- a/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/controller/PaperController.java
@@ -11,9 +11,16 @@ import org.qinan.cedu.service.PaperService;
import org.qinan.cedu.service.QuestionService;
import org.qinan.safetyeval.client.dto.PageResponse;
import org.qinan.safetyeval.client.dto.SingleResponse;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
/**
* 试卷管理
*/
@@ -85,4 +92,23 @@ public class PaperController {
PaperExamInfoVO pageInfo = paperService.paperExamInfo(paperId);
return SingleResponse.success(pageInfo);
}
+
+ /**
+ * 下载试卷PDF:查询试卷考试信息(同paperExamInfo)后渲染为PDF文件流,浏览器直接访问即触发下载
+ *
+ * @param paperId 试卷id
+ * @return PDF文件字节流(文件名为试卷名称)
+ */
+ @ApiOperation("下载试卷PDF")
+ @GetMapping(value = "/paper/exam/download", produces = MediaType.APPLICATION_PDF_VALUE)
+ public ResponseEntity downloadPaperPdf(@RequestParam("paperId") @ApiParam(value = "试卷id", required = true) Long paperId) throws IOException {
+ PaperExamInfoVO examInfo = paperService.paperExamInfo(paperId);
+ byte[] pdfBytes = paperService.renderExamInfoPdf(examInfo);
+ // RFC5987编码文件名,避免中文试卷名称乱码
+ String fileName = URLEncoder.encode(examInfo.getPaperBasicInfoVO().getPaperName() + ".pdf", StandardCharsets.UTF_8.name());
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename*=UTF-8''" + fileName)
+ .contentType(MediaType.APPLICATION_PDF)
+ .body(pdfBytes);
+ }
}
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java
index eeb0560b..599f2fad 100644
--- a/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/PaperService.java
@@ -107,4 +107,12 @@ public interface PaperService extends IService {
* @return 试卷考试信息
*/
PaperExamInfoVO paperExamInfo(Long paperId);
+
+ /**
+ * 将试卷考试信息渲染为PDF字节数组(用于试卷下载)
+ *
+ * @param examInfo 试卷考试信息(试卷基础信息+试题列表含选项)
+ * @return PDF文件字节数组
+ */
+ byte[] renderExamInfoPdf(PaperExamInfoVO examInfo);
}
diff --git a/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java b/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java
index 7739c14f..6d12c1be 100644
--- a/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java
+++ b/safety-cedu-service/src/main/java/org/qinan/cedu/service/impl/PaperServiceImpl.java
@@ -8,6 +8,14 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.baomidou.mybatisplus.extension.toolkit.Db;
+import com.lowagie.text.Document;
+import com.lowagie.text.DocumentException;
+import com.lowagie.text.Element;
+import com.lowagie.text.Font;
+import com.lowagie.text.PageSize;
+import com.lowagie.text.Paragraph;
+import com.lowagie.text.pdf.BaseFont;
+import com.lowagie.text.pdf.PdfWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.qinan.cedu.convertor.PaperConvertor;
@@ -45,6 +53,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -372,4 +382,75 @@ public class PaperServiceImpl extends ServiceImpl implemen
questionVOList.forEach(question -> question.setQuestionOptionVOList(
optionMap.getOrDefault(Long.valueOf(question.getId()), new ArrayList<>())));
}
+
+ @Override
+ public byte[] renderExamInfoPdf(PaperExamInfoVO examInfo) {
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ Document document = new Document(PageSize.A4, 50, 50, 50, 50);
+ PdfWriter.getInstance(document, out);
+ document.open();
+ // STSong-Light 为阅读器内置中文字体(cmaps已内置于openpdf jar),无需嵌入字体文件
+ BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
+ addPaperPdfHeader(document, baseFont, examInfo.getPaperBasicInfoVO());
+ if (!CollectionUtils.isEmpty(examInfo.getQuestionVOList())) {
+ int questionNum = 1;
+ for (QuestionVO question : examInfo.getQuestionVOList()) {
+ addPdfQuestion(document, baseFont, questionNum++, question);
+ }
+ }
+ document.close();
+ return out.toByteArray();
+ } catch (DocumentException | IOException e) {
+ throw new IllegalStateException("试卷PDF渲染失败", e);
+ }
+ }
+
+ /**
+ * 渲染试卷PDF头部:居中试卷名称 + 居中满分行
+ *
+ * @param document PDF文档
+ * @param baseFont 中文字体
+ * @param basicInfo 试卷基础信息
+ * @throws DocumentException 渲染异常
+ */
+ private void addPaperPdfHeader(Document document, BaseFont baseFont, PaperBasicInfoVO basicInfo) throws DocumentException {
+ Paragraph title = new Paragraph(basicInfo.getPaperName(), new Font(baseFont, 18, Font.BOLD));
+ title.setAlignment(Element.ALIGN_CENTER);
+ title.setSpacingAfter(8f);
+ document.add(title);
+ String totalScore = basicInfo.getPaperTotalScore() == null ? "0"
+ : basicInfo.getPaperTotalScore().stripTrailingZeros().toPlainString();
+ Paragraph scoreLine = new Paragraph("(满分:" + totalScore + " 分)", new Font(baseFont, 12, Font.NORMAL));
+ scoreLine.setAlignment(Element.ALIGN_CENTER);
+ scoreLine.setSpacingAfter(10f);
+ document.add(scoreLine);
+ }
+
+ /**
+ * 渲染单道试题:题干(题型+序号+分值)及其选项列表
+ *
+ * @param document PDF文档
+ * @param baseFont 中文字体
+ * @param questionNum 题目序号(从1开始)
+ * @param question 试题信息
+ * @throws DocumentException 渲染异常
+ */
+ private void addPdfQuestion(Document document, BaseFont baseFont, int questionNum, QuestionVO question) throws DocumentException {
+ QuestionTypeEnum typeEnum = QuestionTypeEnum.fromCode(question.getQuestionType());
+ String typeLabel = typeEnum == null ? question.getQuestionType() : typeEnum.getDesc() + "题";
+ String score = question.getScore() == null ? "0" : question.getScore().toPlainString();
+ Paragraph questionLine = new Paragraph("(" + typeLabel + ")" + questionNum + "." + question.getTitle()
+ + "(题目分值:" + score + ")", new Font(baseFont, 10, Font.NORMAL));
+ questionLine.setSpacingBefore(8f);
+ document.add(questionLine);
+ if (CollectionUtils.isEmpty(question.getQuestionOptionVOList())) {
+ return;
+ }
+ for (QuestionOptionVO option : question.getQuestionOptionVOList()) {
+ Paragraph optionLine = new Paragraph(option.getOptionKey() + ":" + option.getOptionText(),
+ new Font(baseFont, 10, Font.NORMAL));
+ optionLine.setIndentationLeft(20f);
+ document.add(optionLine);
+ }
+ }
}