Browse Source

Merge remote-tracking branch 'origin/dev' into dev

dev
myf 1 week ago
parent
commit
404915438f
  1. 8
      common/common-association/pom.xml
  2. 83
      common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java
  3. 76
      common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java
  4. 22
      common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java
  5. 568
      common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java

8
common/common-association/pom.xml

@ -74,7 +74,13 @@ @@ -74,7 +74,13 @@
<artifactId>poi-tl</artifactId>
<version>1.12.2</version>
</dependency>
</dependencies>
<!-- ============ 新增:Spire.Doc Free 依赖 ============ -->
<dependency>
<groupId>e-iceblue</groupId>
<artifactId>spire.doc.free</artifactId>
<version>14.3.1</version>
</dependency>
</dependencies>
</project>

83
common/common-association/src/main/java/apelet/association/controller/QuotationPdfController.java

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
package apelet.association.controller;
import apelet.association.service.QuotationPdfService;
import apelet.common.core.exception.MyRuntimeException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 报价单 WordPDF 下载接口
*
* <p>触发链路报价单<b>列表页</b>"打印PDF"按钮 QuotationPdfPlugin 取当前行 id
* 拼绝对下载地址 前端收到 DOWNLOAD_URL 事件跳转下载 本接口</p>
*
* <p>本类只做 HTTP QuotationPdfService PDF 字节设置响应头后写出
* 业务逻辑取附件模板POI 渲染LibreOffice PDF全部在 QuotationPdfService</p>
*
* <p>请求参数</p>
* <ul>
* <li>id 报价单 id必填</li>
* </ul>
*
* @author hz
* @date 2026-08-19
*/
@RestController
@RequestMapping("/api/quotationPdf")
public class QuotationPdfController {
@Autowired
private QuotationPdfService quotationPdfService;
/**
* 下载报价单 PDF
*
* <p>返回 application/pdf 附件流业务或转换失败时抛 MyRuntimeException
* 由全局异常处理器转成友好错误 JSON 500</p>
*/
@GetMapping("/downloadPdf")
public void downloadPdf(@RequestParam("id") String quotationId,
HttpServletResponse response) {
// 1. 生成 PDF 字节(查数据 → 取附件模板 → 渲染 → LibreOffice 转 PDF)
byte[] pdfBytes;
try {
pdfBytes = quotationPdfService.downloadPdf(quotationId);
} catch (MyRuntimeException e) {
// 业务异常(无附件/报价单不存在等):原样抛出,交给全局异常处理
throw e;
} catch (Exception e) {
// 其余异常:包一层,避免把内部堆栈直接抛给前端
throw new MyRuntimeException("导出失败: " + e.getMessage());
}
// 2. 设置响应头,输出 PDF 附件
try {
response.setContentType("application/pdf");
response.setCharacterEncoding("UTF-8");
// 中文文件名:URLEncoder 编码后走 RFC 5987 的 filename*=UTF-8'',兼容现代浏览器;
// 普通 filename 用 %20 代替空格,防止部分浏览器解析异常
String fileName = URLEncoder.encode("报价单.pdf", StandardCharsets.UTF_8.name())
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition",
"attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + fileName);
// 禁止浏览器缓存:每次打开都是最新 PDF,避免下载到旧的
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
OutputStream out = response.getOutputStream();
out.write(pdfBytes);
out.flush();
} catch (IOException e) {
throw new MyRuntimeException("输出 PDF 失败: " + e.getMessage());
}
}
}

76
common/common-association/src/main/java/apelet/association/plugin/quotation/OpenPopupPlugin.java

