Gqingci 5 天之前
父節點
當前提交
463422a6f3

+ 24 - 0
_extract_schema.py

@@ -0,0 +1,24 @@
+import re
+
+with open('e:/Base/sj/sj.sql', 'r', encoding='utf-8') as f:
+    content = f.read()
+
+tables = [
+    'main_post_candidate_review',
+    'main_student_education',
+    'main_student_experience',
+    'main_back_interview',
+]
+
+with open('_schema_out.txt', 'w', encoding='utf-8') as fout:
+    for table in tables:
+        match = re.search(r'CREATE TABLE `' + table + r'`\s*\((.*?)\)\s*ENGINE', content, re.DOTALL | re.IGNORECASE)
+        if match:
+            fout.write(f"=== {table} ===\n")
+            lines = match.group(1).strip().split('\n')
+            for line in lines:
+                line = line.strip()
+                if line and not line.startswith('PRIMARY KEY') and not line.startswith('KEY') and not line.startswith('UNIQUE KEY'):
+                    fout.write(line + "\n")
+        else:
+            fout.write(f"=== {table} NOT FOUND ===\n")

+ 38 - 0
_schema_out.txt

@@ -0,0 +1,38 @@
+=== main_post_candidate_review NOT FOUND ===
+=== main_student_education ===
+`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+`student_id` bigint NOT NULL COMMENT '学员ID (关联 main_student.id)',
+`school` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '学校名称',
+`education` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '学历',
+`start_time` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '开始时间 (如 2022.9)',
+`end_time` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '结束时间 (如 2026.7)',
+`major` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '专业',
+`campus_experience` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '在校经历描述',
+`tenant_id` bigint NULL DEFAULT 0 COMMENT '租户ID',
+`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
+`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
+`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
+`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
+`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
+`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志',
+INDEX `idx_student_id`(`student_id` ASC) USING BTREE
+=== main_student_experience ===
+`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+`student_id` bigint NOT NULL COMMENT '学员ID (关联 main_student.id)',
+`company` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '公司名称',
+`industry` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '所属行业',
+`start_time` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '开始时间',
+`end_time` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '结束时间',
+`is_internship` tinyint(1) NULL DEFAULT 0 COMMENT '是否实习 (0否 1是)',
+`job_title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '职位名称',
+`department` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '所属部门',
+`work_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '工作内容描述',
+`tenant_id` bigint NULL DEFAULT 0 COMMENT '租户ID',
+`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
+`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
+`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
+`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
+`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
+`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志',
+INDEX `idx_student_id`(`student_id` ASC) USING BTREE
+=== main_back_interview NOT FOUND ===

二進制
report_debug_output.docx


+ 0 - 5
ruoyi-modules/ruoyi-main/pom.xml

@@ -85,11 +85,6 @@
             <artifactId>poi-ooxml</artifactId>
             <version>5.2.5</version>
         </dependency>
-        <dependency>
-            <groupId>fr.opensagres.xdocreport</groupId>
-            <artifactId>fr.opensagres.poi.xwpf.converter.pdf</artifactId>
-            <version>2.0.4</version>
-        </dependency>
 
     </dependencies>
 

+ 11 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/controller/PortalCheckController.java

@@ -1,9 +1,11 @@
 package org.dromara.main.controller;
 
 import cn.dev33.satoken.annotation.SaIgnore;
+import jakarta.servlet.http.HttpServletResponse;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.RequiredArgsConstructor;
+import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.domain.R;
 import org.dromara.common.satoken.utils.LoginHelper;
 import org.dromara.common.web.core.BaseController;
@@ -171,6 +173,15 @@ public class PortalCheckController extends BaseController {
         return R.ok("操作成功", mainBackOrderService.generatePortalCandidateReport(recordId, tenantId));
     }
 
