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

八、适用场景

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

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

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

相关推荐
许彰午5 小时前
30_Java Stream流操作全解
java·windows·python
梦@_@境6 小时前
面向 Spring Boot 的可观测业务流程编排引擎
java·spring boot·后端
仙俊红7 小时前
SpringBoot启动原理
java·spring boot·后端
星间都市山脉7 小时前
Android STS(Security Test Suite)完整介绍与测试流程
android·java·linux·windows·ubuntu·android studio·androidx
記億揺晃着的那天8 小时前
告别误操作!Spring Boot 多环境配置隔离与启动守卫实战
java·spring boot·后端·环境隔离
xiaoliuliu123459 小时前
Sketchpad 5.0.6 几何画板安装版配置教程 Windows版:部署+桌面快捷方式创建指南
windows
skywalker_119 小时前
SpringBoot速通(实战教学)
java·spring boot·redis·rpc·ssm·mybatis-plus
码不停蹄的玄黓10 小时前
Spring Boot 实现过滤器(Filter)三种常用方式
java·spring boot·后端
惊鸿一博10 小时前
网络端口开放访问权限_Windows 11 上确保防火墙允许指定端口如3001可被访问
网络·windows
【这个世界会好的】10 小时前
单层PDF转双层PDF工具
pdf