基于Spring Boot + Thymeleaf + Flying Saucer实现PDF导出功能

PDF导出功能实现文档

一、功能概述

基于Spring Boot + Thymeleaf + Flying Saucer实现PDF导出功能,支持动态数据渲染和多附件整合。

二、核心实现

1. 控制器层 (Controller)

java

less 复制代码
@RestController
@RequestMapping("/api/pdf")
public class PdfExportController {
    
    @Autowired
    private PdfGenerateService pdfGenerateService;
    
    /**
     * 导出PDF接口
     * @param clueId 业务ID
     * @param response HTTP响应
     */
    @GetMapping("/generate")
    public void generatePdf(
            @RequestParam("clueId") String clueId,
            HttpServletResponse response) {
        
        try {
            pdfGenerateService.generateMonitoringArchivePdf(clueId, response);
        } catch (Exception e) {
            log.error("导出PDF失败: {}", e.getMessage(), e);
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }
}

2. 服务层实现 (Service)

java

scss 复制代码
@Service
@Slf4j
public class PdfGenerateServiceImpl implements PdfGenerateService {
    
    @Autowired
    private RiskClueBasicInfoMapper riskClueBasicInfoMapper;
    
    @Autowired
    private RiskCheckFamilyMapper riskCheckFamilyMapper;
    
    @Autowired
    private RiskCheckReportMapper riskCheckReportMapper;
    
    @Autowired
    private TblSysFileRelationService fileRelationService;
    
    /**
     * 生成监测档案PDF
     */
    @Override
    public void generateMonitoringArchivePdf(String clueId, HttpServletResponse response) throws IOException {
        
        // 1. 查询业务数据
        RiskClueBasicInfo basicInfo = riskClueBasicInfoMapper.selectById(clueId);
        if (basicInfo == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "数据不存在");
            return;
        }
        
        // 2. 构建数据模型
        PovertyMonitoringArchive archive = createMockData(clueId);
        Map<String, Object> dataModel = convertToDataModel(archive);
        