+    @GetMapping("/order/candidate/{recordId}/report/download")
+    public void downloadPortalCandidateReport(@PathVariable Long recordId, HttpServletResponse response) {
+        String tenantId = LoginHelper.getTenantId();
+        if (tenantId == null || tenantId.isBlank()) {
+            throw new ServiceException("未登录或token已失效");
+        }
+        mainBackOrderService.downloadPortalCandidateReport(recordId, tenantId, response);
+    }
+
     @PostMapping("/order/create")
     public R<MainBackOrderVo> createOrder(@Validated @RequestBody PortalCheckOrderCreateBo bo) {
         String tenantId = LoginHelper.getTenantId();

+ 31 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainBackInterview.java

@@ -0,0 +1,31 @@
+package org.dromara.main.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.dromara.common.mybatis.core.domain.BaseEntity;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("main_back_interview")
+public class MainBackInterview extends BaseEntity {
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id")
+    private Long id;
+
+    private Long candidateId;
+    private Long recordId;
+    private String intervieweeName;
+    private String intervieweeRelation;
+    private String intervieweeContact;
+    private String qa1;
+    private String qa2;
+    private String qa3;
+    private String qa4;
+    private String qa5;
+    
+    private String tenantId;
+    private String delFlag;
+}

+ 28 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainPostCandidateReview.java

@@ -43,6 +43,34 @@ public class MainPostCandidateReview extends BaseEntity {
     private BigDecimal abilityCRate;
     private String abilityCRemark;
 
+    private String supervisorEval;
+    private String strength;
+    private String improvement;
+
+    private String abilityDName;
+    private BigDecimal abilityDRate;
+    private String abilityDRemark;
+
+    private String colleagueEval;
+    private String cooperation;
+    private String colleagueAbility;
+
+    private String hrEval;
+    private String violation;
+    private String handover;
+
+    private String performCheckResult;
+    private String performRemark;
+
+    private Integer nonCompeteAgreement;
+    private Integer confidentialityAgreement;
+    private String agreementRemark;
+
+    private Integer laborDispute;
+    private String disputeRemark;
+
+    private String conclusionResult;
+
     private String reviewStatus;
 
     @TableLogic

+ 5 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainStudentEducation.java

@@ -21,5 +21,10 @@ public class MainStudentEducation extends BaseEntity {
     private String endTime;
     private String major;
     private String campusExperience;
+
+    private String certNo;
+    private String eduVerification;
+    private String checkResult;
+
     private String delFlag;
 }

+ 5 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/domain/MainStudentExperience.java

@@ -23,5 +23,10 @@ public class MainStudentExperience extends BaseEntity {
     private String jobTitle;
     private String department;
     private String workContent;
+
+    private String lastSalary;
+    private String checkResult;
+    private String checkRemark;
+
     private String delFlag;
 }

+ 6 - 0
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/IMainBackOrderService.java

@@ -1,5 +1,6 @@
 package org.dromara.main.service;
 
+import jakarta.servlet.http.HttpServletResponse;
 import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
 import org.dromara.main.domain.bo.MainBackOrderBo;
@@ -53,4 +54,9 @@ public interface IMainBackOrderService {
      * 生成或获取门户候选人报告
      */
     String generatePortalCandidateReport(Long recordId, String tenantId);
+
+    /**
+     * 下载门户候选人报告
+     */
+    void downloadPortalCandidateReport(Long recordId, String tenantId, HttpServletResponse response);
 }

+ 115 - 1
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/MainBackOrderServiceImpl.java

@@ -5,10 +5,12 @@ import cn.hutool.crypto.digest.DigestUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import org.dromara.common.core.exception.ServiceException;
 import org.dromara.common.core.utils.MapstructUtils;
 import org.dromara.common.core.utils.StringUtils;
+import org.dromara.common.core.utils.file.FileUtils;
 import org.dromara.common.json.utils.JsonUtils;
 import org.dromara.common.mybatis.core.page.PageQuery;
 import org.dromara.common.mybatis.core.page.TableDataInfo;
@@ -51,6 +53,7 @@ import org.dromara.system.domain.bo.SysTenantBo;
 import org.dromara.system.domain.vo.SysTenantVo;
 import org.dromara.system.mapper.SysTenantMapper;
 import org.dromara.system.service.ISysTenantService;
+import org.springframework.http.MediaType;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -571,6 +574,30 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         return reportUrl;
     }
 
+    @Override
+    public void downloadPortalCandidateReport(Long recordId, String tenantId, HttpServletResponse response) {
+        MainBackRecord record = getPortalReportRecord(recordId, tenantId);
+        String reportUrl = StringUtils.isNotBlank(record.getReportUrl())
+            ? record.getReportUrl()
+            : generatePortalCandidateReport(recordId, tenantId);
+
+        MainBackCandidate candidate = mainBackCandidateMapper.selectById(record.getCandidateId());
+        MainStudent student = candidate == null ? null : mainStudentMapper.selectById(candidate.getStudentId());
+        String fileName = sanitizeFileName(
+            safe(student == null ? null : student.getName()),
+            "candidate-report"
+        ) + ".pdf";
+
+        try {
+            FileUtils.setAttachmentResponseHeader(response, fileName);
+            response.setContentType(MediaType.APPLICATION_PDF_VALUE);
+            OssClient ossClient = OssFactory.instance();
+            ossClient.download(ossClient.removeBaseUrl(reportUrl), response.getOutputStream(), response::setContentLengthLong);
+        } catch (Exception e) {
+            throw new ServiceException("下载报告失败: " + e.getMessage());
+        }
+    }
+
     private void fillPortalFields(MainBackOrderVo vo, MainOrder mainOrder) {
         Long count = recordMapper.selectCount(
             new LambdaQueryWrapper<MainBackRecord>()
@@ -595,6 +622,26 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         }
     }
 
+    private MainBackRecord getPortalReportRecord(Long recordId, String tenantId) {
+        if (recordId == null) {
+            throw new ServiceException("候选人记录不能为空");
+        }
+        if (StringUtils.isBlank(tenantId)) {
+            throw new ServiceException("未登录或token已失效");
+        }
+
+        MainBackRecord record = recordMapper.selectById(recordId);
+        if (record == null) {
+            throw new ServiceException("候选人记录不存在");
+        }
+
+        MainBackOrder backOrder = baseMapper.selectById(record.getOrderId());
+        if (backOrder == null || !StringUtils.equals(backOrder.getTenantId(), tenantId)) {
+            throw new ServiceException("无权查看该候选人的报告");
+        }
+        return record;
+    }
+
     private MainStudent matchStudent(PortalCheckOrderCreateBo.CandidateInfo candidateInfo) {
         MainStudent student = mainStudentMapper.selectOne(
             Wrappers.<MainStudent>lambdaQuery()
@@ -958,6 +1005,24 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         // 确保所有空值替换为空格,保证模板格式不被破坏
         placeholders.replaceAll((k, v) -> (v == null || v.isEmpty()) ? " " : v);
 
+        // ========== Checkbox 勾选框 (CB_ 前缀, 格式: "选项1,选项2,...|SELECTED:选中项") ==========
+        // 学历核实 - 核实结果
+        placeholders.put("CB_eduResult", "属实,不属实,无法核实|SELECTED:" + resolveEduCheckResult(context));
+        // 最近工作单位 - 核实结果
+        placeholders.put("CB_companyResult", "属实,部分属实,不属实|SELECTED:" + resolveCompanyCheckResult(context));
+        // 工作表现评估 - 核实结果
+        placeholders.put("CB_performResult", "良好,一般,需关注|SELECTED:" + resolvePerformCheckResult(context));
+        // 限制性协议
+        placeholders.put("CB_restrictAgreement", "竞业禁止协议:是,竞业禁止协议:否,保密协议:是,保密协议:否|SELECTED:");
+        // 劳动争议/失信记录
+        placeholders.put("CB_disputeRecord", "是,否|SELECTED:");
+        // 调查结论
+        placeholders.put("CB_conclusionResult", "推荐入职,有条件推荐,不推荐|SELECTED:" + resolveConclusionResult(context));
+
+        // ========== 备注/信息来源列 ==========
+        placeholders.put("companyRemark", " ");
+        placeholders.put("performRemark", " ");
+
         return wordReportService.generatePdf(placeholders);
     }
 
@@ -1076,6 +1141,56 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
         return source.replaceAll("[\\\\/:*?\"<>|\\s]+", "_");
     }
 
+    /**
+     * 学历核实 checkbox: 属实 / 不属实 / 无法核实
+     * 如果有学历数据则默认"属实",否则"无法核实"
+     */
+    private String resolveEduCheckResult(ReportContext context) {
+        if (context.education != null && StringUtils.isNotBlank(context.education.getSchool())) {
+            return "属实";
+        }
+        return "";
+    }
+
+    /**
+     * 工作单位核实 checkbox: 属实 / 部分属实 / 不属实
+     * 如果有 review 数据则默认"属实",否则留空
+     */
+    private String resolveCompanyCheckResult(ReportContext context) {
+        if (context.review != null) {
+            return "属实";
+        }
+        return "";
+    }
+
+    /**
+     * 工作表现 checkbox: 良好 / 一般 / 需关注
+     * 根据 totalRate 判断:>= 3.5 良好, >= 2 一般, < 2 需关注
+     */
+    private String resolvePerformCheckResult(ReportContext context) {
+        if (context.review != null && context.review.getTotalRate() != null) {
+            double rate = context.review.getTotalRate().doubleValue();
+            if (rate >= 3.5) return "良好";
+            if (rate >= 2.0) return "一般";
+            return "需关注";
+        }
+        return "";
+    }
+
+    /**
+     * 调查结论 checkbox: 推荐入职 / 有条件推荐 / 不推荐
+     * 根据 totalRate 判断:>= 3.5 推荐入职, >= 2 有条件推荐, < 2 不推荐
+     */
+    private String resolveConclusionResult(ReportContext context) {
+        if (context.review != null && context.review.getTotalRate() != null) {
+            double rate = context.review.getTotalRate().doubleValue();
+            if (rate >= 3.5) return "推荐入职";
+            if (rate >= 2.0) return "有条件推荐";
+            return "不推荐";
+        }
+        return "";
+    }
+
     private static class ReportContext {
         private MainStudent student;
         private MainPostCandidateReview review;
@@ -1088,4 +1203,3 @@ public class MainBackOrderServiceImpl implements IMainBackOrderService {
     }
 }
 
-

+ 267 - 62
ruoyi-modules/ruoyi-main/src/main/java/org/dromara/main/service/impl/WordReportService.java

@@ -1,14 +1,16 @@
 package org.dromara.main.service.impl;
 
+import lombok.extern.slf4j.Slf4j;
 import org.apache.poi.xwpf.usermodel.*;
 import org.dromara.common.core.exception.ServiceException;
+
 import org.springframework.core.io.ClassPathResource;
 import org.springframework.stereotype.Component;
-import fr.opensagres.poi.xwpf.converter.pdf.PdfConverter;
-import fr.opensagres.poi.xwpf.converter.pdf.PdfOptions;
 
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
+import java.io.*;
+import java.util.ArrayList;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
 import java.util.regex.Matcher;
@@ -18,133 +20,336 @@ import java.util.regex.Pattern;
  * Word 模板报告服务。
  * <p>
  * 从 classpath 加载 Word 模板 (templates/report_template.docx),
- * 将 {{placeholder}} 占位符替换为实际值,然后转换为 PDF 字节数组。
+ * 将 {{placeholder}} 占位符替换为实际值,
+ * 处理 checkbox 勾选框(Wingdings 字体),
+ * 然后通过 LibreOffice headless 转换为高保真 PDF。
  */
+@Slf4j
 @Component
 public class WordReportService {
 
     private static final String TEMPLATE_PATH = "templates/report_template.docx";
     private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*(\\w+)\\s*}}");
