
这次我们来完成智能视频推荐小程序系列教程的第十一讲下载接口。前几讲里我们已经完成了视频推荐、搜索、收藏等功能整个小程序已经能把用户想看的内容推送到位。但这里其实还缺一个关键环节用户看到推荐结果后想离线观看或保存视频怎么办本讲要解决的就是这个问题把“推荐结果”落成“可下载的文件”。这一讲涉及的东西比较具体不是概念讲解而是直接可以落地的接口开发后端基于 SpringBoot 实现视频下载接口下载链接如何做成有效期内可用的临时地址如何通过 Referer 校验和签名参数做简单的防盗链前端 Uniapp 里怎么调下载接口并保存到本地相册或文件系统批量下载任务的队列设计与失败重试下载大文件时的断点续传与性能观察方式。本讲适合正在做 SpringBoot Vue Uniapp 小程序项目的开发者尤其是需要实现视频防下载、临时链接、流式输出、断点续传这类功能的同学。文章里没有花哨的理论全部围绕可运行的接口设计和代码片段展开。1. 核心能力速览能力项说明项目类型SpringBoot 后端下载接口 Uniapp 前端下载流程主要功能生成临时下载链接、视频流输出、断点续传、防盗链、批量下载任务本讲涉及技术SpringBoot 文件流下载、URL 签名、Referer 校验、Uniapp 下载 API、线程池批量任务与 AI 链路的关系推荐结果关联的视频元数据通过下载接口转化为本地文件启动方式后端 SpringBoot 项目原有启动方式不变新增接口重启即可生效是否支持 API 调试是Swagger 或直接 curl 调试是否支持批量任务是本讲给出批量任务队列设计和代码示例适合场景视频 App 下载功能、课程资源下载、小程序离线缓存、后台管理导出视频列表使用边界必须接入用户鉴权视频版权和下载授权必须在业务层先做校验2. 下载接口的适用场景与使用边界下载接口不是单纯写一个文件输出流就结束。实际业务里用户能不能下载、下载之后能否传播、要不要保留下载记录都是产品层需要考虑的问题。本讲的下载接口设计主要适用以下场景智能推荐小程序中用户点击“下载”按钮把推荐视频保存到本地后台管理系统需要批量导出视频素材但必须有权限控制课程类小程序提供离线缓存下载接口需要与会员有效期绑定企业内部素材库需要做文件分发接口需要加访问时效和校验逻辑。在整个项目里视频下载和前面的 AI 推荐链路是协同工作的。推荐接口告诉用户“哪些视频值得看”下载接口告诉用户“如何把想看的视频存下来”。因此在后端做下载接口时最好把推荐结果里的videoId作为下载请求的主参数而不是让前端直接传文件路径。这样可以避免前端把服务器内部路径暴露出来。使用边界方面必须明确说明下载前必须校验用户登录态和视频权限上传视频的一方必须确认自己拥有版权或已获授权下载接口要设计时效性访问令牌或签名防止接口被长期滥用涉及转载、付费内容、人脸肖像的视频下载能力应默认关闭或走人工审核私域分享链接即使做了签名也可能被二次传播所以签名有效期要短范围要窄。一句话总结下载接口本身不难难的是权限、时效、防盗链和批量任务这四件事。3. 环境准备与前置条件在实际编码之前先确认项目环境。本讲假定你已经完成了系列教程前几讲的内容项目里已经有SpringBoot 后端工程Java 8 或 Java 17 均可已经接入 LangChain4j 或 SpringAI 的视频推荐服务能拿到videoId和视频元数据视频文件本身存储在本地磁盘或对象存储本讲以本地文件存储为主前端使用 Uniapp 工程能正常请求后端接口。需要准备的工具和依赖依赖项说明JDK8 或 17按现有项目版本保持一致Spring Boot2.x 或 3.x按现有项目选择Maven 或 Gradle构建后端工程HBuilderXUniapp 编译运行工具视频测试文件准备一个 10MB 左右的 MP4 文件用于验证下载网络测试工具Postman、Apifox 或 curl用于调试下载接口磁盘方面需要注意视频文件可能较大下载接口读文件时不要一次性把整个文件加载到内存。建议使用InputStreamResource或自定义流式响应避免大文件下载导致内存溢出。如果项目中使用的是对象存储 OSS下载接口可以转型为一个“生成临时签名下载地址”的接口而不是直接输出文件流。这种模式更节省服务器带宽。但如果只是本地磁盘存储流式输出是更直接的做法。本讲同时覆盖这两种思路核心代码先基于本地文件输出最后给出对象存储的适配说明。4. 下载接口业务设计下载接口不是只写一个 Controller。更稳妥的是分两层先设计业务服务层再设计 Web 层。在智能视频推荐小程序里一个视频从推荐结果到用户下载会经历以下步骤推荐接口返回videoId列表和视频元数据用户点击下载按钮前端携带videoId请求后端下载接口后端校验用户登录态、VIP 状态、视频版权状态后端验证通过后生成一次性的临时下载链接或者直接输出文件流前端接收文件流并写入本地下载完成后上报下载结果后端记录下载日志。这里最核心的一点是不要把服务器的物理文件路径直接暴露给前端。前端只传videoId后端根据videoId查询视频存储路径。就算接口设计上做不到绝对安全至少不要从接口返回的 JSON 里直接给出/home/video/xxx.mp4这样的绝对路径。另一种实现方式是用一个“下载码”作为中间层后端生成随机的downloadToken绑定videoId和过期时间前端拿着downloadToken去请求真正的下载接口下载接口校验downloadToken是否有效。这种方式的好处是下载链接可以在有效期内拼接为完整的 CDN 地址或内网地址适合需要把下载能力做成独立服务的情况。本讲的代码会同时包含 File 流输出和 downloadToken 校验两种设计。5. 后端下载接口实现SpringBoot 视频文件流输出先看最基础也最实用的实现方式通过videoId查询视频存储路径然后以文件流形式返回给前端。下面是一个可参考的DownloadController代码。实际项目请按自己的 Service 层方法调整路径和返回值。RestController RequestMapping(/api/video/download) public class DownloadController { private final VideoStorageService videoStorageService; public DownloadController(VideoStorageService videoStorageService) { this.videoStorageService videoStorageService; } /** * 视频文件下载接口 * param videoId 视频ID * param token 下载临时令牌 * param response HttpServletResponse */ GetMapping(/{videoId}) public void download(PathVariable Long videoId, RequestParam String token, HttpServletResponse response) throws IOException { // 1. 校验用户登录态 // 2. 校验视频下载权限 // 3. 校验下载 token 是否存在且未过期 if (!downloadTokenService.verifyToken(token, videoId)) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.getWriter().write(download token invalid or expired); return; } // 4. 查询视频真实存储路径 VideoFileInfo fileInfo videoStorageService.getVideoFileInfo(videoId); if (fileInfo null || !fileInfo.exists()) { response.setStatus(HttpStatus.NOT_FOUND.value()); response.getWriter().write(video file not found); return; } // 5. 处理 Range 请求支持断点续传 File file fileInfo.getFile(); String fileName fileInfo.getFileName(); long fileLength file.length(); response.setContentType(video/mp4); response.setHeader(Content-Disposition, attachment; filename\ fileName \); response.setHeader(Accept-Ranges, bytes); String rangeHeader request.getHeader(Range); if (rangeHeader ! null) { // Range 处理逻辑返回 206 部分内容 } else { long contentLength fileLength; response.setContentLengthLong(contentLength); try (InputStream inputStream new FileInputStream(file); OutputStream outputStream response.getOutputStream()) { IOUtils.copyLarge(inputStream, outputStream); } } } }这段代码的主要流程是下载令牌校验视频文件是否存在设置响应头流式输出文件内容。实际项目中你还需要把request.getHeader(Range)的解析逻辑补上。处理 Range 请求本身不复杂但需要注意多个区间请求的情况。如果项目只需要支持单线程下载器处理bytesstart-end这一种格式就够了。如果要做完整的多线程下载需要解析多个区间。考虑到小程序下载场景单 Range 就够了。下面是处理 Range 的代码片段if (rangeHeader ! null rangeHeader.startsWith(bytes)) { String range rangeHeader.substring(bytes.length()); long start 0; long end fileLength - 1; if (range.contains(-)) { String[] parts range.split(-); start Long.parseLong(parts[0]); if (parts.length 1 !parts[1].isEmpty()) { end Long.parseLong(parts[1]); } } if (start end || start fileLength) { response.setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value()); response.setHeader(Content-Range, bytes */ fileLength); return; } long contentLength end - start 1; response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); response.setHeader(Content-Range, bytes start - end / fileLength); response.setContentLengthLong(contentLength); try (RandomAccessFile raf new RandomAccessFile(file, r); OutputStream outputStream response.getOutputStream()) { raf.seek(start); byte[] buffer new byte[8192]; long remaining contentLength; int len; while (remaining 0 (len raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) ! -1) { outputStream.write(buffer, 0, len); remaining - len; } } return; }搞定了 Range 请求之后用户的下载行为会稳定很多。因为视频文件往往有几百 MB下载中断后如果每次都要重新开始体验非常差。而支持了断点续传之后Uniapp 端、微信小程序端、普通浏览器下载器都能更可靠地完成下载。6. 下载令牌与防盗链设计直接暴露一个下载接口后面带来的问题可能比解决的问题还多。比如接口被第三方盗用、下载链接被无限传播、服务器带宽被刷爆。所以下载令牌和防盗链是必须考虑的环节。下载令牌的核心思路是当用户请求获取下载链接时后端返回一个带签名和过期时间的 URL前端用这个 URL 去请求下载接口下载接口校验签名和过期时间通过后才输出文件流。签名生成可以用一个简单的 HMAC 算法来实现public String generateDownloadToken(Long videoId, Long userId, long expireSeconds) { String secretKey your-secret-key; // 请从配置中心读取 long expireTime System.currentTimeMillis() expireSeconds * 1000; String raw videoId : userId : expireTime; String sign HmacUtils.hmacSha256Hex(secretKey, raw); return Base64.getUrlEncoder().withoutPadding() .encodeToString((raw : sign).getBytes(StandardCharsets.UTF_8)); }对应的校验逻辑public boolean verifyToken(String token, Long videoId) { try { String decoded new String(Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8); String[] parts decoded.split(:); if (parts.length ! 3) { return false; } long expireTime Long.parseLong(parts[1]); if (System.currentTimeMillis() expireTime) { return false; } String expectedSign HmacUtils.hmacSha256Hex(your-secret-key, parts[0] : parts[1]); return expectedSign.equals(parts[2]); } catch (Exception e) { return false; } }除了签名校验还可以配合 Referer 校验。如果下载接口只服务小程序或自家前端可以在 Nginx 层或者后端过滤器里检查Referer头。注意小程序里Referer可能不是你预期的域名所以这块要按实际网络环境配置。如果接口同时给 App 和小程序用建议以签名校验为主、Referer 校验为辅不要强依赖 Referer否则会造成正常用户无法下载。还有一种做法是在 Nginx 上做secure_link让 Nginx 负责签名校验。如果不想在应用层处理下载数据流可以把校验能力下沉到 Nginx应用层只负责生成secure_linkURL。这种方式服务器负载更低适合大文件分发场景。7. Uniapp 前端下载流程与代码实现后端下载接口做好了接下来看前端 Uniapp 怎么调用。在 Uniapp 中下载文件有两种常见方式uni.downloadFile下载文件到临时路径uni.saveVideoToPhotosAlbum保存视频到系统相册。推荐视频下载的场景通常是先uni.downloadFile再用uni.saveVideoToPhotosAlbum保存到相册。以下是在智能视频推荐小程序中对接后端下载接口的完整流程。7.1 获取下载令牌用户点击下载按钮后前端先调用后端获取下载令牌接口。async getDownloadToken(videoId) { const res await uni.request({ url: /api/video/download/token, method: POST, data: { videoId: videoId }, header: { Authorization: uni.getStorageSync(token) } }); return res.data.data.token; }7.2 下载视频文件拿到 token 之后拼接下载链接并调用uni.downloadFile。downloadVideo(videoId) { const that this; uni.showLoading({ title: 准备下载... }); // 先获取下载令牌 this.getDownloadToken(videoId).then(token { const downloadUrl http://your-server/api/video/download/${videoId}?token${token}; const downloadTask uni.downloadFile({ url: downloadUrl, success: (res) { if (res.statusCode 200) { // 下载成功保存到系统相册 uni.saveVideoToPhotosAlbum({ filePath: res.tempFilePath, success: () { uni.showToast({ title: 保存成功 }); }, fail: (err) { uni.showToast({ title: 保存失败, icon: none }); } }); } else { uni.showToast({ title: 下载失败, icon: none }); } }, fail: (err) { uni.showToast({ title: 下载失败, icon: none }); } }); // 监听下载进度 downloadTask.onProgressUpdate((res) { that.downloadProgress res.progress; }); }); }这里有一个细节需要特别注意抖音小程序和微信小程序的saveVideoToPhotosAlbum行为可能不同。微信小程序里这个 API 需要用户授权scope.writePhotosAlbum如果用户拒绝过授权就需要引导用户去设置页打开权限。抖音小程序则需要确认是否支持该 API部分版本可能只支持uni.saveFile保存到本地文件系统而不是相册。实际开发时建议先做能力检测再走保存逻辑。7.3 下载进度展示如果下载任务需要显示进度条上面的downloadTask.onProgressUpdate已经可以拿到进度数据。可以在页面上加一个进度条组件view classprogress-bar view classprogress :style{width: downloadProgress %}/view /view需要注意onProgressUpdate不是每时每刻都会回调。如果文件很小可能直接一次完成看不到中间进度。进度条只作为体验优化不能依赖它判断下载是否成功。8. 接口 API 调用示例与测试方法下载接口写完先用工具验证一遍再进前端流程。这里用一个简单视频文件测试。测试环境假设后端地址http://127.0.0.1:8080测试视频 ID 为1001测试令牌由后端生成8.1 curl 下载文件# 带 token 下载视频到本地 test.mp4 curl -L http://127.0.0.1:8080/api/video/download/1001?tokenYOUR_TOKEN \ -o test.mp4下载完成后用文件大小核对完整性ls -lh test.mp4 file test.mp4如果文件大小和源文件一致并且file命令输出类型为 MP4说明下载接口基本正常。8.2 Python 调用下载接口并校验断点续传也可以直接用 Python 脚本验证断点续传import requests url http://127.0.0.1:8080/api/video/download/1001?tokenYOUR_TOKEN # 第一次请求获取文件大小 headers {Range: bytes0-1024} res requests.get(url, headersheaders, streamTrue, timeout30) print(状态码:, res.status_code) print(Content-Range:, res.headers.get(Content-Range)) if res.status_code 206: with open(partial.mp4, wb) as f: f.write(res.content) print(部分下载成功可继续请求剩余部分)如果返回206 Partial Content说明断点续传已经生效。如果返回200说明 Range 头没有被后端解析需要检查 Controller 里对Range的处理逻辑。8.3 下载令牌过期测试生成一个有效期为 5 秒的 token等待 10 秒后再请求下载接口预期返回403 Forbidden。如果返回403说明过期时间校验生效。如果返回200则需要检查签名校验代码是否真的执行了。测试这块时建议直接用 Apifox 或 Postman 创建多个环境变量方便对比“有效 token”“过期 token”“错误 videoId”“不带 token”四种情况。把结果整理成下表的格式出了问题更容易定位测试场景预期结果实际结果说明有效 token 合法 videoId200 或 206待测正常下载过期 token403待测签名过期伪造 token403待测签名校验失败不存在 videoId404待测文件不存在不带 token403待测缺少签名参数9. 批量下载任务与队列设计单文件下载接口跑通之后部分场景还需要做批量下载。比如用户在推荐列表中一次性勾选多部视频点击“批量缓存”。这时如果前端循环调用下载接口每个请求都独立不仅速度慢还容易触发服务端限流。更合理的方案是后端提供批量下载任务把多个下载请求合并成一个任务队列由后端统一调度。批量任务服务的核心代码逻辑可以这样设计public class BatchDownloadService { private final ExecutorService executorService Executors.newFixedThreadPool(3); private final MapString, BatchDownloadTask taskMap new ConcurrentHashMap(); /** * 创建批量下载任务 * param userId 用户ID * param videoIds 视频ID列表 * return 任务ID */ public String createBatchTask(Long userId, ListLong videoIds) { String taskId UUID.randomUUID().toString(); BatchDownloadTask task new BatchDownloadTask(); task.setTaskId(taskId); task.setStatus(PENDING); task.setTotalCount(videoIds.size()); task.setSuccessCount(0); task.setFailCount(0); taskMap.put(taskId, task); executorService.submit(() - processBatchTask(task, videoIds)); return taskId; } private void processBatchTask(BatchDownloadTask task, ListLong videoIds) { task.setStatus(RUNNING); for (Long videoId : videoIds) { try { // 调用下载服务 downloadService.downloadById(videoId); task.setSuccessCount(task.getSuccessCount() 1); } catch (Exception e) { task.setFailCount(task.getFailCount() 1); // 记录失败原因 } } task.setStatus(FINISHED); } /** * 查询任务进度 * param taskId 任务ID * return 任务进度信息 */ public BatchDownloadTask getTaskProgress(String taskId) { return taskMap.get(taskId); } }批量任务接口需要提供POST /api/video/download/batch创建任务GET /api/video/download/batch/{taskId}查询任务进度DELETE /api/video/download/batch/{taskId}取消任务。前端可以每 3 到 5 秒轮询一次任务进度把成功数和失败数展示在页面上。任务最终执行完把失败的 videoId 列表返回给前端让用户可以单独重试。批量任务注意线程池大小。视频文件下载属于 IO 密集型操作线程数设置为 CPU 核心数的两倍左右比较合理。如果下载目标不是本地文件而是远程对象存储还要注意网络带宽限制线程数过大反而会拖慢整体速度。这里有个非常常见的坑批量下载任务执行到一半用户杀掉了小程序后端任务还在继续跑。如果下载的目标是服务器本地文件程序继续执行没问题但如果下载的目标是注册用户任务记录那用户下次打开小程序时会发现任务已经在后台完成了。要注意的是任务状态需要持久化不能只存在内存里。否则服务重启之后所有批量任务状态全部丢失。10. 资源占用与性能观察下载接口的性能瓶颈通常不在 CPU而在磁盘 IO 和网络带宽。观察性能时重点看这几个指标。10.1 磁盘 IO视频文件下载时FileInputStream或RandomAccessFile都会持续读磁盘。如果同时有多个下载请求磁盘 IO 可能是最先被打满的资源。用iostat观察iostat -x 1如果%util一直接近 100%说明磁盘已经达到瓶颈。这时可以把大视频分发到对象存储或 CDN应用层增加下载并发限制使用固态硬盘作为视频存储盘。10.2 内存占用流式下载不加载整个文件到内存所以内存占用不会和视频大小成正比。但如果代码里用了byte[]一次读完整个文件几百 MB 的视频会直接 OOM。排查内存问题时重点看有没有一次性读取整个文件的代码IOUtils.copyLarge是否用对了有没有缓存队列里堆积大量视频数据。10.3 网络带宽一个 1Gbps 带宽的服务器下载一个 500MB 视频大约需要 4 秒。但如果有 50 个用户同时下载每个用户分到的带宽只有 20Gbps 的百分之一体验会明显下降。所以在下载接口处需要考虑给单个用户设置限速。SpringBoot 层做限速不是太方便一般建议用 Nginxlimit_rate来做location /video/download { limit_rate 2m; limit_rate_after 8m; }这样前 8MB 不限速8MB 之后限速 2MB/s避免单个用户长时间占用全部带宽。11. 常见问题与排查方法从实际开发角度看下载接口最容易踩的坑集中在下面几类。问题现象可能原因排查方式解决方案下载只返回 JSON 错误没有文件流token 过期检查响应状态码重新获取 token下载文件只有 0KB后端返回了错误页面而非文件流用 curl 查看响应头检查 Controller 异常处理逻辑断点续传不生效后端没处理 Range 头查看后端日志和响应头补充 Range 处理逻辑小程序下载失败域名没有配置合法白名单检查小程序后台域名配置配置合法下载域名保存相册失败用户未授权相册权限检查控制台报错引导用户打开设置授权下载一半失败网络不稳定查看错误码实现自动重试或断点续传服务端内存暴涨代码一次性读取整个文件查看 JVM 内存日志改为流式读取批量下载任务卡住线程池任务阻塞查看线程 dump检查下载服务是否抛出异常未捕获下载 URL 被第三方刷流量未做鉴权和签名查看访问日志 IP 热度增加签名校验和 IP 限流浏览器直接访问下载 URL 报 403Referer 校验过严检查 Nginx 配置放开自己域名或改为签名校验其中Uniapp 小程序端的下载域名问题非常典型。小程序里发起uni.downloadFile下载域名必须在后台配置为合法域名而且必须 HTTPS。如果只是本地测试可以在开发者工具里打开“不校验合法域名”选项。但是真机预览和发布版必须配置合法域名。这个域名配置问题可以直接让下载功能完全不可用排查优先级最高。另外超过 100MB 的大视频在部分小程序端会有限制。微信小程序的下载逻辑支持大文件但是否能保存到相册取决于客户端版本。如果业务场景以大文件为主建议不要直接保存到相册而是引导用户通过浏览器打开下载页或者由后端生成完整文件后走应用内文件管理。12. 最佳实践与使用建议下载接口是功能简单但边界复杂的一类接口。整理几个工程化建议。12.1 第一次先小文件测试先用一个 10MB 左右的 MP4 文件测试完整链路不要一上来就下载几个 GB 的视频。小文件可以快速验证接口逻辑、响应头、小程序授权流程。任何环节出问题定位成本都很低。12.2 保留一套最小可运行配置把下载接口涉及的配置集中管理视频存储根目录签名秘钥下载 token 有效期单用户下载限速批量下载线程池大小批量任务失败重试次数。这样新环境部署或者测试环境切换时只需要改配置文件不用改代码。12.3 分清下载与分发本地文件下载适合小规模场景。当视频量增长到一定规模后建议把视频文件迁移到对象存储或 CDN应用层只负责“生成临时签名地址”而不是把文件流经应用服务器中转。这样应用服务器压力大大降低带宽成本也更可控。12.4 下载日志与监控每次下载都要记录日志包括用户 ID、videoId、文件大小、下载耗时、下载结果。后续如果出现滥用或版权纠纷日志可以做审计。监控方面重点看下载失败率、服务器出网带宽、磁盘剩余空间这 3 个指标。12.5 合规与授权边界下载接口本质上是一种复制能力。在版权敏感的场景下只给必要的用户开放下载权限检测到异常批量下载时及时封禁账号或要求人工审核。涉及人脸肖像、他人原创内容、付费资源时下载能力默认关闭或需要用户二次授权。这些不是代码能力但一旦越界整个项目的风险会远超技术问题。13. 总结与下一步本讲完成了智能视频推荐小程序中下载接口的完整链路后端流式文件下载、Range 断点续传、token 临时链接校验、Uniapp 端下载与保存、批量下载任务队列、性能观察与排查方法。建议先验证下面这三个功能单个视频下载是否能正常返回文件流token 过期之后接口是否正确拒绝访问Uniapp 端下载保存到相册的授权流程。下一步可以继续扩展的方向是把下载能力接入 LangChain4j 推荐结果中比如推荐接口直接返回downloadToken用户无需二次点击即可开始下载或者在批量下载任务完成之后用 SpringAI 自动生成“已缓存视频清单”的摘要推送给用户。这些都属于前面几讲搭好的 AI 服务框架上直接加业务逻辑不需要额外引入新的中间件。下载接口做扎实以后整个智能视频推荐小程序的用户闭环就完整了推荐、搜索、播放、收藏、下载。建议收藏备用尤其在调试小程序下载域名授权和 Range 断点续传时可以直接对照排查清单定位问题。