批量收集多源 URL 并异步转 PDF 打包下载的完整实现(Spring Boot + Feign + 异步任务)
场景描述
一个在线教育平台,学生可以批量下载课程资料。资料来自三个不同渠道:
- 自建课程:平台自己的课件系统,有预览页面 URL,需要转成 PDF
- 合作机构:调用合作方接口获取资料 URL,需要转成 PDF
- 历史归档:已经是 PDF 文件,存在 OSS 上,可直接下载
学生勾选多个资料后点击"批量下载",后端需要收集所有 URL,提交给文件服务异步转 PDF 并打包成 ZIP,返回任务 ID 供前端轮询。
一、数据结构定义
java
/**
* 批量下载请求入参
*/
@Data
public 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 封装(提交给文件服务的统一结构)
*/
@Data
public class FileUrl {
@ApiModelProperty("文件地址(预览页面URL或直接下载地址)")
@NotBlank(message = "url不能为空")
private String url;
@ApiModelProperty("文件名(打包后ZIP内的文件名)")
@NotBlank(message = "文件名不能为空")
private String fileName;
@ApiModelProperty("是否可直接下载(true则跳过转PDF,直接打包原文件)")
private boolean directDownload = false;
}
/**
* 异步任务提交参数
*/
@Data
public 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;
}
二、数据库表(自建课程资料表)
sql
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 接口定义
java
/**
* 合作机构资料接口
* 调用合作方系统获取资料预览URL
*/
@FeignClient(name = "partnerFeign", url = "${feign.partner.url}")
public interface PartnerFeign {
/**
* 获取合作机构资料的预览URL
* @param courseId 资料ID
* @param timestamp 时间戳(用于签名验证)
* @param signature 签名(防伪造)
* @return JSON字符串,包含预览URL
*/
@GetMapping("/api/material/preview")
String getMaterialPreviewUrl(
@RequestParam("courseId") String courseId,
@RequestHeader("x-timestamp") long timestamp,
@RequestHeader("x-signature") String signature);
}
/**
* 文件服务接口
* 负责将网页URL转为PDF并打包ZIP
*/
@FeignClient(name = "fileServiceFeign", 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 完整实现
java
@Slf4j
@Service
public class MaterialDownloadServiceImpl {
private final CourseMaterialMapper courseMaterialMapper;
private final PartnerFeign partnerFeign;
private final FileServiceFeign fileServiceFeign;
@Value("${partner.secret-key}")
private String partnerSecretKey;
@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); // 网页预览,需要转PDF
return 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); // 网页预览,需要转PDF
urls.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); // 所有待处理的URL
String result = fileServiceFeign.asyncUrlToPdf(taskForm);
JSONObject json = JSONUtil.parseObj(result);
if (!"1".equals(String.valueOf(json.get("code")))) {
throw new BusinessException("提交下载任务失败:" + json.getStr("msg"));
}
// 返回任务ID
return json.getStr("msg");
}
}
五、Controller
java
@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" }
→ 弹出下载
七、关键设计点总结
| 设计点 | 实现方式 | 解决的问题 |
|---|---|---|
| 多源分发 | 按来源类型分别调用不同方法获取URL | 各渠道逻辑独立,互不干扰 |
| 统一收集 | 所有来源最终都收集到 List<FileUrl> |
下游文件服务只需一个统一接口 |
| directDownload 标记 | 区分"需要转PDF"和"直接下载" | 避免对已有PDF重复转换 |
| 单个失败不中断 | 合作方接口调用用 try-catch + continue | 一个资料失败不影响其他资料 |
| URL 签名 | MD5(secretKey + timestamp + bizId) | 防止URL被伪造或篡改 |
| 异步任务 | 提交后立即返回任务ID | 用户不用等待,大批量文件也不超时 |
| 前端轮询 | 定时查询任务状态直到完成 | 解耦请求和处理过程 |
八、适用场景
这个模式适用于所有需要"从多个来源收集文件并统一打包"的场景:
- 批量下载合同/协议
- 批量导出不同格式的报表
- 批量打印快递面单(不同快递公司接口不同)
- 批量下载电子发票(自开/第三方/历史)
- 批量导出用户数据(不同模块的数据)
核心套路:分源收集 → 统一封装 → 异步处理 → 轮询获取。