+    /** CB_ 前缀的占位符,表示 checkbox 组,值格式为 "选项1|选项2|选项3|SELECTED:选中项" */
+    private static final Pattern CB_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{CB_(\\w+)}}");
+    private static final String DEBUG_DOCX_PATH = "report_debug_output.docx";
+
+    // Wingdings 字符:选中 ☑ = char 254 (0xFE), 未选中 ☐ = char 168 (0xA8)
+    private static final char WINGDINGS_CHECKED = (char) 0xFE;
+    private static final char WINGDINGS_UNCHECKED = (char) 0xA8;
+
+    private static final String[] SOFFICE_PATHS = {
+        "C:\\Program Files\\LibreOffice\\program\\soffice.exe",
+        "C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe",
+        "D:\\Program Files\\LibreOffice\\program\\soffice.exe",
+        "/usr/bin/soffice",
+        "/usr/local/bin/soffice",
+    };
 
     /**
      * 根据占位符映射生成 PDF。
      *
-     * @param placeholders key=占位符名称(不含花括号),value=要填入的值
+     * @param placeholders key=占位符名称, value=要填入的值。
+     *                     对于 CB_ 前缀的 key,value 格式为 "选项1,选项2,选项3|SELECTED:选中项"
      * @return PDF 文件字节数组
      */
     public byte[] generatePdf(Map<String, String> placeholders) {
         try (InputStream templateStream = new ClassPathResource(TEMPLATE_PATH).getInputStream();
-             XWPFDocument document = new XWPFDocument(templateStream);
-             ByteArrayOutputStream pdfOut = new ByteArrayOutputStream()) {
+             XWPFDocument document = new XWPFDocument(templateStream)) {
 
             // 1. 替换文档正文段落中的占位符
             for (XWPFParagraph paragraph : document.getParagraphs()) {
                 replacePlaceholdersInParagraph(paragraph, placeholders);
             }
 
-            // 2. 替换所有表格中的占位符
+            // 2. 替换所有表格中的占位符(包括 checkbox)
             for (XWPFTable table : document.getTables()) {
                 for (XWPFTableRow row : table.getRows()) {
                     for (XWPFTableCell cell : row.getTableCells()) {
-                        for (XWPFParagraph paragraph : cell.getParagraphs()) {
+                        for (XWPFParagraph paragraph : new ArrayList<>(cell.getParagraphs())) {
+                            // 检查是否是 checkbox 占位符
+                            String paraText = paragraph.getText();
+                            if (paraText != null) {
+                                Matcher cbMatcher = CB_PLACEHOLDER_PATTERN.matcher(paraText);
+                                if (cbMatcher.find()) {
+                                    String cbKey = "CB_" + cbMatcher.group(1);
+                                    String cbValue = placeholders.getOrDefault(cbKey, "");
+                                    applyCheckboxParagraph(cell, paragraph, cbValue);
+                                    continue;
+                                }
+                            }
                             replacePlaceholdersInParagraph(paragraph, placeholders);
                         }
                     }
                 }
             }
 
-            // 3. 转换为 PDF
-            PdfOptions options = PdfOptions.create();
-            PdfConverter.getInstance().convert(document, pdfOut, options);
+            // 3. 保存到临时文件
+            Path tempDir = Files.createTempDirectory("report_");
+            Path docxPath = tempDir.resolve("report.docx");
+            try (FileOutputStream fos = new FileOutputStream(docxPath.toFile())) {
+                document.write(fos);
+            }
+
+            // 调试:保存到项目目录
+            saveDebugDocx(document);
 
-            return pdfOut.toByteArray();
+            // 4. LibreOffice 转 PDF
+            byte[] pdfBytes = convertToPdfWithLibreOffice(docxPath, tempDir);
+
+            // 5. 清理
+            cleanupTempDir(tempDir);
+
+            return pdfBytes;
+        } catch (ServiceException e) {
+            log.error("报告 PDF 生成失败: {}", e.getMessage(), e);
+            throw e;
         } catch (Exception e) {
-            throw new ServiceException("生成报告PDF失败: " + e.getMessage());
+            log.error("报告 PDF 生成异常", e);
+            throw new ServiceException("生成报告PDF失败: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()));
         }
     }
 
     /**
-     * 替换段落中的占位符。
-     * <p>
-     * 需要处理占位符可能被拆分到多个 run 中的情况,
-     * 例如 "{{" 在 run1,"name" 在 run2,"}}" 在 run3。
+     * 处理 checkbox 组。
+     * value 格式: "选项1,选项2,选项3|SELECTED:选中项"
+     * 例如: "属实,不属实,无法核实|SELECTED:属实"
+     *
+     * 会在 cell 中生成多行,每行一个 checkbox + 标签,
+     * 选中项使用 Wingdings ☑ (char 254),未选中使用 ☐ (char 168)。
+     */
+    private void applyCheckboxParagraph(XWPFTableCell cell, XWPFParagraph existingParagraph, String value) {
+        if (value == null || value.isEmpty()) {
+            // 清空占位符文本
+            clearParagraphText(existingParagraph);
+            return;
+        }
+
+        // 解析 value: "选项1,选项2,选项3|SELECTED:选中项"
+        String optionsPart = value;
+        String selected = "";
+        if (value.contains("|SELECTED:")) {
+            int idx = value.indexOf("|SELECTED:");
+            optionsPart = value.substring(0, idx);
+            selected = value.substring(idx + "|SELECTED:".length());
+        }
+        String[] options = optionsPart.split(",");
+
+        // 清空现有段落
+        clearParagraphText(existingParagraph);
+
+        // 在第一个选项写入现有段落
+        boolean isFirst = true;
+        for (String option : options) {
+            option = option.trim();
+            boolean isChecked = option.equals(selected.trim());
+
+            XWPFParagraph para;
+            if (isFirst) {
+                para = existingParagraph;
+                isFirst = false;
+            } else {
+                para = cell.addParagraph();
+                // 复制段落格式
+                if (existingParagraph.getCTP().getPPr() != null) {
+                    para.getCTP().setPPr(existingParagraph.getCTP().getPPr());
+                }
+            }
+
+            // 创建 Wingdings checkbox run
+            XWPFRun checkRun = para.createRun();
+            setWingdingsFont(checkRun);
+            checkRun.setText(String.valueOf(isChecked ? WINGDINGS_CHECKED : WINGDINGS_UNCHECKED));
+
+            // 创建标签 run(普通字体)
+            XWPFRun labelRun = para.createRun();
+            labelRun.setText(" " + option);
+            // 复制字号等格式
+            if (existingParagraph.getRuns() != null && !existingParagraph.getRuns().isEmpty()) {
+                XWPFRun origRun = existingParagraph.getRuns().get(0);
+                if (origRun.getFontSizeAsDouble() != null) {
+                    labelRun.setFontSize(origRun.getFontSizeAsDouble());
+                    checkRun.setFontSize(origRun.getFontSizeAsDouble());
+                }
+            }
+        }
+    }
+
+    /**
+     * 为 run 设置 Wingdings 字体。
+     */
+    private void setWingdingsFont(XWPFRun run) {
+        run.setFontFamily("Wingdings");
+    }
+
+    /**
+     * 清空段落中所有 run 的文本。
+     */
+    private void clearParagraphText(XWPFParagraph paragraph) {
+        List<XWPFRun> runs = paragraph.getRuns();
+        if (runs != null) {
+            for (XWPFRun run : runs) {
+                setRunTextSafely(run, "");
+            }
+        }
+    }
+
+    /**
+     * 替换段落中的普通 {{placeholder}} 占位符。
      */
     private void replacePlaceholdersInParagraph(XWPFParagraph paragraph, Map<String, String> placeholders) {
-        // 首先尝试快速路径:拼接所有 run 文本,检查是否包含 {{
         String fullText = paragraph.getText();
         if (fullText == null || !fullText.contains("{{")) {
             return;
         }
 
-        // 收集所有 run 的文本
         List<XWPFRun> runs = paragraph.getRuns();
         if (runs == null || runs.isEmpty()) {
             return;
         }
 
-        // 构建完整文本和每个字符对应的 run 索引映射
         StringBuilder sb = new StringBuilder();
-        int[] charToRunIndex = new int[fullText.length() + 100]; // extra buffer
-        int[] charToOffsetInRun = new int[fullText.length() + 100];
-        int globalPos = 0;
-
-        for (int ri = 0; ri < runs.size(); ri++) {
-            String runText = runs.get(ri).getText(0);
-            if (runText == null) {
-                continue;
-            }
-            for (int ci = 0; ci < runText.length(); ci++) {
-                if (globalPos < charToRunIndex.length) {
-                    charToRunIndex[globalPos] = ri;
-                    charToOffsetInRun[globalPos] = ci;
-                }
-                sb.append(runText.charAt(ci));
-                globalPos++;
+        for (XWPFRun run : runs) {
+            String runText = run.getText(0);
+            if (runText != null) {
+                sb.append(runText);
             }
         }
 
         String concatenated = sb.toString();
         Matcher matcher = PLACEHOLDER_PATTERN.matcher(concatenated);
-
         if (!matcher.find()) {
             return;
         }
 
-        // 有占位符需要替换 — 使用 run 合并策略
-        // 将所有文本合并到第一个 run,清空其余 run,然后执行替换
         String replaced = PLACEHOLDER_PATTERN.matcher(concatenated).replaceAll(mr -> {
             String key = mr.group(1).trim();
             String value = placeholders.getOrDefault(key, " ");
-            // 如果值为空字符串,填一个空格保证格式
             return value.isEmpty() ? " " : Matcher.quoteReplacement(value);
         });
 
-        // 检查替换后文本是否包含换行——如果有,需要分行处理
         if (replaced.contains("\n")) {
-            // 对于包含换行的情况,将第一行放在当前 run,其余作为新 run
             String[] lines = replaced.split("\n", -1);
-            // 设置第一个 run 的文本
-            if (!runs.isEmpty()) {
-                runs.get(0).setText(lines[0], 0);
-                // 清空其余 run
-                for (int i = 1; i < runs.size(); i++) {
-                    runs.get(i).setText("", 0);
-                }
-                // 添加剩余行作为额外文本行
-                for (int i = 1; i < lines.length; i++) {
-                    runs.get(0).addBreak();
-                    runs.get(0).setText(lines[i]);
-                }
+            setRunTextSafely(runs.get(0), lines[0]);
+            for (int i = 1; i < runs.size(); i++) {
+                setRunTextSafely(runs.get(i), "");
+            }
+            for (int i = 1; i < lines.length; i++) {
+                runs.get(0).addBreak();
+                runs.get(0).setText(lines[i]);
             }
         } else {
-            // 简单情况:将替换后的完整文本设置到第一个 run
-            if (!runs.isEmpty()) {
-                runs.get(0).setText(replaced, 0);
-                // 清空其余 run 的文本
-                for (int i = 1; i < runs.size(); i++) {
-                    runs.get(i).setText("", 0);
+            setRunTextSafely(runs.get(0), replaced);
+            for (int i = 1; i < runs.size(); i++) {
+                setRunTextSafely(runs.get(i), "");
+            }
+        }
+    }
+
+    private void setRunTextSafely(XWPFRun run, String text) {
+        if (run == null) {
+            return;
+        }
+        try {
+            if (run.getCTR() != null && run.getCTR().sizeOfTArray() > 0) {
+                run.setText(text, 0);
+            } else {
+                run.setText(text);
+            }
+        } catch (Exception e) {
+            log.warn("设置段落文本失败,回退为空 run: {}", e.getMessage(), e);
+            try {
+                run.setText(text);
+            } catch (Exception ignored) {
+                // 忽略二次失败,避免中断整体流程
+            }
+        }
+    }
+
+    // ==================== LibreOffice PDF 转换 ====================
+
+    private byte[] convertToPdfWithLibreOffice(Path docxPath, Path outputDir) {
+        String sofficePath = findSoffice();
+        try {
+            ProcessBuilder pb = new ProcessBuilder(
+                sofficePath, "--headless", "--norestore",
+                "--convert-to", "pdf",
+                "--outdir", outputDir.toAbsolutePath().toString(),
+                docxPath.toAbsolutePath().toString()
+            );
+            pb.redirectErrorStream(true);
+            log.info("执行 LibreOffice 转 PDF: {}", String.join(" ", pb.command()));
+
+            Process process = pb.start();
+            String output;
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
+                output = reader.lines().reduce("", (a, b) -> a + "\n" + b);
+            }
+
+            int exitCode = process.waitFor();
+            if (exitCode != 0) {
+                log.error("LibreOffice 转换失败, exitCode={}, output={}", exitCode, output);
+                throw new ServiceException("LibreOffice 转 PDF 失败 (exitCode=" + exitCode + ")");
+            }
+            log.info("LibreOffice 转换完成: {}", output.trim());
+
+            Path pdfPath = outputDir.resolve("report.pdf");
+            if (!Files.exists(pdfPath)) {
+                try (var stream = Files.list(outputDir)) {
+                    pdfPath = stream.filter(p -> p.toString().endsWith(".pdf"))
+                        .findFirst()
+                        .orElseThrow(() -> new ServiceException("LibreOffice 转换后未找到 PDF 文件"));
+                }
+            }
+            byte[] pdfBytes = Files.readAllBytes(pdfPath);
+            log.info("生成 PDF 大小: {} bytes", pdfBytes.length);
+            return pdfBytes;
+        } catch (ServiceException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new ServiceException("调用 LibreOffice 转 PDF 失败: " + e.getMessage());
+        }
+    }
+
+    private String findSoffice() {
+        try {
+            Process which = new ProcessBuilder("where", "soffice").start();
+            try (BufferedReader reader = new BufferedReader(new InputStreamReader(which.getInputStream()))) {
+                String path = reader.readLine();
+                if (path != null && !path.isBlank() && new File(path.trim()).exists()) {
+                    return path.trim();
                 }
             }
+        } catch (Exception ignored) {}
+
+        for (String path : SOFFICE_PATHS) {
+            if (new File(path).exists()) {
+                return path;
+            }
+        }
+        throw new ServiceException("未找到 LibreOffice,请安装后重试。");
+    }
+
+    private void cleanupTempDir(Path tempDir) {
+        try {
+            try (var stream = Files.list(tempDir)) {
+                stream.forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} });
+            }
+            Files.deleteIfExists(tempDir);
+        } catch (Exception e) {
+            log.warn("清理临时目录失败: {}", e.getMessage());
+        }
+    }
+
+    private void saveDebugDocx(XWPFDocument document) {
+        try {
+            File debugFile = new File(DEBUG_DOCX_PATH).getAbsoluteFile();
+            try (FileOutputStream fos = new FileOutputStream(debugFile)) {
+                document.write(fos);
+            }
+            log.info("填充后的 Word 已保存到: {}", debugFile.getAbsolutePath());
+        } catch (Exception e) {
+            log.warn("保存调试 Word 文件失败: {}", e.getMessage());
         }
     }
 }

二進制
ruoyi-modules/ruoyi-main/src/main/resources/templates/report_template.docx