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

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

八、适用场景

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

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

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

相关推荐
一只大袋鼠3 小时前
SpringBoot 初学阶段知识点汇总(一)
spring boot·笔记·后端
做个文艺程序员3 小时前
第02篇:搭建 ES 集群 + Spring Boot 整合实战——从 Docker Compose 到 Java 客户端全覆盖
java·spring boot·elasticsearch
圆粥綠3 小时前
【保姆级】国内Windows用户Android Studio下载+安装+配置完整教程(2026最新版,避坑指南)
android·windows·android studio
猫头虎3 小时前
【Trea】Trea国内版|国际版|海外版下载|Mac版|Windows版|Linux下载配置教程
linux·人工智能·windows·macos·aigc·ai编程·agi
玖釉-3 小时前
C++ 中的矩阵介绍:以二维矩阵查找为例
c++·windows·算法·矩阵
斯特凡今天也很帅3 小时前
Spring Boot+mybatis项目切换sql为传参成无参
spring boot·sql·mybatis
zb200641203 小时前
Laravel4.x核心特性全解析
spring boot·后端·php·laravel
Devin~Y4 小时前
大厂Java面试实录:Spring Boot微服务 + Redis缓存 + Kafka消息队列 + Prometheus链路追踪 + RAG向量检索
java·spring boot·redis·spring cloud·kafka·rabbitmq·spring mvc
lzp07914 小时前
C#如何优雅处理引用类型的深拷贝(贰)
spring boot·后端·ui