Browse Source

Merge branch 'feat/batch_export' into refactor

# Conflicts:
#	pom.xml
#	src/main/java/com/njuzr/eaibackend/config/SecurityConfig.java
baiqi 1 năm trước cách đây
mục cha
commit
a20ee2279e

+ 18 - 0
pom.xml

@@ -192,6 +192,24 @@
             <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
             <version>2.1.0</version>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-freemarker</artifactId>
+        </dependency>
+
+        <!-- Jsoup HTML解析 -->
+        <dependency>
+            <groupId>org.jsoup</groupId>
+            <artifactId>jsoup</artifactId>
+            <version>1.15.3</version>
+        </dependency>
+
+        <!-- ZIP压缩 -->
+        <dependency>
+            <groupId>net.lingala.zip4j</groupId>
+            <artifactId>zip4j</artifactId>
+            <version>2.11.5</version>
+        </dependency>
 
     </dependencies>
 

+ 2 - 0
src/main/java/com/njuzr/eaibackend/config/SecurityConfig.java

@@ -59,6 +59,8 @@ public class SecurityConfig {
                                 "/api/onlyoffice/**",
                                 "/api/assignment/**",
                                 "/doc/**",
+                                "/api/export/**",
+                                "/api/test",
                                 "api/auth/loginByPortal").permitAll() // 允许公开访问的路径
                         .anyRequest().authenticated() // 其他所有请求需要认证
                 )

+ 52 - 0
src/main/java/com/njuzr/eaibackend/controller/ExportController.java

@@ -0,0 +1,52 @@
+package com.njuzr.eaibackend.controller;
+
+import com.njuzr.eaibackend.service.ExportCompositionService;
+import com.njuzr.eaibackend.vo.UserVO;
+import freemarker.template.TemplateException;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/export")
+public class ExportController {
+    @Autowired
+    private ExportCompositionService exportCompositionService;
+
+    @GetMapping("/composition/word")
+    public ResponseEntity<byte[]> exportWord(@RequestParam Long assignmentId, @RequestParam Long studentId) throws TemplateException, IOException {
+
+        Pair<UserVO, byte[]> pair = exportCompositionService.exportWord(assignmentId, studentId);
+        UserVO student = pair.getLeft();
+        byte[] wordBytes = pair.getRight();
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+        headers.setContentDispositionFormData("attachment",
+                URLEncoder.encode(String.format("%s_%s.%s", student.getName(), student.getOfficialNumber(), "docx"), StandardCharsets.UTF_8));
+
+        return ResponseEntity.ok()
+                .headers(headers)
+                .body(wordBytes);
+    }
+
+    @GetMapping("/composition/zip")
+    public ResponseEntity<byte[]> exportBatch(@RequestParam Long assignmentId, @RequestParam(required = false) List<Long> studentIds) {
+
+        byte[] zipBytes = exportCompositionService.exportBatchToZip(studentIds, assignmentId);
+
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION,
+                        "attachment; filename=compositions.zip")
+                .contentType(MediaType.APPLICATION_OCTET_STREAM)
+                .body(zipBytes);
+    }
+}

+ 3 - 1
src/main/java/com/njuzr/eaibackend/mapper/EngagementMapper.java

@@ -3,7 +3,9 @@ package com.njuzr.eaibackend.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.njuzr.eaibackend.po.Engagement;
 import org.apache.ibatis.annotations.Mapper;
