一、项目目标
我们想实现的功能是:
-
将 PDF 文件的每一页转换成 PNG 图片。
-
返回 第一页图片 给前端(同步处理)。
-
其余页面在后台异步生成,不阻塞前端。
-
对异步失败的页面进行 最多 3 次重试。
-
生成的图片上传到服务器,并在数据库记录。
提升用户体验的核心点:同步处理第一页 → 异步处理剩余页 → 异常重试 → 数据库存储。
二、整体流程图
用户上传 PDF 文件
│
▼
同步生成第一页图片 → 返回前端
│
▼
异步处理剩余页(2~N)
│
├─ 渲染 PDF → 编码 PNG → 上传服务器 → 保存数据库
│
└─ 出现异常 → retryProcessPage() 重试最多3次
│
▼
用户查看合同详情,循环拉取图片展示在前端
│
├─ 图片少(<=20) → JOIN 查询
└─ 图片多 (>20) → 批量 IN 查询
│
▼
返回封装好的对象
三、关键类和方法
1. createPdfToImgAsyncOnlyFirst 方法
作用:负责处理 PDF 文件,将第一页同步生成,剩余页异步处理。
public String createPdfToImgAsyncOnlyFirst(String filePath, Integer contractId, ContractFile contractFile) throws Exception {
int pageCount;
try (PDDocument document = PDDocument.load(new File(filePath))) {
pageCount = document.getNumberOfPages();
}
// 👉 异步处理剩余页(从第 2 页开始)
for (int i = 1; i < pageCount; i++) {
int page = i;
CompletableFuture.runAsync(() -> {
try {
String url = pdfPageProcessor.processPageForHttps(filePath, page).get();
copyFileClient.present(url);
String[] split = url.split("/");
String objectName = split.length == 3 ? split[2] : null;
ContractFileImg img = new ContractFileImg();
img.setContractId(contractId);
img.setFileId(contractFile.getId());
img.setIdx(page + 1); // 页码从 1 开始
img.setUrl(url);
img.setObjectName(objectName);
contractFileImgMapper.insert(img);
} catch (Exception e) {
log.error("异步生成第 {} 页图片失败: {}", page + 1, e.getMessage(), e);
// 可调用重试方法或者加入消息队列
retryProcessPage(filePath, page, contractId, contractFile);
}
});
}
// 👉 同步处理第 1 页
String firstImageUrl = pdfPageProcessor.processPageForHttps(filePath, 0).get();
copyFileClient.present(firstImageUrl);
String[] split = firstImageUrl.split("/");
String objectName = split.length == 3 ? split[2] : null;
// ✅ 更新文件页数
contractFile.setPage(pageCount);
contractFileMapper.insert(contractFile);
ContractFileImg img = new ContractFileImg();
img.setContractId(contractId);
img.setFileId(contractFile.getId());
img.setIdx(1);
img.setUrl(firstImageUrl);
img.setObjectName(objectName);
img.setCreateTime(new Date());
contractFileImgMapper.insert(img);
return firstImageUrl;
}
2. processPageForHttps 方法
作用:将 PDF 的某一页渲染成图片、上传服务器并返回访问 URL。
private void retryProcessPage(String filePath, int page, Integer contractId, ContractFile contractFile) {
int retries = 3;
while (retries > 0) {
try {
String url = pdfPageProcessor.processPageForHttps(filePath, page).get();
copyFileClient.present(url);
// 可以插入数据库逻辑
break;
} catch (Exception e) {
retries--;
log.warn("重试第 {} 页失败,还剩 {} 次", page + 1, retries);
if (retries == 0) {
log.error("第 {} 页多次重试失败,任务终止", page + 1);
}
}
}
}
小结:
-
失败会重试最多 3 次。
-
避免偶尔网络/IO 问题导致整个任务失败。
-
日志详细记录,方便排查问题。
四、合同文件查询核心方法
根据合同 ID 自动选择 JOIN 或 批量 IN 查询:
public ContractVo findContractFile(@PathVariable Integer contractId) {
long imgCount = contractFileImgMapper.selectCount(
Wrappers.<ContractFileImg>lambdaQuery().eq(ContractFileImg::getContractId, contractId)
);
if (imgCount <= 20) {
return findContractFileJoin(contractId);
} else {
return findContractFileBatchIn(contractId);
}
}
// JOIN 查询模式
private ContractVo findContractFileJoin(Integer contractId) {
List<ContractFile> contractFiles = contractFileMapper.selectJoinList(
ContractFile.class,
MPJWrappers.<ContractFile>lambdaJoin()
.selectAll(ContractFile.class)
.selectAssociation(ContractFileImg.class, ContractFile::getContractFileImgs)
.leftJoin(ContractFileImg.class, ContractFileImg::getFileId, ContractFile::getId)
.eq(ContractFile::getContractId, contractId)
);
if (contractFiles.isEmpty()) return new ContractVo();
contractFiles.forEach(f -> f.setCertificatePdf(null));
ContractVo vo = new ContractVo();
vo.setContractFiles(contractFiles);
return vo;
}
// 批量 IN 查询模式
private ContractVo findContractFileBatchIn(Integer contractId) {
List<ContractFile> contractFiles = contractFileMapper.selectList(
Wrappers.<ContractFile>lambdaQuery()
.select(ContractFile::getId, ContractFile::getContractId, ContractFile::getFileUrl,
ContractFile::getFileName, ContractFile::getPage, ContractFile::getIdx,
ContractFile::getCreateTime, ContractFile::getFileSize, ContractFile::getOriginalName)
.eq(ContractFile::getContractId, contractId)
);
if (contractFiles.isEmpty()) return new ContractVo();
List<Integer> fileIds = contractFiles.stream().map(ContractFile::getId).collect(Collectors.toList());
List<ContractFileImg> contractFileImgs = contractFileImgMapper.selectList(
Wrappers.<ContractFileImg>lambdaQuery().in(ContractFileImg::getFileId, fileIds)
);
Map<Integer, List<ContractFileImg>> imgMap = contractFileImgs.stream()
.collect(Collectors.groupingBy(ContractFileImg::getFileId));
for (ContractFile contractFile : contractFiles) {
contractFile.setCertificatePdf(null);
contractFile.setContractFileImgs(imgMap.getOrDefault(contractFile.getId(), Collections.emptyList()));
}
ContractVo vo = new ContractVo();
vo.setContractFiles(contractFiles);
return vo;
}
五、重点知识点
-
异步处理
-
使用
CompletableFuture.runAsync()或@Async注解。 -
避免阻塞主线程,提高性能。
-
-
PDF 渲染
-
使用
PDFBox的PDFRenderer渲染每一页。 -
可以控制 DPI(每英寸点数),影响图片清晰度。
-
-
图片上传
-
将
BufferedImage编码成 PNG。 -
上传到服务器并返回 URL。
-
-
重试机制
-
异步失败后调用
retryProcessPage。 -
最多尝试 3 次,失败记录日志。
-
-
数据库存储
-
每页图片单独记录表
ContractFileImg。 -
文件总页数记录在
ContractFile表。
-
六、总结
-
第一页同步返回 → 用户立刻可见。
-
剩余页异步生成 → 后台处理,提高效率。
-
异常重试 → 确保 PDF 每页都能成功生成。
-
日志记录 → 可追踪每页处理情况。
-
数据库记录 → 前端和后台可查询每页图片。
从用户的角度来看,这套 PDF 转图片的流程设计有几个明显优势:
-
快速看到第一页
-
用户上传 PDF 后,页面几乎立即显示第一页图片。
-
用户无需等待整个 PDF 完全生成,体验流畅。
-
-
后台静默处理剩余页
-
剩余页在后台异步生成,用户可以继续操作其他功能。
-
用户不会感到卡顿或页面长时间“加载中”。
-
-
异常自动重试
-
如果后台生成某页失败,系统会自动重试最多 3 次。
-
用户几乎感受不到异常,保证大部分情况下每页都能成功生成。
-
-
图片 URL 持久可用
-
所有生成的图片上传到服务器并存储数据库。
-
用户刷新页面或再次访问时可以快速查看,不必重复生成。
-
-
日志和异常可追踪
-
开发者后台有详细日志,即便出现问题,也能快速排查。
-
用户体验稳定,不会因为偶尔的网络或 IO 错误而失败。
-
总结一句话:
用户上传 PDF 后,第一页即刻可见,剩余页面在后台悄悄生成,体验流畅且稳定,即使遇到小问题也不影响使用。 前端根据接口返回的图片数量和文件总页数比对,未完全加载则增加一个占位提示框,然后循环拉取接口知道数据全部加载完毕渲染到前端页面。(未采用单个文件占位拉取数据主要原因是太多文件会造成多次调用接口增加服务器压力)
5571



被折叠的 条评论
为什么被折叠?



