liujie
2026-04-09 3adfcc5a9829a0231bf95c1a7e3aaad1d28b05d5
update
4个文件已修改
406 ■■■■■ 已修改文件
admin-server/src/main/java/com/estate/expert/admin/controller/stat/ReportController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin-server/src/main/java/com/estate/expert/admin/utils/wordtopdf/WordToPdfConverter.java 358 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin-server/src/main/java/com/estate/expert/admin/utils/wordtopdf/WordToPdfTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin-server/src/main/resources/application.yml 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
admin-server/src/main/java/com/estate/expert/admin/controller/stat/ReportController.java
@@ -373,9 +373,11 @@
            Files.write(filePath, fileBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
            log.info("文件已保存到服务器: {}", filePath.toString());
            System.out.println("文件已保存到服务器: " + filePath.toString());
            return fileName;
        } catch (Exception e) {
            log.error("保存文件到服务器失败: {}", fileName, e);
            System.out.println("保存文件到服务器失败: " + e.getMessage());
            // 不抛出异常,避免影响用户下载
        }
        return null;
admin-server/src/main/java/com/estate/expert/admin/utils/wordtopdf/WordToPdfConverter.java
@@ -1,6 +1,15 @@
package com.estate.expert.admin.utils.wordtopdf;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFFooter;
import org.apache.poi.xwpf.usermodel.XWPFHeader;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.core.office.OfficeManager;
import org.jodconverter.local.LocalConverter;
@@ -8,37 +17,46 @@
import javax.annotation.PreDestroy;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * Word 转 PDF 工具类(使用 LibreOffice/OpenOffice)
 * 支持图表、图片、表格等复杂格式
 */
@Slf4j
public class WordToPdfConverter {
    
    private OfficeManager officeManager;
    private static WordToPdfConverter instance;
    private final AtomicInteger conversionCount = new AtomicInteger(0);
    // LibreOffice 安装路径(根据您的安装位置调整)
    private static final String LIBRE_OFFICE_PATH = "C:\\Program Files\\LibreOffice";
    //private static final String LIBRE_OFFICE_PATH = "C:\\Program Files (x86)\\LibreOffice";
    // 支持转换的文件格式
    private static final String LIBRE_OFFICE_PATH = "/usr/lib/libreoffice";
    private static final String DEFAULT_FALLBACK_FONT = "Noto Sans CJK SC";
    private static final Set<String> WINDOWS_CJK_FONTS = new LinkedHashSet<>(Arrays.asList(
            "\u5b8b\u4f53", "SimSun",
            "\u5fae\u8f6f\u96c5\u9ed1", "Microsoft YaHei",
            "\u9ed1\u4f53", "SimHei",
            "\u4eff\u5b8b", "FangSong",
            "\u6977\u4f53", "KaiTi",
            "\u65b0\u5b8b\u4f53", "NSimSun",
            "\u7b49\u7ebf", "DengXian"
    ));
    private static final List<String> SUPPORTED_EXTENSIONS = Arrays.asList(
            ".doc", ".docx", ".rtf", ".txt", ".odt"
    );
    private static WordToPdfConverter instance;
    private final AtomicInteger conversionCount = new AtomicInteger(0);
    private OfficeManager officeManager;
    
    private WordToPdfConverter() {
        initializeOfficeManager();
    }
    
