乐于分享
好东西不私藏

批量收集多源 URL 并异步转 PDF 打包下载的完整实现(Spring Boot + Feign + 异步任务)

批量收集多源 URL 并异步转 PDF 打包下载的完整实现(Spring Boot + Feign + 异步任务)

批量收集多源 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(50NOT NULL COMMENT '课程ID',  `title` varchar(200NOT NULL COMMENT '资料标题',  `preview_url` varchar(500DEFAULT NULL COMMENT '预览页面URL',  `source_type` varchar(20NOT NULL COMMENT '来源类型:SELF自建/PARTNER合作/ARCHIVE归档',  `pdf_url` varchar(500DEFAULT 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<FileUrlbuildSelfCourseUrls(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&timestamp=%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<FileUrlbuildPartnerCourseUrls(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<FileUrlbuildArchiveUrls(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

@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&timestamp=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
用户不用等待,大批量文件也不超时
前端轮询
定时查询任务状态直到完成
解耦请求和处理过程

八、适用场景

这个模式适用于所有需要"从多个来源收集文件并统一打包"的场景:

  • 批量下载合同/协议
  • 批量导出不同格式的报表
  • 批量打印快递面单(不同快递公司接口不同)
  • 批量下载电子发票(自开/第三方/历史)
  • 批量导出用户数据(不同模块的数据)

核心套路:分源收集 → 统一封装 → 异步处理 → 轮询获取