-// TODO:(柏琪)
+
+import java.util.List;
+
 /**
  * @author 柏琪
  * @date 2024-07-27 13:57

+ 21 - 0
src/main/java/com/njuzr/eaibackend/mapper/serviceIpml/EngagementMapperServiceImpl.java

@@ -0,0 +1,21 @@
+package com.njuzr.eaibackend.mapper.serviceIpml;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.njuzr.eaibackend.mapper.EngagementMapper;
+import com.njuzr.eaibackend.po.Engagement;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class EngagementMapperServiceImpl extends ServiceImpl<EngagementMapper, Engagement> {
+
+    public List<Engagement> getEngagementByAssignmentId(Long assignmentId) {
+        return this.lambdaQuery()
+                .eq(Engagement::getAssignmentId, assignmentId)
+                .list();
+    }
+
+
+}

+ 128 - 0
src/main/java/com/njuzr/eaibackend/service/ExportCompositionService.java

@@ -0,0 +1,128 @@
+package com.njuzr.eaibackend.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.mapper.EngagementMapper;
+import com.njuzr.eaibackend.mapper.serviceIpml.EngagementMapperServiceImpl;
+import com.njuzr.eaibackend.po.Engagement;
+import com.njuzr.eaibackend.utils.WordExporter;
+import com.njuzr.eaibackend.utils.ZipExport;
+import com.njuzr.eaibackend.vo.AssignmentVO;
+import com.njuzr.eaibackend.vo.EngagementVO;
+import com.njuzr.eaibackend.vo.UserVO;
+import freemarker.template.Configuration;
+import freemarker.template.TemplateException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.tuple.Pair;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.util.CollectionUtils;
+
+import java.io.IOException;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class ExportCompositionService {
+
+    @Autowired
+    private Configuration freemarkerConfig;
+    @Autowired
+    private EngagementMapper engagementMapper;
+    @Autowired
+    private AssignmentService assignmentService;
+    @Autowired
+    private UserService userService;
+    @Autowired
+    private WordExporter wordExporter;
+    @Autowired
+    private EngagementMapperServiceImpl engagementMapperServiceImpl;
+
+    public Pair<UserVO, byte[]> exportWord(Long assignmentId, Long studentId) {
+        AssignmentVO assignment = assignmentService.findAssignmentById(assignmentId);
+        EngagementVO engagement = assignmentService.getEngagement(studentId, assignmentId);
+        UserVO user = userService.searchUsers(new Page<>(1,1), studentId, null, null, null).getRecords().get(0);
+
+        Map<String, Object> model = buildWordInfoModel(assignment, user, engagement);
+
+        return Pair.of(user, wordExporter.exportCompositionWord(model));
+
+    }
+
+    private Map<String, Object> buildWordInfoModel(AssignmentVO assignment, UserVO user, EngagementVO engagement) {
+        Map<String, Object> model = new HashMap<>();
+        model.put("title", assignment.getAssignmentName());
+        model.put("studentName", user.getName());
+        model.put("officialNumber", user.getOfficialNumber());
+        model.put("assignmentName", assignment.getAssignmentName());
+        model.put("date", new Date());
+        model.put("content", processContent(engagement.getTextContent()));
+        return model;
+    }
+
+    public byte[] exportBatchToZip(List<Long> studentIds, Long assignmentId) {
+        if(assignmentId == null){
+            throw MyException.create(HttpStatus.BAD_REQUEST, "assignmentId不能为空");
+        }
+
+        if (CollectionUtils.isEmpty(studentIds)) {
+            List<Engagement> engagements = engagementMapperServiceImpl.getEngagementByAssignmentId(assignmentId);
+            if (CollectionUtils.isEmpty(engagements)) {
+                return null;
+            }
+            studentIds = engagements.stream()
+                    .map(Engagement::getStudentId)
+                    .toList();
+        }
+        return exportBatchToZipByStudentIds(studentIds, assignmentId);
+    }
+
+    private byte[] exportBatchToZipByStudentIds(List<Long> studentIds, Long assignmentId) {
+        List<ZipExport.ZipItem> zipItems = studentIds.stream()
+                .map(studentId -> {
+                    try {
+                        Pair<UserVO, byte[]> pair = exportWord(assignmentId, studentId);
+                        UserVO student = pair.getLeft();
+                        byte[] wordBytes = pair.getRight();
+                        String fileName = String.format("%s_%s.%s", student.getName(), student.getOfficialNumber(), "docx");
+                        return new ZipExport.ZipItem(fileName, wordBytes);
+                    } catch (Exception e) {
+                        log.error("exportBatchToZip failed, studentId: {}, reason: {}", studentId, e.getMessage());
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+
+        try {
+            return ZipExport.packToZip(zipItems);
+        } catch (IOException e) {
+            throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "ZIP打包失败"+e.getMessage());
+        }
+    }
+
+
+    public byte[] exportWordTest(Long assignmentId, Long studentId) throws IOException, TemplateException {
+
+        String assignmentName = "My Favorite Season";
+        String content = "My favorite season is autumn. The weather is cool and comfortable, and the leaves turn golden, making the scenery beautiful. I enjoy walking in the park and listening to the sound of rustling leaves. Autumn is also a harvest season, with fruits like apples and pears ripe for picking.\n" +
+                "During this season, my family often goes hiking or has picnics. The Mid-Autumn Festival, when we eat mooncakes and admire the full moon, is my favorite holiday. Autumn reminds me of warmth and joy.\n" +
+                "In short, autumn is not only pleasant but also full of happy memories. That’s why I love it most.";
+
+        Map<String, Object> model = new HashMap<>();
+        model.put("title", "title_"+assignmentName);
+        model.put("studentName", "柏琪");
+        model.put("assignmentName", assignmentName);
+        model.put("date", new Date());
+        model.put("content", content.replaceAll("\n", "<br/>"));
+
+        return wordExporter.exportCompositionWord(model);
+    }
+
+    private String processContent(String content) {
+        return content.replaceAll("\n", "<br/>");
+    }
+
+}

+ 127 - 0
src/main/java/com/njuzr/eaibackend/utils/WordExporter.java

@@ -0,0 +1,127 @@
+package com.njuzr.eaibackend.utils;
+
+import com.njuzr.eaibackend.exception.MyException;
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Element;
+import org.jsoup.nodes.Node;
+import org.jsoup.nodes.TextNode;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class WordExporter {
+
+    @Autowired
+    private Configuration freemarkerConfig;
+
+    public byte[] exportCompositionWord(Map<String, Object> model) {
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try {
+            // 加载模板并渲染
+            Template template = freemarkerConfig.getTemplate("composition.ftl");
+            String htmlContent = FreeMarkerTemplateUtils.processTemplateIntoString(template, model);
+
+            // 创建Word文档
+            XWPFDocument document = new XWPFDocument();
+            parseHtmlToWord(document, htmlContent); // 关键改造点
+
+            document.write(out);
+            document.close();
+        } catch (Exception e){
+            log.error("exportWord failed. reason: {}", e.getMessage());
+            throw MyException.create(HttpStatus.BAD_REQUEST, "作业导出失败,请重试");
+        }
+
+        return out.toByteArray();
+    }
+
+    /**
+     * 将HTML内容解析为Word格式(支持基础HTML标签)
+     */
+    private void parseHtmlToWord(XWPFDocument document, String html) {
+        // 使用JSoup解析HTML
+        Element body = Jsoup.parse(html).body();
+
+        // 递归处理所有子节点
+        for (Node node : body.childNodes()) {
+            processNode(document, node);
+        }
+    }
+
+    /**
+     * 递归处理HTML节点
+     */
+    private void processNode(XWPFDocument doc, Node node) {
+        if (node instanceof TextNode) {
+            // 文本节点直接添加
+            XWPFParagraph para = doc.createParagraph();
+            XWPFRun run = para.createRun();
+            run.setText(((TextNode) node).text());
+        } else if (node instanceof Element) {
+            Element element = (Element) node;
+            switch (element.tagName().toLowerCase()) {
+                case "h1":
+                case "h2":
+                case "h3":
+                case "h4":
+                case "h5":
+                case "h6":
+                    // 处理标题
+                    XWPFParagraph heading = doc.createParagraph();
+                    heading.setStyle("Heading" + element.tagName().charAt(1));
+                    XWPFRun headingRun = heading.createRun();
+                    headingRun.setText(element.text());
+                    headingRun.setBold(true);
+                    break;
+                case "p":
+                    // 处理段落
+                    XWPFParagraph para = doc.createParagraph();
+                    XWPFRun run = para.createRun();
+                    run.setText(element.text());
+                    break;
+                case "b":
+                case "strong":
+                    // 加粗文本
+                    XWPFParagraph boldPara = doc.createParagraph();
+                    XWPFRun boldRun = boldPara.createRun();
+                    boldRun.setText(element.text());
+                    boldRun.setBold(true);
+                    break;
+                case "i":
+                case "em":
+                    // 斜体文本
+                    XWPFParagraph italicPara = doc.createParagraph();
+                    XWPFRun italicRun = italicPara.createRun();
+                    italicRun.setText(element.text());
+                    italicRun.setItalic(true);
+                    break;
+                case "ul":
+                case "ol":
+                    // 处理列表
+                    for (Element li : element.children()) {
+                        XWPFParagraph listPara = doc.createParagraph();
+                        XWPFRun listRun = listPara.createRun();
+                        listRun.setText("• " + li.text()); // 简单列表符号
+                    }
+                    break;
+                default:
+                    // 默认处理(递归子节点)
+                    for (Node child : node.childNodes()) {
+                        processNode(doc, child);
+                    }
+            }
+        }
+    }
+}

