批量收集多源 URL 并异步转 PDF 打包下载的完整实现Spring Boot Feign 异步任务场景描述一个在线教育平台学生可以批量下载课程资料。资料来自三个不同渠道自建课程平台自己的课件系统有预览页面 URL需要转成 PDF合作机构调用合作方接口获取资料 URL需要转成 PDF历史归档已经是 PDF 文件存在 OSS 上可直接下载学生勾选多个资料后点击批量下载后端需要收集所有 URL提交给文件服务异步转 PDF 并打包成 ZIP返回任务 ID 供前端轮询。一、数据结构定义/** * 批量下载请求入参 */DatapublicclassBatchDownloadForm{ApiModelProperty(自建课程资料ID列表)privateListStringselfCourseIds;ApiModelProperty(合作机构资料ID列表)privateListStringpartnerCourseIds;ApiModelProperty(历史归档资料ID列表)privateListStringarchiveIds;ApiModelProperty(学生ID)NotBlank(message学生ID不能为空)privateStringstudentId;}/** * 文件 URL 封装提交给文件服务的统一结构 */DatapublicclassFileUrl{ApiModelProperty(文件地址预览页面URL或直接下载地址)NotBlank(messageurl不能为空)privateStringurl;ApiModelProperty(文件名打包后ZIP内的文件名)NotBlank(message文件名不能为空)privateStringfileName;ApiModelProperty(是否可直接下载true则跳过转PDF直接打包原文件)privatebooleandirectDownloadfalse;}/** * 异步任务提交参数 */DatapublicclassAsyncPdfTaskForm{ApiModelProperty(系统来源)NotBlank(message系统来源不能为空)privateStringsource;ApiModelProperty(业务类型自定义标识用于区分不同业务的下载任务)privateStringbusinessType;ApiModelProperty(业务编码如学生ID用于关联查询任务状态)NotBlank(message业务编码不能为空)privateStringbusinessCode;ApiModelProperty(URL列表)Size(min1,messageURL列表不能为空)privateListFileUrlurls;}二、数据库表自建课程资料表CREATETABLEt_course_material(idbigint(20)UNSIGNEDNOTNULLAUTO_INCREMENT,course_idvarchar(50)NOTNULLCOMMENT课程ID,titlevarchar(200)NOTNULLCOMMENT资料标题,preview_urlvarchar(500)DEFAULTNULLCOMMENT预览页面URL,source_typevarchar(20)NOTNULLCOMMENT来源类型SELF自建/PARTNER合作/ARCHIVE归档,pdf_urlvarchar(500)DEFAULTNULLCOMMENT已归档的PDF地址仅ARCHIVE类型有值,create_timedatetimeDEFAULTCURRENT_TIMESTAMP,PRIMARYKEY(id))ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT课程资料表;三、Feign 接口定义/** * 合作机构资料接口 * 调用合作方系统获取资料预览URL */FeignClient(namepartnerFeign,url${feign.partner.url})publicinterfacePartnerFeign{/** * 获取合作机构资料的预览URL * param courseId 资料ID * param timestamp 时间戳用于签名验证 * param signature 签名防伪造 * return JSON字符串包含预览URL */GetMapping(/api/material/preview)StringgetMaterialPreviewUrl(RequestParam(courseId)StringcourseId,RequestHeader(x-timestamp)longtimestamp,RequestHeader(x-signature)Stringsignature);}/** * 文件服务接口 * 负责将网页URL转为PDF并打包ZIP */FeignClient(namefileServiceFeign,url${feign.file-service.url})publicinterfaceFileServiceFeign{/** * 提交异步转PDF打包任务 * param form 任务参数包含URL列表 * return JSON字符串包含任务ID */PostMapping(/v2/export/asyncUrlToPdf)StringasyncUrlToPdf(RequestBodyAsyncPdfTaskFormform);}四、Service 完整实现Slf4jServicepublicclassMaterialDownloadServiceImpl{privatefinalCourseMaterialMappercourseMaterialMapper;privatefinalPartnerFeignpartnerFeign;privatefinalFileServiceFeignfileServiceFeign;Value(${partner.secret-key})privateStringpartnerSecretKey;Value(${course.preview.base-url})privateStringselfPreviewBaseUrl;Value(${spring.application.name})privateStringserviceName;publicMaterialDownloadServiceImpl(CourseMaterialMappercourseMaterialMapper,PartnerFeignpartnerFeign,FileServiceFeignfileServiceFeign){this.courseMaterialMappercourseMaterialMapper;this.partnerFeignpartnerFeign;this.fileServiceFeignfileServiceFeign;}/** * 批量下载课程资料 * * 整体流程 * 1. 分别处理三种来源的资料收集所有文件URL * 2. 提交给文件服务异步转PDF并打包 * 3. 返回任务ID前端轮询下载 * * param form 下载请求参数 * return 异步任务ID */publicStringbatchDownload(BatchDownloadFormform){// 统一收集所有文件URL的容器ListFileUrlallFileUrlsnewArrayList();// 1. 处理自建课程资料本地生成签名URL需要转PDF if(CollUtil.isNotEmpty(form.getSelfCourseIds())){ListFileUrlselfUrlsbuildSelfCourseUrls(form.getSelfCourseIds());allFileUrls.addAll(selfUrls);}// 2. 处理合作机构资料调用远程接口获取URL需要转PDF if(CollUtil.isNotEmpty(form.getPartnerCourseIds())){ListFileUrlpartnerUrlsbuildPartnerCourseUrls(form.getPartnerCourseIds());allFileUrls.addAll(partnerUrls);}// 3. 处理历史归档资料已有PDF直接下载 if(CollUtil.isNotEmpty(form.getArchiveIds())){ListFileUrlarchiveUrlsbuildArchiveUrls(form.getArchiveIds());allFileUrls.addAll(archiveUrls);}// 4. 校验并提交异步任务 if(CollUtil.isEmpty(allFileUrls)){thrownewBusinessException(没有可下载的资料);}returnsubmitAsyncTask(form.getStudentId(),allFileUrls);}/** * 处理自建课程资料 * 本地拼接预览页面URL MD5签名防止URL被篡改 * directDownload false文件服务会打开这个URL渲染页面后转为PDF */privateListFileUrlbuildSelfCourseUrls(ListStringcourseIds){// 批量查询资料信息ListCourseMaterialEntitymaterialscourseMaterialMapper.selectBatchIds(courseIds);returnmaterials.stream().map(material-{FileUrlfileUrlnewFileUrl();// 生成带签名的预览URL防止学生篡改URL访问其他资料longtimestampSystem.currentTimeMillis();StringsignDigestUtils.md5Hex(material.getCourseId()EDUtimestamp);StringurlString.format(%s?courseId%stimestamp%dsign%s,selfPreviewBaseUrl,material.getCourseId(),timestamp,sign);fileUrl.setUrl(url);fileUrl.setFileName(material.getTitle().pdf);fileUrl.setDirectDownload(false);// 网页预览需要转PDFreturnfileUrl;}).collect(Collectors.toList());}/** * 处理合作机构资料 * 调用合作方Feign接口获取预览URL * 每个调用单独try-catch单个失败不影响其他资料 */privateListFileUrlbuildPartnerCourseUrls(ListStringcourseIds){ListFileUrlurlsnewArrayList();for(StringcourseId:courseIds){try{// 生成调用合作方接口的签名longtimestampSystem.currentTimeMillis();StringsignatureDigestUtils.md5Hex(partnerSecretKeytimestamp:courseId);// 调用合作方接口StringresultpartnerFeign.getMaterialPreviewUrl(courseId,timestamp,signature);JSONObjectjsonJSONUtil.parseObj(result);if(!200.equals(String.valueOf(json.get(code)))){log.error(获取合作方资料URL失败, courseId{}, msg{},courseId,json.get(message));continue;// 单个失败跳过不影响其他}FileUrlfileUrlnewFileUrl();fileUrl.setUrl(json.getStr(data));fileUrl.setFileName(合作课程_courseId.pdf);fileUrl.setDirectDownload(false);// 网页预览需要转PDFurls.add(fileUrl);}catch(Exceptione){// 单个资料获取失败不中断整个流程log.error(调用合作方接口异常, courseId{}, error{},courseId,e.getMessage());}}returnurls;}/** * 处理历史归档资料 * 已经是PDF文件存在OSS上直接使用下载地址 * directDownload true文件服务直接下载原文件不做转换 */privateListFileUrlbuildArchiveUrls(ListStringarchiveIds){ListCourseMaterialEntitymaterialscourseMaterialMapper.selectBatchIds(archiveIds);returnmaterials.stream().filter(m-StrUtil.isNotBlank(m.getPdfUrl()))// 过滤掉没有PDF地址的.map(material-{FileUrlfileUrlnewFileUrl();fileUrl.setUrl(material.getPdfUrl());fileUrl.setFileName(material.getTitle().pdf);fileUrl.setDirectDownload(true);// 已是PDF直接下载returnfileUrl;}).collect(Collectors.toList());}/** * 提交异步转PDF打包任务 * 文件服务收到后会 * 1. 遍历URL列表 * 2. directDownloadfalse的打开URL → 渲染页面 → 转PDF * 3. directDownloadtrue的直接下载原文件 * 4. 所有文件打包为ZIP * 5. 上传到OSS生成下载链接 * * return 任务ID前端用于轮询下载状态 */privateStringsubmitAsyncTask(StringstudentId,ListFileUrlurls){AsyncPdfTaskFormtaskFormnewAsyncPdfTaskForm();taskForm.setSource(serviceName);// 来源系统标识taskForm.setBusinessType(course_material_download);// 业务类型标识taskForm.setBusinessCode(studentId);// 关联到具体学生taskForm.setUrls(urls);// 所有待处理的URLStringresultfileServiceFeign.asyncUrlToPdf(taskForm);JSONObjectjsonJSONUtil.parseObj(result);if(!1.equals(String.valueOf(json.get(code)))){thrownewBusinessException(提交下载任务失败json.getStr(msg));}// 返回任务IDreturnjson.getStr(msg);}}五、ControllerApi(tags课程资料下载)RestControllerRequestMapping(/material)publicclassMaterialDownloadController{privatefinalMaterialDownloadServiceImplmaterialDownloadService;publicMaterialDownloadController(MaterialDownloadServiceImplmaterialDownloadService){this.materialDownloadServicematerialDownloadService;}ApiOperation(批量下载课程资料)PostMapping(/batchDownload)publicRStringbatchDownload(RequestBodyValidatedBatchDownloadFormform){StringtaskIdmaterialDownloadService.batchDownload(form);returnnewR(taskId);}}六、完整流程图学生勾选资料 → 点击批量下载 │ ├── 前端发送请求 │ { │ selfCourseIds: [C001, C002], │ partnerCourseIds: [P001], │ archiveIds: [A001, A002], │ studentId: STU_2024001 │ } │ ├── 后端 Service 处理 │ │ │ ├── 自建课程 C001, C002 │ │ ├── 查库获取资料信息 │ │ ├── 拼接签名URLhttps://edu.com/preview?courseIdC001timestampxxxsignxxx │ │ └── FileUrl { url..., fileName高等数学第一章.pdf, directDownloadfalse } │ │ │ ├── 合作机构 P001 │ │ ├── 生成签名MD5(secretKey timestamp :P001) │ │ ├── 调用 partnerFeign.getMaterialPreviewUrl(P001, timestamp, signature) │ │ ├── 获取返回的预览URL │ │ └── FileUrl { url..., fileName合作课程_P001.pdf, directDownloadfalse } │ │ │ ├── 历史归档 A001, A002 │ │ ├── 查库获取已归档的PDF地址 │ │ └── FileUrl { urlhttps://oss.com/xxx.pdf, fileName线性代数.pdf, directDownloadtrue } │ │ │ └── 合并所有 FileUrl → 提交异步任务 │ ├── 文件服务异步处理 │ ├── C001: 打开URL → 渲染页面 → 截图/打印 → 生成PDF │ ├── C002: 打开URL → 渲染页面 → 截图/打印 → 生成PDF │ ├── P001: 打开URL → 渲染页面 → 截图/打印 → 生成PDF │ ├── A001: 直接从OSS下载PDF原文件 │ ├── A002: 直接从OSS下载PDF原文件 │ └── 5个文件打包 → 上传ZIP到OSS → 生成下载链接 │ ├── 后端返回任务IDtask_20240601_001 │ └── 前端轮询 GET /file/task/status?taskIdtask_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各渠道逻辑独立互不干扰统一收集所有来源最终都收集到ListFileUrl下游文件服务只需一个统一接口directDownload 标记区分需要转PDF和直接下载避免对已有PDF重复转换单个失败不中断合作方接口调用用 try-catch continue一个资料失败不影响其他资料URL 签名MD5(secretKey timestamp bizId)防止URL被伪造或篡改异步任务提交后立即返回任务ID用户不用等待大批量文件也不超时前端轮询定时查询任务状态直到完成解耦请求和处理过程八、适用场景这个模式适用于所有需要从多个来源收集文件并统一打包的场景批量下载合同/协议批量导出不同格式的报表批量打印快递面单不同快递公司接口不同批量下载电子发票自开/第三方/历史批量导出用户数据不同模块的数据核心套路分源收集 → 统一封装 → 异步处理 → 轮询获取。