    /**
     * 获取单例实例
     */
    public static synchronized WordToPdfConverter getInstance() {
        if (instance == null) {
            instance = new WordToPdfConverter();
@@ -46,191 +64,281 @@
        return instance;
    }
    
    /**
     * 初始化 Office 管理器
     */
    private void initializeOfficeManager() {
        try {
            log.info("正在初始化 LibreOffice 管理器...");
            log.info("Initializing LibreOffice manager");
            
            // 检查 LibreOffice 是否安装
            File officeHome = new File(LIBRE_OFFICE_PATH);
            if (!officeHome.exists() || !officeHome.isDirectory()) {
                throw new RuntimeException("未找到 LibreOffice 安装目录: " + LIBRE_OFFICE_PATH +
                                         "\n请安装 LibreOffice 或修改 LIBRE_OFFICE_PATH 配置");
                throw new RuntimeException("LibreOffice home not found: " + LIBRE_OFFICE_PATH);
            }
            
            // 创建 Office 管理器
            officeManager = LocalOfficeManager.builder()
                    .officeHome(officeHome)
                    .portNumbers(2002)  // 使用 2002 端口
                    .processTimeout(300000L) // 5分钟超时
                    .taskExecutionTimeout(300000L) // 5分钟任务超时
                    .maxTasksPerProcess(20) // 每个进程最大任务数
                    .install() // 如果未安装则尝试安装
                    .portNumbers(2002)
                    .processTimeout(300000L)
                    .taskExecutionTimeout(300000L)
                    .maxTasksPerProcess(20)
                    .install()
                    .build();
            
            // 启动服务
            officeManager.start();
            log.info("LibreOffice 管理器启动成功,服务端口: 2002");
            log.info("LibreOffice manager started on port 2002");
        } catch (Exception e) {
            log.error("初始化 LibreOffice 管理器失败", e);
            throw new RuntimeException("无法启动 LibreOffice 服务: " + e.getMessage(), e);
            log.error("Failed to initialize LibreOffice manager", e);
            throw new RuntimeException("Unable to start LibreOffice service: " + e.getMessage(), e);
        }
    }
    
    /**
     * 转换 Word 文件为 PDF
     * @param wordFilePath Word 文件路径
     * @param pdfFilePath  PDF 输出路径
     * @return 是否转换成功
     */
    public boolean convert(String wordFilePath, String pdfFilePath) {
        return convert(new File(wordFilePath), new File(pdfFilePath));
    }
    
    /**
     * 转换 Word 文件为 PDF
     * @param wordFile Word 文件
     * @param pdfFile  PDF 输出文件
     * @return 是否转换成功
     */
    public boolean convert(File wordFile, File pdfFile) {
        long startTime = System.currentTimeMillis();
        int currentCount = conversionCount.incrementAndGet();
        File conversionSource = wordFile;
        
        try {
            // 验证输入文件
            validateInputFile(wordFile);
            // 准备输出目录
            prepareOutputDirectory(pdfFile);
            conversionSource = prepareConversionSource(wordFile);
            
            log.info("开始转换 [{}]: {} -> {}", currentCount,
                    wordFile.getName(), pdfFile.getName());
            log.info("Start conversion [{}]: {} -> {}", currentCount, wordFile.getName(), pdfFile.getName());
            
            // 执行转换
            LocalConverter.builder()
                    .officeManager(officeManager)
                    .build()
                    .convert(wordFile)
                    .convert(conversionSource)
                    .to(pdfFile)
                    .execute();
            
            // 验证输出文件
            boolean success = validateOutputFile(pdfFile);
            long costTime = System.currentTimeMillis() - startTime;
            if (success) {
                log.info("转换成功 [{}]: {} -> {} (耗时: {}ms)",
                log.info("Conversion succeeded [{}]: {} -> {} ({} ms)",
                        currentCount, wordFile.getName(), pdfFile.getName(), costTime);
            } else {
                log.warn("转换完成但文件可能异常 [{}]: {} (耗时: {}ms)",
                log.warn("Conversion finished with invalid output [{}]: {} ({} ms)",
                        currentCount, wordFile.getName(), costTime);
            }
            return success;
        } catch (Exception e) {
            log.error("转换失败 [{}]: {}", currentCount, wordFile.getName(), e);
            log.error("Conversion failed [{}]: {}", currentCount, wordFile.getName(), e.getMessage(), e);
            return false;
        } finally {
            cleanupTemporarySource(wordFile, conversionSource);
        }
    }
    private File prepareConversionSource(File wordFile) {
        if (!shouldNormalizeDocxFonts(wordFile)) {
            return wordFile;
        }
        try {
            return normalizeDocxFonts(wordFile);
        } catch (Exception e) {
            log.warn("Docx font normalization failed, fallback to original file: {}", wordFile.getAbsolutePath(), e);
            return wordFile;
        }
    }
    private boolean shouldNormalizeDocxFonts(File wordFile) {
        return isLinux() && wordFile.getName().toLowerCase(Locale.ROOT).endsWith(".docx");
    }
    private boolean isLinux() {
        return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("linux");
    }
    private File normalizeDocxFonts(File sourceFile) throws IOException {
        Path tempFile = Files.createTempFile("estate-word-font-normalized-", ".docx");
        String fallbackFont = resolveFallbackFont();
        int replacedRuns = 0;
        try (InputStream inputStream = Files.newInputStream(sourceFile.toPath());
             XWPFDocument document = new XWPFDocument(inputStream);
             OutputStream outputStream = Files.newOutputStream(tempFile)) {
            replacedRuns += normalizeParagraphs(document.getParagraphs(), fallbackFont);
            replacedRuns += normalizeTables(document.getTables(), fallbackFont);
            for (XWPFHeader header : document.getHeaderList()) {
                replacedRuns += normalizeParagraphs(header.getParagraphs(), fallbackFont);
                replacedRuns += normalizeTables(header.getTables(), fallbackFont);
            }
            for (XWPFFooter footer : document.getFooterList()) {
                replacedRuns += normalizeParagraphs(footer.getParagraphs(), fallbackFont);
                replacedRuns += normalizeTables(footer.getTables(), fallbackFont);
            }
            document.write(outputStream);
        }
        log.info("Docx font normalization finished, fallbackFont={}, replacedRuns={}, source={}, temp={}",
                fallbackFont, replacedRuns, sourceFile.getAbsolutePath(), tempFile);
        return tempFile.toFile();
    }
    private int normalizeParagraphs(List<XWPFParagraph> paragraphs, String fallbackFont) {
        int replacedRuns = 0;
        for (XWPFParagraph paragraph : paragraphs) {
            replacedRuns += normalizeParagraph(paragraph, fallbackFont);
        }
        return replacedRuns;
    }
    private int normalizeParagraph(XWPFParagraph paragraph, String fallbackFont) {
        int replacedRuns = 0;
        for (XWPFRun run : paragraph.getRuns()) {
            if (normalizeRunFont(run, fallbackFont)) {
                replacedRuns++;
            }
        }
        return replacedRuns;
    }
    private int normalizeTables(List<XWPFTable> tables, String fallbackFont) {
        int replacedRuns = 0;
        for (XWPFTable table : tables) {
            for (XWPFTableRow row : table.getRows()) {
                for (XWPFTableCell cell : row.getTableCells()) {
                    replacedRuns += normalizeBodyElements(cell.getBodyElements(), fallbackFont);
                }
            }
        }
        return replacedRuns;
    }
    private int normalizeBodyElements(List<IBodyElement> bodyElements, String fallbackFont) {
        int replacedRuns = 0;
        for (IBodyElement bodyElement : bodyElements) {
            if (bodyElement instanceof XWPFParagraph paragraph) {
                replacedRuns += normalizeParagraph(paragraph, fallbackFont);
            } else if (bodyElement instanceof XWPFTable table) {
                replacedRuns += normalizeTables(List.of(table), fallbackFont);
            }
        }
        return replacedRuns;
    }
    private boolean normalizeRunFont(XWPFRun run, String fallbackFont) {
        boolean shouldReplace = shouldReplaceFont(run.getFontFamily())
                || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.ascii))
                || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.cs))
                || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.eastAsia))
                || shouldReplaceFont(run.getFontFamily(XWPFRun.FontCharRange.hAnsi));
        if (!shouldReplace) {
            return false;
        }
        run.setFontFamily(fallbackFont);
        run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.ascii);
        run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.cs);
        run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.eastAsia);
        run.setFontFamily(fallbackFont, XWPFRun.FontCharRange.hAnsi);
        return true;
    }
    
    /**
     * 验证输入文件
     */
    private boolean shouldReplaceFont(String fontName) {
        return fontName != null && WINDOWS_CJK_FONTS.contains(fontName.trim());
    }
    private String resolveFallbackFont() {
        String systemProperty = System.getProperty("estate.word.pdf.font");
        if (systemProperty != null && !systemProperty.isBlank()) {
            return systemProperty.trim();
        }
        String environmentValue = System.getenv("ESTATE_WORD_PDF_FONT");
        if (environmentValue != null && !environmentValue.isBlank()) {
            return environmentValue.trim();
        }
        return DEFAULT_FALLBACK_FONT;
    }
    private void cleanupTemporarySource(File originalFile, File conversionSource) {
        if (conversionSource == null || conversionSource.equals(originalFile)) {
            return;
        }
        try {
            Files.deleteIfExists(conversionSource.toPath());
        } catch (IOException e) {
            log.warn("Failed to delete temporary docx: {}", conversionSource.getAbsolutePath(), e);
        }
    }
    private void validateInputFile(File wordFile) {
        if (wordFile == null) {
            throw new IllegalArgumentException("输入文件不能为空");
            throw new IllegalArgumentException("Input file must not be null");
        }
        
        if (!wordFile.exists()) {
            throw new IllegalArgumentException("输入文件不存在: " + wordFile.getAbsolutePath());
            throw new IllegalArgumentException("Input file does not exist: " + wordFile.getAbsolutePath());
        }
        
        if (!wordFile.isFile()) {
            throw new IllegalArgumentException("输入路径不是文件: " + wordFile.getAbsolutePath());
            throw new IllegalArgumentException("Input path is not a file: " + wordFile.getAbsolutePath());
        }
        
        // 检查文件格式
        String fileName = wordFile.getName().toLowerCase();
        boolean supported = SUPPORTED_EXTENSIONS.stream()
                .anyMatch(fileName::endsWith);
        String fileName = wordFile.getName().toLowerCase(Locale.ROOT);
        boolean supported = SUPPORTED_EXTENSIONS.stream().anyMatch(fileName::endsWith);
        if (!supported) {
            throw new IllegalArgumentException("不支持的文件格式: " + fileName +
                    "\n支持的格式: " + SUPPORTED_EXTENSIONS);
            throw new IllegalArgumentException("Unsupported file type: " + fileName + ", supported: " + SUPPORTED_EXTENSIONS);
        }
        
        // 检查文件大小(限制 100MB)
        long fileSize = wordFile.length();
        long maxSize = 100 * 1024 * 1024; // 100MB
        long maxSize = 100L * 1024L * 1024L;
        if (fileSize > maxSize) {
            throw new IllegalArgumentException("文件过大: " + (fileSize / 1024 / 1024) +
                    "MB (最大支持 100MB)");
            throw new IllegalArgumentException("File is too large: " + (fileSize / 1024 / 1024) + "MB, max is 100MB");
        }
    }
    
    /**
     * 准备输出目录
     */
    private void prepareOutputDirectory(File pdfFile) {
        File parentDir = pdfFile.getParentFile();
        if (parentDir != null && !parentDir.exists()) {
            boolean created = parentDir.mkdirs();
            if (!created) {
                throw new RuntimeException("无法创建输出目录: " + parentDir.getAbsolutePath());
            }
        if (parentDir != null && !parentDir.exists() && !parentDir.mkdirs()) {
            throw new RuntimeException("Unable to create output directory: " + parentDir.getAbsolutePath());
        }
    }
    
    /**
     * 验证输出文件
     */
    private boolean validateOutputFile(File pdfFile) {
        if (!pdfFile.exists()) {
            log.error("输出文件未创建: {}", pdfFile.getAbsolutePath());
            log.error("Output file was not created: {}", pdfFile.getAbsolutePath());
            return false;
        }
        
        if (pdfFile.length() == 0) {
            log.error("输出文件为空: {}", pdfFile.getAbsolutePath());
            log.error("Output file is empty: {}", pdfFile.getAbsolutePath());
            return false;
        }
        
        // 检查文件头是否为 PDF
        try (java.io.FileInputStream fis = new java.io.FileInputStream(pdfFile)) {
        try (FileInputStream inputStream = new FileInputStream(pdfFile)) {
            byte[] header = new byte[4];
            int read = fis.read(header);
            int read = inputStream.read(header);
            if (read >= 4) {
                // PDF 文件头应该是 "%PDF"
                boolean isPdf = header[0] == '%' &&
                                header[1] == 'P' &&
                                header[2] == 'D' &&
                                header[3] == 'F';
                boolean isPdf = header[0] == '%'
                        && header[1] == 'P'
                        && header[2] == 'D'
                        && header[3] == 'F';
                if (!isPdf) {
                    log.warn("输出文件可能不是有效的 PDF 文件: {}", pdfFile.getAbsolutePath());
                    log.warn("Output file is not a valid pdf: {}", pdfFile.getAbsolutePath());
                }
                return isPdf;
            }
        } catch (Exception e) {
            log.warn("验证 PDF 文件头失败", e);
            log.warn("Failed to validate pdf header", e);
        }
        
        return true;
    }
    
    /**
     * 批量转换
     * @param wordFiles Word 文件列表
     * @param outputDir 输出目录
     * @return 成功转换的文件数量
     */
    public int batchConvert(List<File> wordFiles, File outputDir) {
        if (!outputDir.exists()) {
            outputDir.mkdirs();
        if (!outputDir.exists() && !outputDir.mkdirs()) {
            throw new RuntimeException("Unable to create batch output directory: " + outputDir.getAbsolutePath());
        }
        
        int successCount = 0;
@@ -239,66 +347,48 @@
            try {
                String baseName = getFileBaseName(wordFile);
                File pdfFile = new File(outputDir, baseName + ".pdf");
                log.info("批量转换进度: {}/{} - {}", i + 1, wordFiles.size(), wordFile.getName());
                log.info("Batch conversion progress: {}/{} - {}", i + 1, wordFiles.size(), wordFile.getName());
                if (convert(wordFile, pdfFile)) {
                    successCount++;
                }
            } catch (Exception e) {
                log.error("批量转换失败: {}", wordFile.getName(), e);
                log.error("Batch conversion failed: {}", wordFile.getName(), e);
            }
        }
        
        log.info("批量转换完成: 成功 {}/{}", successCount, wordFiles.size());
        log.info("Batch conversion finished: {}/{}", successCount, wordFiles.size());
        return successCount;
    }
    
    /**
     * 获取文件名(不含扩展名)
     */
    private String getFileBaseName(File file) {
        String name = file.getName();
        int dotIndex = name.lastIndexOf('.');
        return (dotIndex == -1) ? name : name.substring(0, dotIndex);
        return dotIndex == -1 ? name : name.substring(0, dotIndex);
    }
    
    /**
     * 获取转换统计
     */
    public int getConversionCount() {
        return conversionCount.get();
    }
    
    /**
     * 重启 Office 服务
     */
    public void restart() {
        log.info("正在重启 LibreOffice 服务...");
        log.info("Restarting LibreOffice service");
        shutdown();
        initializeOfficeManager();
        log.info("LibreOffice 服务重启完成");
        log.info("LibreOffice service restarted");
    }
    
    /**
     * 关闭 Office 管理器
     */
    @PreDestroy
    public void shutdown() {
        if (officeManager != null && officeManager.isRunning()) {
            try {
                officeManager.stop();
                log.info("LibreOffice 服务已关闭");
                log.info("LibreOffice service stopped");
            } catch (OfficeException e) {
                log.error("关闭 LibreOffice 服务时出错", e);
                log.error("Failed to stop LibreOffice service", e);
            }
        }
    }
    
    /**
     * 检查服务状态
     */
    public boolean isServiceRunning() {
        return officeManager != null && officeManager.isRunning();
    }
admin-server/src/main/java/com/estate/expert/admin/utils/wordtopdf/WordToPdfTest.java
@@ -39,8 +39,8 @@
        System.out.println("\n--- 测试1:单文件转换 ---");
        WordToPdfConverter converter = WordToPdfConverter.getInstance();
        // 请修改为您的 Word 文件路径
        String wordFilePath = "F:\\file\\estate\\reports\\"+uuid+".docx"; // 包含图表的 Word 文件
        String pdfFilePath = "F:\\file\\estate\\reports\\"+uuid+".pdf";
        String wordFilePath = "/data/uploads/reports/"+uuid+".docx"; // 包含图表的 Word 文件
        String pdfFilePath = "/data/uploads/reports/"+uuid+".pdf";
        
        File wordFile = new File(wordFilePath);
        if (!wordFile.exists()) {
@@ -68,7 +68,7 @@
        } else {
            System.out.println("✗ 单文件转换失败");
        }
        return "http://221.182.45.100:11225/images/reports/"+uuid+".pdf";
        return "http://xllpgzx.com/report/"+uuid+".pdf";
    }
    
    /**
admin-server/src/main/resources/application.yml
@@ -1,10 +1,5 @@
server:
  port: 8080
logging:
  config: classpath:logback.xml
  level:
      root: info
  port: 8182
spring:
  profiles:
@@ -30,9 +25,9 @@
      max-request-size: 100MB
  datasource:
    url: jdbc:mysql://localhost:3306/estate?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull
    username: root
    password: 123456
    url: jdbc:mysql://localhost:3306/estate_dgzp?useSSL=false&autoReconnect=true&useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&allowPublicKeyRetrieval=true
    username: admin
    password: ZXtzbsMuCIFbmBW4HtOjRd
    driver-class-name: com.mysql.jdbc.Driver
    hikari:
      auto-commit: true
@@ -45,32 +40,23 @@
  # JPA
  jpa:
    hibernate:
      ddl-auto: update
    #      ddl-auto: update
    show-sql: false
    
  # MyBatis-Plus
  mybatis-plus:
    mapper-locations: classpath:mapper/*.xml
    type-aliases-package: com.estate.expert.admin.domain.entity
    configuration:
      map-underscore-to-camel-case: true
      use-generated-keys: true
      default-statement-timeout: 30
    global-config:
      db-config:
        logic-delete-field: deleted
        logic-delete-value: 1
        logic-not-delete-value: 0
# 上传文件目录
file:
  upload:
    #    dir: /data/uploads
    dir: F:\file\estate
    dir: /data/uploads
# 高德地图API密钥
map:
  api:
    key: 5ad97434d47b7cc74b4145d5509d28d2
# 模糊评估API地址
fuzzy:
  api:
    url: http://localhost:5000/api/Fuzzy_evaluation
license:
  key: lI0Qvh4sExRSocj5hUsiykvZzzEkquy04aydOb22zh70O58gdourrlAMZAu/BdATBk3xqmDxn0y8+AM1BYgwEOREcS9+S8V6FUFtoJpPDu4=