+ 43 - 0
src/main/java/com/njuzr/eaibackend/utils/ZipExport.java

@@ -0,0 +1,43 @@
+package com.njuzr.eaibackend.utils;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+
+import java.io.*;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+public class ZipExport {
+    private static final int BUFFER_SIZE = 1024 * 4; // 4KB缓冲区
+
+    /**
+     * 将多个字节数组(如Word文件)打包为ZIP
+     */
+    public static byte[] packToZip(List<ZipItem> items) throws IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+             ZipOutputStream zos = new ZipOutputStream(baos)) {
+            
+            for (ZipItem item : items) {
+                addToZip(zos, item.getFileName(), item.getContent());
+            }
+            zos.finish();
+            return baos.toByteArray();
+        }
+    }
+
+    private static void addToZip(ZipOutputStream zos, String fileName, byte[] content) 
+            throws IOException {
+        ZipEntry entry = new ZipEntry(fileName);
+        zos.putNextEntry(entry);
+        zos.write(content);
+        zos.closeEntry();
+    }
+
+    @Data
+    @AllArgsConstructor
+    public static class ZipItem {
+        private final String fileName;
+        private final byte[] content;
+    }
+}

+ 10 - 0
src/main/resources/templates/composition.ftl

@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<body>
+<p>姓名: ${studentName}</p>
+<p>学号: ${officialNumber}</p>
+<div>
+    ${content}
+</div>
+</body>
+</html>