@ -4,10 +4,15 @@ package apelet.association.plugin.quotation; @@ -4,10 +4,15 @@ package apelet.association.plugin.quotation;
import apelet.common.core.object.ObjectValue;
import apelet.common.online.abstractplugin.ListPlugin;
import apelet.common.online.model.ShowParameter;
import apelet.common.online.model.constant.AttributeEnum;
import apelet.common.online.model.constant.ShowTypeEnum;
import apelet.common.online.model.constant.ViewStatus;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/*
@ -49,5 +54,76 @@ public class OpenPopupPlugin extends ListPlugin { @@ -49,5 +54,76 @@ public class OpenPopupPlugin extends ListPlugin {
showParameter.setCustomParam(map);
super.showForm(showParameter);
}
if ("打印".equals(widgetVariableName)) {
// 1. 取当前行:优先从框架 rowData 穿透取(ReportListPlugin 同款);
// 拿不到再回退按钮事件传入的对象
Map<String, Object> row = getCurrentRow();
if ((row == null || row.get("id") == null) && objectValue != null) {
row = objectValue.getValues();
}
if (row == null || row.get("id") == null) {
this.showWarningMessage("请选择一条数据");
this.cancelOperate();
return;
}
// 2. 拼下载地址(优先绝对地址 scheme://host:port/contextPath;拿不到请求时退回相对路径)
// 签名图由报价单 managerperson 决定,不依赖当前登录用户
String url = buildUrl(String.valueOf(row.get("id")));
// 3. 事件:{url, type:"download"} → 前端下载该 URL
Map<String, String> map = new HashMap<>();
map.put("url", url);
map.put("type", "download");
super.setWidgetAttribute("", AttributeEnum.DOWNLOAD_URL, map);
this.cancelOperate();
}
}
private String buildUrl(String quotationId) {
String base = buildBaseUrl();
return base + "/api/quotationPdf/downloadPdf?id=" + quotationId;
}
/**
* 取当前行数据rowData 结构为 {@code {tableWidgetId: [行Map]}}
* 穿透外层 Map 找到第一个"值为 List<Map>"的项取其第一行
*/
private Map<String, Object> getCurrentRow() {
try {
Map<String, Object> rowData = getDto().getModel().getRowData();
if (rowData == null) {
return null;
}
for (Object val : rowData.values()) {
if (val instanceof List) {
List<?> list = (List<?>) val;
if (!list.isEmpty() && list.get(0) instanceof Map) {
return (Map<String, Object>) list.get(0);
}
}
}
} catch (Exception ignored) {
// 取行失败交给外层统一提示"请选择一条数据"
}
return null;
}
/** 从当前请求拼协议+主机+端口+contextPath;异常时返回空串(退回相对地址) */
private String buildBaseUrl() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null || attrs.getRequest() == null) {
return "";
}
HttpServletRequest request = attrs.getRequest();
String ctx = request.getContextPath();
return request.getScheme() + "://" + request.getServerName() + ":"
+ request.getServerPort() + (ctx == null ? "" : ctx);
} catch (Exception e) {
return "";
}
}
}

22
common/common-association/src/main/java/apelet/association/service/QuotationPdfService.java

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
package apelet.association.service;
/**
* 报价单 WordPDF 服务
*
* @author hz
* @date 2026-08-19
*/
public interface QuotationPdfService {
/**
* 取报价单附件作为模板渲染真实数据 PDF
*
* <p>签名图取报价单 managerperson纯用户 id对应的 xy_sys_user.signature_image
* 与当前登录用户无关</p>
*
* @param quotationId 报价单 id
* @return PDF 字节
* @throws Exception 渲染/转换失败
*/
byte[] downloadPdf(String quotationId) throws Exception;
}

568
common/common-association/src/main/java/apelet/association/service/impl/QuotationPdfServiceImpl.java