        // 3. 设置响应头
        String fileName = URLEncoder.encode(
                archive.getVillageName() + "-" + archive.getHouseholdHead() + "档案.pdf",
                StandardCharsets.UTF_8.name()
        );
        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition", "attachment; filename="" + fileName + """);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        
        // 4. 生成并输出PDF
        try {
            generatePdf("archive.ftl", clueId, dataModel, response.getOutputStream());
        } catch (Exception e) {
            throw new RuntimeException("PDF生成失败", e);
        }
    }
    
    /**
     * 构建数据模型
     */
    private Map<String, Object> convertToDataModel(PovertyMonitoringArchive archive) {
        Map<String, Object> dataModel = new HashMap<>();
        
        // 基础信息
        dataModel.put("fileNo", archive.getFileNo());
        dataModel.put("countyName", archive.getCountyName());
        dataModel.put("townName", archive.getTownName());
        dataModel.put("villageName", archive.getVillageName());
        dataModel.put("archiveTime", formatDate(archive.getArchiveTime()));
        
        // 核查信息
        dataModel.put("checkMethod", archive.getCheckMethod());
        dataModel.put("checkTime", archive.getCheckTime());
        dataModel.put("householdHead", archive.getHouseholdHead());
        dataModel.put("phone", archive.getPhone());
        dataModel.put("familyPopulation", archive.getFamilyPopulation());
        dataModel.put("householdType", archive.getHouseholdType());
        dataModel.put("familyMembers", archive.getCenCheckMember());
        
        // 收支信息
        dataModel.put("totalIncome", archive.getTotalIncome());
        dataModel.put("salaryIncome", archive.getSalaryIncome());
        dataModel.put("businessIncome", archive.getBusinessIncome());
        dataModel.put("propertyIncome", archive.getPropertyIncome());
        dataModel.put("transferIncome", archive.getTransferIncome());
        dataModel.put("perCapitaIncome", archive.getPerCapitaIncome());
        dataModel.put("complianceExpense", archive.getComplianceExpense());
        dataModel.put("claimIncome", archive.getClaimIncome());
        dataModel.put("illnessExpense", archive.getIllnessExpense());
        dataModel.put("educationExpense", archive.getEducationExpense());
        dataModel.put("accidentExpense", archive.getAccidentExpense());
        dataModel.put("otherExpense", archive.getOtherExpense());
        dataModel.put("totalRigidExpense", archive.getTotalRigidExpense());
        dataModel.put("netPerCapitaIncome", archive.getNetPerCapitaIncome());
        
        // 保障信息
        dataModel.put("housingSafety", archive.getHousingSafety());
        dataModel.put("waterSafety", archive.getWaterSafety());
        dataModel.put("schoolingStatus", archive.getSchoolingStatus());
        dataModel.put("medicalInsurance", archive.getMedicalInsurance());
        
        // 风险信息
        dataModel.put("riskJudgment", archive.getRiskJudgment());
        dataModel.put("riskType", archive.getRiskType());
        dataModel.put("povertyCauseAnalysis", archive.getPovertyCauseAnalysis());
        dataModel.put("farmerSignature", archive.getFarmerSignature());
        dataModel.put("checkerSignature", archive.getCheckerSignature());
        
        // 附件列表
        dataModel.put("list2", archive.getList2());
        dataModel.put("list3", archive.getList3());
        dataModel.put("list4", archive.getList4());
        dataModel.put("list5", archive.getList5());
        dataModel.put("list6", archive.getList6());
        dataModel.put("list7", archive.getList7());
        dataModel.put("list8", archive.getList8());
        dataModel.put("list9", archive.getList9());
        dataModel.put("list11", archive.getList11());
        dataModel.put("list100", archive.getList100());
        
        return dataModel;
    }
    
    /**
     * 核心PDF生成方法
     */
    public void generatePdf(String ftlName, String bizId, 
                           Map<String, Object> dataModel, 
                           OutputStream outputStream) throws Exception {
        // 1. 渲染HTML模板
        String htmlContent = renderHtmlFromFtl(ftlName, dataModel);
        
        // 2. 清理HTML为XHTML格式
        String cleanedHtml = normalizeToXhtml(htmlContent);
        
        // 3. 生成PDF
        buildPdf(cleanedHtml, bizId, outputStream);
    }
    
    /**
     * 渲染FreeMarker模板
     */
    private static String renderHtmlFromFtl(String ftlName, Map<String, Object> dataModel) throws Exception {
        Configuration cfg = new Configuration(Configuration.VERSION_2_3_30);
        cfg.setDefaultEncoding("UTF-8");
        cfg.setClassForTemplateLoading(PdfExportController.class, "/template/");
        cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        
        Template template = cfg.getTemplate(ftlName);
        try (StringWriter writer = new StringWriter()) {
            template.process(dataModel, writer);
            return writer.toString();
        }
    }
    
    /**
     * 转换HTML为XHTML格式
     */
    private static String normalizeToXhtml(String htmlContent) {
        Document document = Jsoup.parse(htmlContent);
        document.outputSettings()
                .syntax(Document.OutputSettings.Syntax.xml)
                .escapeMode(Entities.EscapeMode.xhtml)
                .charset(StandardCharsets.UTF_8);
        return document.html();
    }
    
    /**
     * 使用Flying Saucer生成PDF
     */
    private void buildPdf(String htmlContent, String bizId, OutputStream outputStream) throws Exception {
        PdfRendererBuilder builder = new PdfRendererBuilder();
        builder.useFastMode();
        
        // 创建内存缓冲区
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        
        try {
            // 生成PDF到内存
            builder.withHtmlContent(htmlContent, 
                    new ClassPathResource("/template/").getURL().toString());
            builder.toStream(baos);
            configureFonts(builder); // 配置字体
            builder.run();
            
            // 获取PDF字节数组
            byte[] pdfBytes = baos.toByteArray();
            
            // 调用上传服务存储PDF
            uploadPdfViaAttachmentService(pdfBytes, bizId, "risk_report_" + System.currentTimeMillis() + ".pdf");
            
            // 输出到响应流
            baos.writeTo(outputStream);
            
            // 更新业务状态
            riskClueBasicInfoMapper.updateRiskClueIsBatchById(bizId);
            log.info("PDF生成并上传成功");
            
        } finally {
            baos.close();
        }
    }
    
    /**
     * 配置中文字体
     */
    private static void configureFonts(PdfRendererBuilder builder) throws IOException {
        registerFontFromJar(builder, "template/static/fonts/FZXBSJT.TTF", "fztyst");
        registerFontFromJar(builder, "template/static/fonts/FSJT.TTF", "fsjt");
        registerFontFromJar(builder, "template/static/fonts/FSGB.TTF", "fsgb");
        registerFontFromJar(builder, "template/static/fonts/KT.TTF", "ktgb");
        registerFontFromJar(builder, "template/static/fonts/calibri.ttf", "calibri");
        
        log.info("中文字体加载成功");
    }
    
    /**
     * 从JAR包加载字体
     */
    private static void registerFontFromJar(PdfRendererBuilder builder, 
                                          String fontPath, 
                                          String fontAlias) throws IOException {
        try (InputStream fontStream = Thread.currentThread()
                .getContextClassLoader().getResourceAsStream(fontPath)) {
            if (fontStream == null) {
                throw new IOException("字体文件未找到: " + fontPath);
            }
            
            // 创建临时文件并注册字体
            Path tempFontFile = Files.createTempFile("pdf-font-", ".ttf");
            Files.copy(fontStream, tempFontFile, StandardCopyOption.REPLACE_EXISTING);
            builder.useFont(tempFontFile.toFile(), fontAlias);
            tempFontFile.toFile().deleteOnExit();
        }
    }
    
    /**
     * 上传PDF到文件服务
     */
    private void uploadPdfViaAttachmentService(byte[] pdfData, 
                                             String bizId, 
                                             String fileName) throws Exception {
        String bizType = "yjsb";
        String orgId = getCurrentOrgId();
        
        // 转换为MultipartFile
        MultipartFile multipartFile = new MockMultipartFile(
                "file",
                fileName,
                "application/pdf",
                new ByteArrayInputStream(pdfData)
        );
        
        // 调用文件上传服务
        Map<String, String> result = fileRelationService.uploadAttachmentFile1(
                multipartFile,
                bizId,
                bizType,
                orgId
        );
        
        log.info("PDF文件关系已记录: {}", result);
    }
}

3. 数据实体类

java

ini 复制代码
@Data
public class PovertyMonitoringArchive {
    // 基础信息
    private String fileNo;
    private String countyName;
    private String townName;
    private String villageName;
    private String archiveTime;
    
    // 核查信息
    private String checkMethod;
    private String checkTime;
    private String householdHead;
    private String phone;
    private Integer familyPopulation;
    private String householdType;
    private List<CenCheckMember> cenCheckMember;
    
    // 收支信息
    private BigDecimal totalIncome;
    private BigDecimal salaryIncome;
    private BigDecimal businessIncome;
    private BigDecimal propertyIncome;
    private BigDecimal transferIncome;
    private BigDecimal perCapitaIncome;
    private BigDecimal complianceExpense;
    private BigDecimal claimIncome;
    private BigDecimal illnessExpense;
    private BigDecimal educationExpense;
    private BigDecimal accidentExpense;
    private BigDecimal otherExpense;
    private BigDecimal totalRigidExpense;
    private BigDecimal netPerCapitaIncome;
    
    // 保障信息
    private String housingSafety;
    private String waterSafety;
    private String schoolingStatus;
    private String medicalInsurance;
    
    // 风险信息
    private String riskJudgment;
    private String riskType;
    private String povertyCauseAnalysis;
    private String farmerSignature;
    private String checkerSignature;
    
    // 附件列表
    private List<TblSysFileRelation> list2;
    private List<TblSysFileRelation> list3;
    private List<TblSysFileRelation> list4;
    private List<TblSysFileRelation> list5;
    private List<TblSysFileRelation> list6;
    private List<TblSysFileRelation> list7;
    private List<TblSysFileRelation> list8;
    private List<TblSysFileRelation> list9;
    private List<TblSysFileRelation> list11;
    private List<String> list100;
}

4. FreeMarker模板 (archive.ftl)

html

xml 复制代码
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <style>
        @page {
            size: A4;
            margin: 2cm;
        }
        body {
            font-family: SimSun, "宋体";
            font-size: 12px;
            line-height: 1.5;
        }
        .header {
            text-align: center;
            margin-bottom: 30px;
        }
        .title {
            font-size: 16px;
            font-weight: bold;
            margin-bottom: 10px;
        }
        .table-container {
            margin: 15px 0;
            page-break-inside: avoid;
        }
        .info-table {
            width: 100%;
            border-collapse: collapse;
            margin: 10px 0;
        }
        .info-table th, .info-table td {
            border: 1px solid #000;
            padding: 6px 8px;
            vertical-align: top;
        }
        .section {
            margin: 20px 0 30px 0;
            page-break-inside: avoid;
        }
        .section-title {
            font-size: 14px;
            font-weight: bold;
            margin: 15px 0 10px 0;
            text-decoration: underline;
        }
        .image-section {
            text-align: center;
            margin: 20px 0;
        }
        .image-item {
            margin: 15px 0;
        }
    </style>
</head>
<body>

<!-- 档案封面 -->
<div class="header">
    <div class="title">NO.${fileNo}</div>
    <div class="title">${countyName}档案资料</div>
    <div class="title">${townName}${villageName}</div>
    <div class="title">归档时间:${archiveTime}</div>
</div>

<!-- 档案目录 -->
<div class="table-container">
    <div class="title">档案目录</div>
    <table class="info-table">
        <tr><td>1. ${countyName}核查</td><td>第2页</td></tr>
        <tr><td>2. ${villageName}签到</td><td>第3页</td></tr>
        <tr><td>3. ${villageName}对象提名名单</td><td>第3页</td></tr>
        <tr><td>4. ${villageName}会议记录</td><td>第3页</td></tr>
        <tr><td>5. ${villageName}影像资料</td><td>第4页</td></tr>
        <tr><td>6. ${villageName}资料</td><td>第4页</td></tr>
        <tr><td>7. ${villageName}对象报告</td><td>第4页</td></tr>
        <tr><td>8. ${townName}报告</td><td>第4页</td></tr>
        <tr><td>9. ${countyName}确定</td><td>第5页</td></tr>
    </table>
</div>

<!-- 监测核查表 -->
<div class="section">
    <div class="section-title">${countyName}核查表</div>
    <div class="title">${townName}${villageName} 核查方式:${checkMethod} 核查时间:${checkTime}</div>

    <table class="info-table">
        <tr><th colspan="4">基本信息</th></tr>
        <tr>
            <td>户主姓名</td><td>${householdHead}</td>
            <td>家庭人口</td><td>${familyPopulation}</td>
        </tr>
        <tr>
            <td>联系电话</td><td>${phone}</td>
            <td>户类型</td><td>${householdType}</td>
        </tr>
    </table>

    <!-- 家庭成员表格 -->
    <table class="info-table">
        <tr><th colspan="4">成员</th></tr>
        <tr><th>姓名</th><th>号码</th><th>与户主关系</th><th>健康状况</th></tr>
        <#list familyMembers as member>
        <tr>
            <td>${member.name}</td>
            <td>${member.idCard}</td>
            <td>${member.relation}</td>
            <td>${member.healthStatus}</td>
        </tr>
        </#list>
    </table>

    <!-- 收支信息表格 -->
    <table class="info-table">
        <tr><th colspan="4">前12个月家庭收支信息</th></tr>
        <tr><td>家庭纯收入</td><td colspan="3">${totalIncome}</td></tr>
        <tr>
            <td>工资性收入</td><td>${salaryIncome}</td>
            <td>经营净收入</td><td>${businessIncome}</td>
        </tr>
        <!-- 更多收支行... -->
    </table>
</div>

<!-- 附件图片部分 -->
<#if list2?has_content>
<div class="section">
    <div class="section-title">${villageName}民代表民主评议签到表</div>
    <div class="image-section">
        <#list list2 as image>
        <div class="image-item">
            <img src="${image.url}" width="600" alt="签到表">
        </div>
        </#list>
    </div>
</div>
</#if>

</body>
</html>

三、Maven依赖

xml

xml 复制代码
<dependencies>
    <!-- Spring Boot Starter -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Thymeleaf模板引擎 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-freemarker</artifactId>
    </dependency>
    
    <!-- Flying Saucer PDF生成 -->
    <dependency>
        <groupId>org.xhtmlrenderer</groupId>
        <artifactId>flying-saucer-pdf</artifactId>
        <version>9.1.22</version>
    </dependency>
    
    <!-- HTML解析 -->
    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
        <version>1.15.3</version>
    </dependency>
    
    <!-- MyBatis Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>
</dependencies>

四、使用方式

1. 前端调用

javascript

javascript 复制代码
// 导出PDF
function exportPdf(clueId) {
    const url = `/api/pdf/generate?clueId=${clueId}`;
    window.open(url, '_blank');
}

2. 直接访问

text

bash 复制代码
GET http://localhost:8080/api/pdf/generate?clueId=123456

3. 响应头说明

text

bash 复制代码
Content-Type: application/pdf
Content-Disposition: attachment; filename="XX村-张三档案.pdf"

五、核心特性

  1. 动态数据渲染:通过FreeMarker模板引擎动态生成HTML
  2. 中文支持:内置多款中文字体,确保中文显示正常
  3. 自动分页:CSS媒体查询实现智能分页
  4. 附件整合:支持图片等附件嵌入PDF
  5. 文件存储:生成的PDF自动上传到文件服务器
  6. 响应式设计:适应不同尺寸的A4页面

六、注意事项

  1. 字体文件 :确保字体文件存放在resources/template/static/fonts/目录
  2. 模板路径 :FreeMarker模板放在resources/template/目录
  3. 内存管理:大文件生成时注意内存使用
  4. 并发处理:高并发场景下考虑异步生成

这个方案适用于需要生成复杂格式、包含动态数据和图片的PDF文档场景。

相关推荐
程序员爱钓鱼2 小时前
Node.js 编程实战:路由与中间件
前端·后端·node.js
程序员爱钓鱼2 小时前
Node.js 编程实战:Express 基础
前端·后端·node.js
CosMr2 小时前
【QT】【FFmpeg】 Qt 中FFmpeg环境搭建以及D__STDC_FORMAT_MACROS、PRId64解答
后端
Knight_AL2 小时前
Spring Boot 的主要特性与传统 Spring 项目的区别
spring boot·后端·spring
回家路上绕了弯2 小时前
一文读懂分布式事务:核心原理、解决方案与实践思考
分布式·后端
踏浪无痕2 小时前
JobFlow 背后:五个让我豁然开朗的设计瞬间
分布式·后端·架构
黄俊懿2 小时前
【深入理解SpringCloud微服务】Gateway简介与模拟Gateway手写一个微服务网关
spring boot·后端·spring·spring cloud·微服务·gateway·架构师
用户2190326527353 小时前
别再到处try-catch了!SpringBoot全局异常处理这样设计
java·spring boot·后端
梁同学与Android3 小时前
Android ---【经验篇】阿里云 CentOS 服务器环境搭建 + SpringBoot项目部署(二)
android·spring boot·后端