批量收集多源 URL 并异步转 PDF 打包下载的完整实现(Spring Boot + Feign + 异步任务)
场景描述
一个在线教育平台,学生可以批量下载课程资料。资料来自三个不同渠道:
- 自建课程
:平台自己的课件系统,有预览页面 URL,需要转成 PDF - 合作机构
:调用合作方接口获取资料 URL,需要转成 PDF - 历史归档
:已经是 PDF 文件,存在 OSS 上,可直接下载
学生勾选多个资料后点击"批量下载",后端需要收集所有 URL,提交给文件服务异步转 PDF 并打包成 ZIP,返回任务 ID 供前端轮询。
一、数据结构定义
/*** 批量下载请求入参*/@Datapublic class BatchDownloadForm {@ApiModelProperty("自建课程资料ID列表")private List<String> selfCourseIds;@ApiModelProperty("合作机构资料ID列表")private List<String> partnerCourseIds;@ApiModelProperty("历史归档资料ID列表")private List<String> archiveIds;@ApiModelProperty("学生ID")@NotBlank(message = "学生ID不能为空")private String studentId;}/*** 文件 URL 封装(提交给文件服务的统一结构)*/@Datapublic class FileUrl {@ApiModelProperty("文件地址(预览页面URL或直接下载地址)")@NotBlank(message = "url不能为空")private String url;@ApiModelProperty("文件名(打包后ZIP内的文件名)")@NotBlank(message = "文件名不能为空")private String fileName;@ApiModelProperty("是否可直接下载(true则跳过转PDF,直接打包原文件)")private boolean directDownload = false;}/*** 异步任务提交参数*/@Datapublic class AsyncPdfTaskForm {@ApiModelProperty("系统来源")@NotBlank(message = "系统来源不能为空")private String source;@ApiModelProperty("业务类型(自定义标识,用于区分不同业务的下载任务)")private String businessType;@ApiModelProperty("业务编码(如学生ID,用于关联查询任务状态)")@NotBlank(message = "业务编码不能为空")private String businessCode;@ApiModelProperty("URL列表")@Size(min = 1, message = "URL列表不能为空")private List<FileUrl> urls;}
二、数据库表(自建课程资料表)
CREATE TABLE `t_course_material` (`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,`course_id` varchar(50) NOT NULL COMMENT '课程ID',`title` varchar(200) NOT NULL COMMENT '资料标题',`preview_url` varchar(500) DEFAULT NULL COMMENT '预览页面URL',`source_type` varchar(20) NOT NULL COMMENT '来源类型:SELF自建/PARTNER合作/ARCHIVE归档',`pdf_url` varchar(500) DEFAULT NULL COMMENT '已归档的PDF地址(仅ARCHIVE类型有值)',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程资料表';
三、Feign 接口定义
/*** 合作机构资料接口* 调用合作方系统获取资料预览URL*/@FeignClient(name = "partnerFeign", url = "{feign.file-service.url}")public interface FileServiceFeign {/*** 提交异步转PDF打包任务* @param form 任务参数(包含URL列表)* @return JSON字符串,包含任务ID*/@PostMapping("/v2/export/asyncUrlToPdf")String asyncUrlToPdf(@RequestBody AsyncPdfTaskForm form);}
四、Service 完整实现
@Slf4j@Servicepublic class MaterialDownloadServiceImpl {private final CourseMaterialMapper courseMaterialMapper;private final PartnerFeign partnerFeign;private final FileServiceFeign fileServiceFeign;@Value("{course.preview.base-url}")private String selfPreviewBaseUrl;@Value("${spring.application.name}")private String serviceName;public MaterialDownloadServiceImpl(CourseMaterialMapper courseMaterialMapper,PartnerFeign partnerFeign,FileServiceFeign fileServiceFeign) {this.courseMaterialMapper = courseMaterialMapper;this.partnerFeign = partnerFeign;this.fileServiceFeign = fileServiceFeign;}/*** 批量下载课程资料** 整体流程:* 1. 分别处理三种来源的资料,收集所有文件URL* 2. 提交给文件服务异步转PDF并打包* 3. 返回任务ID,前端轮询下载** @param form 下载请求参数* @return 异步任务ID*/public String batchDownload(BatchDownloadForm form) {// 统一收集所有文件URL的容器List<FileUrl> allFileUrls = new ArrayList<>();// ===== 1. 处理自建课程资料(本地生成签名URL,需要转PDF) =====if (CollUtil.isNotEmpty(form.getSelfCourseIds())) {List<FileUrl> selfUrls = buildSelfCourseUrls(form.getSelfCourseIds());allFileUrls.addAll(selfUrls);}// ===== 2. 处理合作机构资料(调用远程接口获取URL,需要转PDF) =====if (CollUtil.isNotEmpty(form.getPartnerCourseIds())) {List<FileUrl> partnerUrls = buildPartnerCourseUrls(form.getPartnerCourseIds());allFileUrls.addAll(partnerUrls);}// ===== 3. 处理历史归档资料(已有PDF,直接下载) =====if (CollUtil.isNotEmpty(form.getArchiveIds())) {List<FileUrl> archiveUrls = buildArchiveUrls(form.getArchiveIds());allFileUrls.addAll(archiveUrls);}// ===== 4. 校验并提交异步任务 =====if (CollUtil.isEmpty(allFileUrls)) {throw new BusinessException("没有可下载的资料");}return submitAsyncTask(form.getStudentId(), allFileUrls);}/*** 处理自建课程资料* 本地拼接预览页面URL + MD5签名(防止URL被篡改)* directDownload = false,文件服务会打开这个URL渲染页面后转为PDF*/private List<FileUrl> buildSelfCourseUrls(List<String> courseIds) {// 批量查询资料信息List<CourseMaterialEntity> materials = courseMaterialMapper.selectBatchIds(courseIds);return materials.stream().map(material -> {FileUrl fileUrl = new FileUrl();// 生成带签名的预览URL(防止学生篡改URL访问其他资料)long timestamp = System.currentTimeMillis();String sign = DigestUtils.md5Hex(material.getCourseId() + "EDU" + timestamp);String url = String.format("%s?courseId=%s×tamp=%d&sign=%s",selfPreviewBaseUrl, material.getCourseId(), timestamp, sign);fileUrl.setUrl(url);fileUrl.setFileName(material.getTitle() + ".pdf");fileUrl.setDirectDownload(false); // 网页预览,需要转PDFreturn fileUrl;}).collect(Collectors.toList());}/*** 处理合作机构资料* 调用合作方Feign接口获取预览URL* 每个调用单独try-catch,单个失败不影响其他资料*/private List<FileUrl> buildPartnerCourseUrls(List<String> courseIds) {List<FileUrl> urls = new ArrayList<>();for (String courseId : courseIds) {try {// 生成调用合作方接口的签名long timestamp = System.currentTimeMillis();String signature = DigestUtils.md5Hex(partnerSecretKey + timestamp + ":" + courseId);// 调用合作方接口String result = partnerFeign.getMaterialPreviewUrl(courseId, timestamp, signature);JSONObject json = JSONUtil.parseObj(result);if (!"200".equals(String.valueOf(json.get("code")))) {log.error("获取合作方资料URL失败, courseId={}, msg={}", courseId, json.get("message"));continue; // 单个失败跳过,不影响其他}FileUrl fileUrl = new FileUrl();fileUrl.setUrl(json.getStr("data"));fileUrl.setFileName("合作课程_" + courseId + ".pdf");fileUrl.setDirectDownload(false); // 网页预览,需要转PDFurls.add(fileUrl);} catch (Exception e) {// 单个资料获取失败不中断整个流程log.error("调用合作方接口异常, courseId={}, error={}", courseId, e.getMessage());}}return urls;}/*** 处理历史归档资料* 已经是PDF文件存在OSS上,直接使用下载地址* directDownload = true,文件服务直接下载原文件,不做转换*/private List<FileUrl> buildArchiveUrls(List<String> archiveIds) {List<CourseMaterialEntity> materials = courseMaterialMapper.selectBatchIds(archiveIds);return materials.stream().filter(m -> StrUtil.isNotBlank(m.getPdfUrl())) // 过滤掉没有PDF地址的.map(material -> {FileUrl fileUrl = new FileUrl();fileUrl.setUrl(material.getPdfUrl());fileUrl.setFileName(material.getTitle() + ".pdf");fileUrl.setDirectDownload(true); // 已是PDF,直接下载return fileUrl;}).collect(Collectors.toList());}/*** 提交异步转PDF打包任务* 文件服务收到后会:* 1. 遍历URL列表* 2. directDownload=false的:打开URL → 渲染页面 → 转PDF* 3. directDownload=true的:直接下载原文件* 4. 所有文件打包为ZIP* 5. 上传到OSS,生成下载链接** @return 任务ID(前端用于轮询下载状态)*/private String submitAsyncTask(String studentId, List<FileUrl> urls) {AsyncPdfTaskForm taskForm = new AsyncPdfTaskForm();taskForm.setSource(serviceName); // 来源系统标识taskForm.setBusinessType("course_material_download"); // 业务类型标识taskForm.setBusinessCode(studentId); // 关联到具体学生taskForm.setUrls(urls); // 所有待处理的URLString result = fileServiceFeign.asyncUrlToPdf(taskForm);JSONObject json = JSONUtil.parseObj(result);if (!"1".equals(String.valueOf(json.get("code")))) {throw new BusinessException("提交下载任务失败:" + json.getStr("msg"));}// 返回任务IDreturn json.getStr("msg");}}
五、Controller
@Api(tags = "课程资料下载")@RestController@RequestMapping("/material")public class MaterialDownloadController {private final MaterialDownloadServiceImpl materialDownloadService;public MaterialDownloadController(MaterialDownloadServiceImpl materialDownloadService) {this.materialDownloadService = materialDownloadService;}@ApiOperation("批量下载课程资料")@PostMapping("/batchDownload")public R<String> batchDownload(@RequestBody @Validated BatchDownloadForm form) {String taskId = materialDownloadService.batchDownload(form);return new R<>(taskId);}}
六、完整流程图
学生勾选资料 → 点击"批量下载"│├── 前端发送请求│ {│ "selfCourseIds": ["C001", "C002"],│ "partnerCourseIds": ["P001"],│ "archiveIds": ["A001", "A002"],│ "studentId": "STU_2024001"│ }│├── 后端 Service 处理│ ││ ├── 自建课程 C001, C002│ │ ├── 查库获取资料信息│ │ ├── 拼接签名URL:https://edu.com/preview?courseId=C001×tamp=xxx&sign=xxx│ │ └── FileUrl { url=..., fileName="高等数学第一章.pdf", directDownload=false }│ ││ ├── 合作机构 P001│ │ ├── 生成签名:MD5(secretKey + timestamp + ":P001")│ │ ├── 调用 partnerFeign.getMaterialPreviewUrl("P001", timestamp, signature)│ │ ├── 获取返回的预览URL│ │ └── FileUrl { url=..., fileName="合作课程_P001.pdf", directDownload=false }│ ││ ├── 历史归档 A001, A002│ │ ├── 查库获取已归档的PDF地址│ │ └── FileUrl { url="https://oss.com/xxx.pdf", fileName="线性代数.pdf", directDownload=true }│ ││ └── 合并所有 FileUrl → 提交异步任务│├── 文件服务异步处理│ ├── C001: 打开URL → 渲染页面 → 截图/打印 → 生成PDF│ ├── C002: 打开URL → 渲染页面 → 截图/打印 → 生成PDF│ ├── P001: 打开URL → 渲染页面 → 截图/打印 → 生成PDF│ ├── A001: 直接从OSS下载PDF原文件│ ├── A002: 直接从OSS下载PDF原文件│ └── 5个文件打包 → 上传ZIP到OSS → 生成下载链接│├── 后端返回任务ID:"task_20240601_001"│└── 前端轮询GET /file/task/status?taskId=task_20240601_001第1次:{ "status": "processing", "progress": "3/5" }第2次:{ "status": "processing", "progress": "5/5" }第3次:{ "status": "completed", "downloadUrl": "https://oss.com/download/task_xxx.zip" }→ 弹出下载
七、关键设计点总结
List<FileUrl> | ||
八、适用场景
这个模式适用于所有需要"从多个来源收集文件并统一打包"的场景:
批量下载合同/协议 批量导出不同格式的报表 批量打印快递面单(不同快递公司接口不同) 批量下载电子发票(自开/第三方/历史) 批量导出用户数据(不同模块的数据)
核心套路:分源收集 → 统一封装 → 异步处理 → 轮询获取。
夜雨聆风