@ -0,0 +1,568 @@ @@ -0,0 +1,568 @@
package apelet.association.service.impl;
import apelet.association.service.QuotationPdfService;
import apelet.common.core.exception.MyRuntimeException;
import apelet.common.core.object.ObjectValue;
import apelet.common.generator.utils.OrmGenDataSourceUtil;
import apelet.common.online.service.OnlineDictService;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.spire.doc.Document;
import com.spire.doc.FileFormat;
/**
* 报价单 WordPDF 服务实现
*
* <p>整体流程</p>
* <ol>
* <li> id 查报价单主表</li>
* <li>取报价单附件file_text 字段平台附件 JSON作为 Word 模板</li>
* <li>下载模板 docx 字节</li>
* <li> POI XWPF 替换模板里的 {@code #{xxx}} 文本占位符 + 插入签名图</li>
* <li> LibreOffice 本地命令行soffice --headless --convert-to pdf转成 PDF</li>
* <li>返回 PDF 字节</li>
* </ol>
*
* <p>模板占位符约定与报价单模板文件保持一致</p>
* <pre>
* #{name} 报价单名称
* #{number} 报价单编号
* #{type} 项目类型字典翻译后文本
* #{create_time} 创建时间取日期部分 yyyy-MM-dd
* #{qty} 总报价
* #{managerperson} 报价经理姓名xy_sys_user.show_name
* #{personimage} 签名图报价单 managerperson 对应用户的 signature_image图片占位符
* </pre>
*
* <p>模板的获取方式 {@code quotation_template.docx} 上传成某条报价单的附件file_text 字段
* 附件若为 .pdf 则直接透传返回不渲染只有 .docx/.doc 才走渲染+转换</p>
*
* <p>占位符定位方式 zyu QuotePrintServicedocx 是嵌套结构文档段落/表格单元格段落
* 先用 {@link #getAllParagraphs} "正文段落 + 所有表格内段落"拍平成一张 List
* 后续替换只需一层循环遍历段落run 的处理在各方法内部完成</p>
*
* @author hz
* @date 2026-08-19
*/
@Service
public class QuotationPdfServiceImpl implements QuotationPdfService {
@Autowired
private OrmGenDataSourceUtil ormGenDataSourceUtil;
@Autowired
private OnlineDictService onlineDictService;
/** 模板文本占位符正则:匹配 #{xxx} */
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("#\\{(.+?)\\}");
@Override
public byte[] downloadPdf(String quotationId) throws Exception {
// 1. 查报价单主数据(queryOne 内部会自动带出 F7/分录等对象字段)
ObjectValue quotation = ormGenDataSourceUtil.queryOne("quotation", quotationId);
if (quotation == null) {
throw new MyRuntimeException("报价单不存在: " + quotationId);
}
// 2. 模板 = 报价单附件(file_text 字段存的是平台附件 JSON,如
// [{"baseUrl":"https://oss...","uploadPath":"xxx","filename":"quotation_template.docx",...}]
// 附件上传组件保存的就是这个结构)
Attachment attachment = parseAttachment(quotation.getString("file_text"));
if (attachment == null || !hasText(attachment.url)) {
throw new MyRuntimeException("该报价单未上传模板附件,请先在报价单附件里上传 docx 模板");
}
// 3. 下载模板文件字节(可能来自 OSS,走 HTTP GET)
byte[] fileBytes = downloadFile(attachment.url);
if (fileBytes == null || fileBytes.length == 0) {
throw new MyRuntimeException("模板附件下载失败或文件为空");
}
// 4. 附件本身就是 PDF:没必要渲染,直接透传;
// 否则用模板渲染出 docx,再交给 LibreOffice 转 PDF
if (isPdf(attachment.filename)) {
return fileBytes;
}
byte[] docxBytes = renderTemplate(fileBytes, quotation);
return docxToPdf(docxBytes);
}
// ==================== 模板渲染 ====================
/**
* 用报价单真实数据替换模板占位符输出填充后的 docx 字节
*
* <p>替换顺序有讲究 zyu<b>先插签名图再替换文本</b>
* 图片替换需要定位到 {@code #{personimage}} 的原始文本一旦文本先被替换
* 占位符就没了而先插图时会把 personimage 占位符清掉文本替换阶段自然不会再碰它
* 所以文本替换逻辑里不需要再对 personimage 做特殊跳过</p>
*
* @param templateBytes 模板 docx 字节
* @param quotation 报价单数据签名图取 managerperson 对应用户的 signature_image
*/
private byte[] renderTemplate(byte[] templateBytes, ObjectValue quotation) throws Exception {
// 组装文本占位符的数据
Map<String, Object> dataMap = new HashMap<>();
dataMap.put("name", quotation.getString("name"));
dataMap.put("number", quotation.getString("number"));
// create_time 是完整时间戳,模板里一般只要日期部分,取前 10 位(yyyy-MM-dd)
String createTime = quotation.getString("create_time");
dataMap.put("create_time", hasText(createTime) && createTime.length() >= 10 ? createTime.substring(0, 10) : createTime);
dataMap.put("qty", quotation.getString("qty"));
// 项目类型:类型字段存的是字典 id,翻译成字典文本;
// 若为多选(逗号分隔,如 "1,2")整串翻译不到,则保留原值,避免空白
Map<String, String> dictMap = onlineDictService.getDictMap(2052225870772310016L); // 项目类型字典
Object typeObj = quotation.get("type", "");
String typeRaw = typeObj == null ? "" : String.valueOf(typeObj);
String typeVal = hasText(typeRaw) ? dictMap.get(typeRaw) : null;
dataMap.put("type", hasText(typeVal) ? typeVal : typeRaw);
// 报价经理:优先取 F7 对象(表单里配了关联字段),查对应表拿 show_name;
// 兼容库里存纯数字 id 的情况(直接当 xy_sys_user 主键查)
String managerName = resolveManagerName(quotation);
dataMap.put("managerperson", managerName == null ? "" : managerName);
// 签名图:取报价单 managerperson(纯用户 id)对应的 xy_sys_user.signature_image,
// 而不是当前登录用户;解析不到或下载失败时保持 null,图片占位符被清空、不插图,PDF 照常生成
byte[] imageBytes = null;
ObjectValue managerUser = resolveManagerUser(quotation);
if (managerUser != null) {
String imgUrl = getUrl(managerUser.getString("signature_image"));
if (hasText(imgUrl)) {
imageBytes = downloadFile(imgUrl);
}
}
try (InputStream tplIs = new ByteArrayInputStream(templateBytes);
XWPFDocument document = new XWPFDocument(tplIs)) {
// 1) 先插签名图(此时占位符还在原文里)
// 宽度/高度单位是 px,POI addPicture 需要 EMU,1px = 9525 EMU
replaceImageInAllParagraphs(document, "personimage", imageBytes, 100, 100);
// 2) 再替换文本占位符(personimage 已被清掉,不会被误替换)
replaceTextInAllParagraphs(document, dataMap);
ByteArrayOutputStream out = new ByteArrayOutputStream();
document.write(out);
return out.toByteArray();
}
}
/**
* 报价经理姓名xy_sys_user.show_name解析不到返回 null模板里该占位符留空
*/
private String resolveManagerName(ObjectValue quotation) {
ObjectValue user = resolveManagerUser(quotation);
return user == null ? null : user.getString("show_name");
}
/**
* 报价经理对应的用户记录姓名签名图共用
* <ol>
* <li> managerperson F7 对象{tableName, id}按关联表名+id </li>
* <li>否则若存的是纯数字 id直接当 xy_sys_user 主键查当前数据即为此形态</li>
* <li>都解析不到返回 null签名图/姓名留空不阻断整单 PDF</li>
* </ol>
*/
private ObjectValue resolveManagerUser(ObjectValue quotation) {
try {
ObjectValue managerperson = quotation.getObjectValue("managerperson");
if (managerperson != null) {
String table = managerperson.getTableName();
String id = managerperson.getString("id");
if (hasText(table) && hasText(id)) {
return ormGenDataSourceUtil.queryOne(table, id);
}
return null;
}
Object raw = quotation.get("managerperson");
if (raw != null && String.valueOf(raw).trim().matches("\\d+")) {
return ormGenDataSourceUtil.queryOne("xy_sys_user", String.valueOf(raw).trim());
}
return null;
} catch (Exception e) {
// 关联查询失败不阻断整单 PDF,返回 null 让签名图/姓名留白
return null;
}
}
// ==================== 段落收集与占位符替换 ====================
/**
* 收集文档里<b>所有</b>段落正文段落 + 所有表格单元格内的段落
*
* <p>docx 是嵌套容器文档段落/表格单元格段落占位符可能在正文
* 也可能在表格里报价单明细常在表格中把两层嵌套拆箱成一张扁平 List
* 调用方只需一层循环即可遍历所有段落run 的处理在各方法内部完成</p>
*/
private List<XWPFParagraph> getAllParagraphs(XWPFDocument document) {
List<XWPFParagraph> list = new ArrayList<>();
for (IBodyElement el : document.getBodyElements()) {
if (el instanceof XWPFParagraph) {
// 正文段落,直接收集
list.add((XWPFParagraph) el);
} else if (el instanceof XWPFTable) {
// 表格:逐行逐单元格取出里面的段落
for (XWPFTableRow row : ((XWPFTable) el).getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
list.addAll(cell.getParagraphs());
}
}
}
}
return list;
}
/** 对所有段落做文本占位符替换(正文 + 表格一次搞定) */
private void replaceTextInAllParagraphs(XWPFDocument document, Map<String, Object> dataMap) {
for (XWPFParagraph paragraph : getAllParagraphs(document)) {
replaceInParagraph(paragraph, dataMap);
}
}
/**
* 单个段落的文本占位符替换
*
* <p>把段内所有 run 的文本<b>拼接</b>后再正则匹配再按偏移整体回写
* 这样能处理占位符被 word 拆成多个 run 的情况 {@code #{create_time}}
* 被拆成 {@code #{ / create_time / }} 三截</p>
*
* <p>回写方式第一个 run 保留其格式写入整段新文本其余 run 清空</p>
*/
private void replaceInParagraph(XWPFParagraph paragraph, Map<String, Object> dataMap) {
List<XWPFRun> runs = paragraph.getRuns();
if (runs.isEmpty()) {
return;
}
// 拼接所有 run 文本
StringBuilder sb = new StringBuilder();
for (XWPFRun run : runs) {
String t = run.text();
sb.append(t == null ? "" : t);
}
String full = sb.toString();
if (full.indexOf("#{") < 0) {
return;
}
// 逐个替换 #{xxx},未提供键的替换为空
Matcher m = PLACEHOLDER_PATTERN.matcher(full);
if (!m.find()) {
return;
}
m.reset();
StringBuffer replaced = new StringBuffer();
while (m.find()) {
Object val = dataMap.get(m.group(1).trim());
// quoteReplacement 防止替换值里的 $ / \ 被 appendReplacement 转义
m.appendReplacement(replaced, val == null ? "" : Matcher.quoteReplacement(val.toString()));
}
m.appendTail(replaced);
// 整体回写:第一个 run 保留其格式,其余 run 清空
runs.get(0).setText(replaced.toString(), 0);
for (int i = 1; i < runs.size(); i++) {
runs.get(i).setText("", 0);
}
}
/**
* 对所有段落做图片占位符替换正文 + 表格一次搞定
*
* <p>imageBytes null用户没签名图/下载失败时只清掉占位符文本不插图
* 保证 PDF 照常生成不会残留 {@code #{personimage}} 字面量</p>
*/
private void replaceImageInAllParagraphs(XWPFDocument document, String placeholderKey, byte[] imageBytes, int width, int height) throws IOException, InvalidFormatException {
String placeholder = "#{" + placeholderKey + "}";
for (XWPFParagraph paragraph : getAllParagraphs(document)) {
String text = paragraph.getText();
if (text == null || !text.contains(placeholder)) {
continue;
}
for (XWPFRun run : paragraph.getRuns()) {
String runText = run.text();
if (runText == null || !runText.contains(placeholder)) {
continue;
}
// 只把占位符清掉,保留同 run 里的其他文字(如"负责人签字:")
run.setText(runText.replace(placeholder, ""), 0);
if (imageBytes != null) {
run.addPicture(new ByteArrayInputStream(imageBytes), XWPFDocument.PICTURE_TYPE_PNG,
placeholderKey + ".png", width * 9525, height * 9525);
}
}
}
}
// ==================== LibreOffice 本地转 PDF ====================
// /**
// * 用 LibreOffice 把 docx 字节转换为 PDF 字节。
// *
// * <p>核心命令:{@code soffice --headless --convert-to pdf --outdir <临时目录> <docx>}。</p>
// * <ul>
// * <li>--headless:无界面模式,适合服务器调用;</li>
// * <li>-env:UserInstallation 指定独立配置目录:LibreOffice 同时只允许一个实例持有用户配置,
// * 不指定的话并发/多次调用会互相抢锁卡住;</li>
// * <li>转换可能较慢(首次启动要初始化),超时 120s 后强制杀掉进程;</li>
// * <li>转换完成后清掉临时目录,避免磁盘堆积。</li>
// * </ul>
// *
// * <p>需要运行环境安装 LibreOffice(本机:C:/Program Files/LibreOffice/program/soffice.exe)。</p>
// */
// private byte[] docxToPdf(byte[] docxBytes) throws Exception {
// // 独立临时目录 + 独立 profile,避免与其他 soffice 实例文件锁冲突
// File tmpDir = new File(System.getProperty("java.io.tmpdir"), "quote_pdf_" + System.currentTimeMillis());
// if (!tmpDir.mkdirs()) {
// throw new IOException("创建临时目录失败: " + tmpDir);
// }
// try {
// File docxFile = new File(tmpDir, "quote.docx");
// Files.write(docxFile.toPath(), docxBytes);
//
// ProcessBuilder pb = new ProcessBuilder(
// findSoffice(),
// "--headless",
// "--convert-to", "pdf",
// "--outdir", tmpDir.getAbsolutePath(),
// "-env:UserInstallation=file:///" + tmpDir.getAbsolutePath().replace("\\", "/") + "/lo_profile",
// docxFile.getAbsolutePath());
// // 把子进程输出重定向合并到标准输出,下面统一读走,避免管道缓冲区满导致子进程阻塞
// pb.redirectErrorStream(true);
// Process p = pb.start();
// try (InputStream is = p.getInputStream()) {
// byte[] buf = new byte[8192];
// while (is.read(buf) != -1) {
// // 消耗输出,避免管道阻塞
// }
// }
// if (!p.waitFor(120, TimeUnit.SECONDS)) {
// p.destroyForcibly();
// throw new IOException("LibreOffice 转换超时");
// }
//
// // 转换输出文件名为输入名去扩展名 + .pdf,即 quote.pdf
// File pdfFile = new File(tmpDir, "quote.pdf");
// if (!pdfFile.exists()) {
// throw new IOException("LibreOffice 转换后未找到 PDF,请确认已安装 LibreOffice");
// }
// return Files.readAllBytes(pdfFile.toPath());
// } finally {
// deleteDir(tmpDir);
// }
// }
private byte[] docxToPdf(byte[] docxBytes) throws Exception {
// 用 Spire.Doc 直接转换,无需临时文件和命令行
try (ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream()) {
// 1. 从字节数组加载 docx(正确用法)
Document document = new Document();
document.loadFromStream(new ByteArrayInputStream(docxBytes), FileFormat.Docx);
// 2. 保存为 PDF 到内存流
document.saveToStream(pdfOutputStream, FileFormat.PDF);
// 3. 返回 PDF 字节
return pdfOutputStream.toByteArray();
} catch (Exception e) {
throw new IOException("Spire.Doc 转换 PDF 失败: " + e.getMessage(), e);
}
}
/**
* 定位 soffice 可执行文件依次探测
* 1) JVM 属性 soffice.path启动时 -Dsoffice.path=xxx 指定
* 2) 环境变量 SOFFICE_HOME
* 3) Windows 常见安装路径
* 4) 最后回退 PATH 里的 soffice
*/
private String findSoffice() {
String sofficePath = System.getProperty("soffice.path");
if (hasText(sofficePath) && new File(sofficePath).exists()) {
return sofficePath;
}
String sofficeHome = System.getenv("SOFFICE_HOME");
if (hasText(sofficeHome)) {
File f = new File(sofficeHome, "program/soffice.exe");
if (f.exists()) {
return f.getAbsolutePath();
}
}
String[] candidates = {
"C:/Program Files/LibreOffice/program/soffice.exe",
"C:/Program Files (x86)/LibreOffice/program/soffice.exe",
};
for (String c : candidates) {
if (new File(c).exists()) {
return c;
}
}
return "soffice";
}
/** 递归删除临时目录(含子目录/文件),转换结束清理磁盘 */
private void deleteDir(File dir) {
File[] files = dir.listFiles();
if (files != null) {
for (File f : files) {
if (f.isDirectory()) {
deleteDir(f);
} else {
f.delete();
}
}
}
dir.delete();
}
// ==================== 文件解析与下载 ====================
/**
* 解析平台附件 JSON返回下载地址 + 文件名
*
* <p>兼容两种格式</p>
* <ul>
* <li>数组{@code [{"baseUrl":"https://oss...","uploadPath":"xxx/","filename":"a.docx"}]}</li>
* <li>对象{@code {"baseUrl":"...","uploadPath":"...","filename":"a.docx"}}</li>
* </ul>
*
* <p>上传路径以 ./ 开头的是本地相对路径 ./zz-resource/upload-files/...
* 没有 OSS 地址拼不出来返回 null视为"没有可用模板"</p>
*/
private Attachment parseAttachment(String fileJson) {
if (!hasText(fileJson)) {
return null;
}
try {
Object obj = JSON.parse(fileJson);
JSONObject jo = null;
if (obj instanceof JSONArray) {
JSONArray arr = (JSONArray) obj;
if (arr.isEmpty()) {
return null;
}
jo = arr.getJSONObject(0);
} else if (obj instanceof JSONObject) {
jo = (JSONObject) obj;
} else {
return null;
}
String baseUrl = jo.getString("baseUrl");
String uploadPath = jo.getString("uploadPath");
String filename = jo.getString("filename");
if (!hasText(baseUrl) || !hasText(uploadPath) || !hasText(filename)) {
return null;
}
if (uploadPath.trim().startsWith("./")) {
return null;
}
Attachment attachment = new Attachment();
// 平台约定:OSS 完整访问地址 = baseUrl + "/" + uploadPath + "/" + filename
attachment.url = baseUrl + "/" + uploadPath + "/" + filename;
attachment.filename = filename;
return attachment;
} catch (Exception e) {
return null;
}
}
/**
* 签名图路径解析逻辑同 parseAttachment
* 解析不到返回空串签名图是可选项缺了不影响出 PDF
*/
private String getUrl(String signatureImage) {
if (!hasText(signatureImage)) {
return "";
}
try {
Object obj = JSON.parse(signatureImage);
JSONObject jo = null;
if (obj instanceof JSONArray) {
JSONArray arr = (JSONArray) obj;
if (arr.isEmpty()) {
return "";
}
jo = arr.getJSONObject(0);
} else if (obj instanceof JSONObject) {
jo = (JSONObject) obj;
} else {
return "";
}
String baseUrl = jo.getString("baseUrl");
String uploadPath = jo.getString("uploadPath");
String filename = jo.getString("filename");
if (!hasText(baseUrl) || !hasText(uploadPath) || !hasText(filename)) {
return "";
}
if (uploadPath.trim().startsWith("./")) {
return "";
}
return baseUrl + "/" + uploadPath + "/" + filename;
} catch (Exception e) {
return "";
}
}
/** 下载远程文件字节(OSS 模板、签名图共用):GET + 连接/读取超时 + HTTP 200 校验 */
private byte[] downloadFile(String fileUrl) throws IOException {
URL url = new URL(fileUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(5000);
conn.setReadTimeout(15000);
try {
int code = conn.getResponseCode();
if (code != HttpURLConnection.HTTP_OK) {
throw new IOException("下载失败, HTTP " + code);
}
try (InputStream is = conn.getInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int len;
while ((len = is.read(buf)) != -1) {
baos.write(buf, 0, len);
}
return baos.toByteArray();
}
} finally {
conn.disconnect();
}
}
/** 附件后缀判断:.pdf 直接透传,不渲染不转换 */
private boolean isPdf(String filename) {
return hasText(filename) && filename.toLowerCase().endsWith(".pdf");
}
private boolean hasText(String s) {
return s != null && !s.trim().isEmpty();
}
/** 附件解析结果:下载地址 + 原文件名 */
private static class Attachment {
String url;
String filename;
}
}
Loading…
Cancel
Save