做视频推荐类小程序功能开发到“能推荐”只是完成了三分之一。推荐列表返回给用户之后用户感兴趣、点击查看详情、真正把视频下载到本地这才算一个完整的闭环。前面几讲我们解决了推荐结果怎么来、接口怎么返回、前端怎么展示但大多数实战项目卡在一个很现实的点上视频文件怎么安全、稳定地交付给用户。这一讲我们专门拆解下载接口。很多同学会觉得下载接口太简单无非是InputStream加ResponseEntity把文件二进制流吐出去。实际上一个能上生产的下载接口要同时解决文件定位、鉴权、Range 断点续传、内存控制、前端配合五个问题。尤其在小程序场景下uni.downloadFile和浏览器里的window.location.href行为完全不同后端接口设计稍不注意前端就会拿到一个打不开的文件。我的判断是下载接口是整个推荐小程序里最容易翻车的环节值得单独拿出一讲来系统梳理。本文会先讲清楚下载接口在推荐系统里的位置再给出完整的 SpringBoot 实现、AI 能力加持的推荐语生成、UniApp 前端配合代码最后回到常见问题和生产环境最佳实践。读完这篇文章你能掌握一个可落地的视频下载接口完整链路数据库表设计、文件存储配置、流式下载实现、下载鉴权、前端保存以及下载行为如何反哺推荐系统。1. 下载接口在智能视频推荐小程序中的定位先说一个容易被忽略的事实推荐的终点不是展示是用户消费内容。用户看到推荐卡片点击观看或者下载推荐系统才算完成了一次有效触达。如果你只做了推荐列表用户对视频再感兴趣下载链路断了体验就是零。下载接口在这个项目中的定位有三个层面。第一它是推荐闭环的最后一公里。从 AI 推荐引擎产出候选集到用户真正拿到视频文件下载接口承担了内容交付的职责。推荐做得再准内容交付不了用户留存一定出问题。第二它是用户画像数据的重要来源。用户下载了什么视频、什么时候下载、下载后是否长时间保存这些都是高质量的行为信号。比起曝光、点击下载行为更能反映用户的真实兴趣。后续做协同过滤、用户偏好分析时download_record表就是金矿。第三它是后端工程能力的综合体现。下载接口看起来简单实际上串联了文件存储、IO 流管理、HTTP 协议、安全鉴权和前端生命周期管理。一个团队能不能把下载接口写成生产级代码基本能代表这个团队的后端基本功。这里有个常见误区很多人把下载接口理解为“把文件从磁盘读出来返给前端”。这个理解在文件很小、用户量很少的时候没错一旦视频文件上百 MB、用户并发上来就会出现内存溢出、下载超时、连接中断、文件损坏等问题。下载接口的核心不是“返回文件”而是“如何高效、安全、稳定地把文件从服务端传输到客户端”。理解了这层定位再来设计技术方案就有了主线围绕用户下载行为做记录围绕文件传输做性能优化围绕资源配置做安全控制。2. 技术方案选型直接流式返回还是先取下载地址在动手写代码之前先解决一个方案问题。小程序里让用户下载视频常见有两种实现方式。方案一后端接口直接返回文件流前端调用uni.downloadFileURL 直接指向 SpringBoot 的下载接口。接口内部读取文件、设置响应头、返回二进制流。优点是链路短实现简单缺点也明显下载地址暴露了后端文件路径和接口结构容易被别人拿着接口地址批量刷同时所有下载流量都经过应用服务器对后端带宽和 IO 压力很大视频文件大了之后应用服务器容易成为瓶颈。方案二先获取下载地址再异步下载后端提供一个生成下载链接的接口前端拿到一个带签名的一次性 URL再调用uni.downloadFile去下载。优点是可以控制下载链接的有效期、绑定用户身份、限制下载次数而且将来可以把生成的链接指向 CDN 或对象存储把流量压力从应用服务器上移走缺点是链路多了一层前后端要多写一个接口。对于智能视频推荐小程序这种场景我更推荐方案二。原因不只是安全更是出于架构演进考虑。项目初期文件放在本地磁盘方案二多写一个生成链接的接口代码量增加不多等到视频量大了把文件迁移到对象存储或 CDN 时方案一的改造工作量要比方案二大得多。本讲的时间线设定在“文件已经上传到本地服务器通过本地文件路径管理”的阶段所以我们做的下载链接就先指向本服务的下载接口。但要注意设计时保留好签名、时效、次数这些字段后续切换 CDN 时前端代码几乎不用改。3. 前置工作下载记录表与文件存储配置下载接口不是孤立的功能它需要数据库记录和文件存储配置打底。3.1 下载记录表设计先看 SQL。下载记录表的核心职责是记录“谁在什么时间下载了哪个视频”同时为后续推荐算法的行为反馈提供数据基础。-- 文件路径sql/download_record.sql CREATE TABLE download_record ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, user_id bigint(20) NOT NULL COMMENT 用户ID, video_id bigint(20) NOT NULL COMMENT 视频ID, recommend_id varchar(64) DEFAULT NULL COMMENT 推荐记录ID可追溯是哪次推荐命中, download_token varchar(128) NOT NULL COMMENT 下载令牌一次有效, file_path varchar(255) NOT NULL COMMENT 实际文件路径, file_size bigint(20) DEFAULT NULL COMMENT 文件大小(字节), status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1成功 2失败 3超时, expire_time datetime NOT NULL COMMENT 下载链接过期时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_video_id (video_id), KEY idx_token (download_token) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT视频下载记录表;这张表的设计有几个关键点。recommend_id字段是下载行为和推荐链路之间的桥梁。用户在推荐列表里点击了某个视频前端会把对应的推荐唯一标识传到下载接口写入这张表。后续做推荐效果分析时可以统计“某次推荐命中后带来了多少次下载”直接用于评估推荐算法的转化效果。download_token是一次性下载令牌下文会详细讲它的生成和校验逻辑。它的存在保证了下载地址不会被人恶意抓取后长期使用。status字段记录了下载结果但要注意服务端只能知道“文件流是否成功写出”无法准确知道客户端是否完整接收。所以状态为“成功”只代表服务端正常返回了数据流更精确的下载完成状态需要前端主动上报这点在最佳实践部分展开。3.2 视频文件存储配置本地存储方案下文件通常放在一个独立的目录不要在resources目录里设置静态资源映射——那样任何人都可以通过拼接路径直接访问文件。在application.yml中单独配置文件存储路径和下载相关参数# 文件路径src/main/resources/application.yml app: video: storage-path: /data/videos download-base-url: /api/video/download token-expire-minutes: 30 max-download-size: 512mb spring: servlet: multipart: max-file-size: 512mb max-request-size: 512mb说明几个配置项的含义storage-path视频文件在服务器上的根目录。子目录可以按日期或视频分类组织避免单个目录文件过多影响文件系统性能。token-expire-minutes下载令牌有效期。设置太短用户点开链接可能已经过期设置太长链接被盗用风险增加。一般 30 分钟比较合适。max-download-size限制单次下载的文件大小防止超大文件长时间占用服务器 IO。这里有一个很容易踩的坑有些教程会把视频目录直接配成 SpringBoot 静态资源路径例如spring.web.resources.static-locations/data/videos。这样做确实能通过浏览器直接访问但等于把你的视频库完全公开了任何知道文件名的人都可以下载毫无安全性可言。我们的下载接口必须走自己的鉴权和令牌逻辑绝不依赖静态资源映射。4. 核心实现SpringBoot 下载接口开发方案确定、表结构建好接着进入真正的编码环节。这一节我们分三步走先生成一次性下载令牌再实现核心下载接口最后封装文件流读取逻辑。4.1 下载令牌生成接口先写一个生成下载令牌的接口。前端在用户点击下载时先请求这个接口拿到带令牌的地址然后再去下载。// 文件路径src/main/java/com/example/video/controller/VideoDownloadController.java RestController RequestMapping(/api/video/download) public class VideoDownloadController { Resource private VideoDownloadService downloadService; /** * 获取下载地址 * 调用后会生成一个带令牌的一次性下载链接 */ PostMapping(/apply) public ResultDownloadApplyVO applyDownload(RequestBody DownloadApplyDTO dto) { DownloadApplyVO vo downloadService.createDownloadTask( dto.getVideoId(), dto.getRecommendId()); return Result.success(vo); } }对应的 Service 实现如下// 文件路径src/main/java/com/example/video/service/impl/VideoDownloadServiceImpl.java Service public class VideoDownloadServiceImpl implements VideoDownloadService { Value(${app.video.download-base-url}) private String downloadBaseUrl; Value(${app.video.token-expire-minutes}) private int tokenExpireMinutes; Resource private DownloadRecordMapper downloadRecordMapper; Resource private VideoInfoMapper videoInfoMapper; Override public DownloadApplyVO createDownloadTask(Long videoId, String recommendId) { // 1. 查询视频信息校验存在性 VideoInfo video videoInfoMapper.selectById(videoId); if (video null) { throw new BusinessException(ErrorCode.VIDEO_NOT_FOUND); } // 2. 生成一次性令牌这里使用UUID // 生产环境建议加一个时间戳用户ID的签名逻辑避免纯UUID被猜测后批量调用 String token UUID.randomUUID().toString().replace(-, ); String filePath video.getFilePath(); LocalDateTime expireTime LocalDateTime.now().plusMinutes(tokenExpireMinutes); // 3. 写入下载记录表 DownloadRecord record new DownloadRecord(); record.setUserId(CurrentUser.getUserId()); record.setVideoId(videoId); record.setRecommendId(recommendId); record.setDownloadToken(token); record.setFilePath(filePath); record.setFileSize(video.getFileSize()); record.setExpireTime(expireTime); downloadRecordMapper.insert(record); // 4. 返回完整下载地址 DownloadApplyVO vo new DownloadApplyVO(); vo.setDownloadUrl(downloadBaseUrl /stream?token token); vo.setExpireTime(expireTime); return vo; } }这个接口的核心价值是“先登记、后下载”。每一次下载任务在数据库中都有记录令牌一次有效过期时间可控后续审计和用户行为分析都有据可查。注意代码里的CurrentUser.getUserId()是伪代码实际项目里从登录态中获取。当前接口必须要求用户登录不能让匿名用户无限生成下载链接。4.2 核心流式下载接口下载核心接口的重点支持文件流输出和 Range 断点续传并对内存友好。// 文件路径src/main/java/com/example/video/controller/VideoDownloadController.java /** * 流式下载视频 * 支持 Range 请求头实现断点续传 */ GetMapping(/stream) public ResponseEntityResource streamDownload( RequestParam String token, RequestHeader(value HttpHeaders.RANGE, required false) String range) throws IOException { DownloadRecord record downloadService.validateAndGetRecord(token); if (record null || record.getExpireTime().isBefore(LocalDateTime.now())) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } File file new File(record.getFilePath()); if (!file.exists()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } long fileLength file.length(); long start 0; long end fileLength - 1; // 解析 Range: bytesstart-end if (range ! null range.startsWith(bytes)) { String[] rangeValues range.substring(bytes.length()).split(-); try { if (!rangeValues[0].isEmpty()) { start Long.parseLong(rangeValues[0]); } if (rangeValues.length 1 !rangeValues[1].isEmpty()) { end Long.parseLong(rangeValues[1]); } } catch (NumberFormatException e) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE).build(); } if (start end || start fileLength) { return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE) .header(HttpHeaders.CONTENT_RANGE, bytes */ fileLength) .build(); } end Math.min(end, fileLength - 1); long contentLength end - start 1; InputStream inputStream new FileInputStream(file); inputStream.skip(start); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .header(HttpHeaders.CONTENT_RANGE, bytes start - end / fileLength) .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentLength(contentLength) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new InputStreamResource(inputStream) { Override public long contentLength() { return contentLength; } }); } // 不支持Range时的全量下载 InputStream inputStream new FileInputStream(file); return ResponseEntity.ok() .header(HttpHeaders.ACCEPT_RANGES, bytes) .contentLength(fileLength) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(new InputStreamResource(inputStream) { Override public long contentLength() { return fileLength; } }); }这段代码的要点有三个。第一通过InputStreamResource包装文件流而不是把整个文件读成byte[]。这样数据是分批从磁盘读到内存再写出去单个文件即使有几百 MBJVM 里也不会出现超大对象。第二解析Range请求头返回206 Partial Content。用户在弱网环境下下载中断后客户端会带着上次的断点继续请求服务端从指定位置继续输送数据。没有这个能力前端断点续传和播放器拖动进度条都会失效。第三所有响应都带Accept-Ranges: bytes告诉客户端服务端支持范围请求。这个响应头是小程序播放器能否拖动进度条、下载工具能否做断点续传的关键。4.3 Token 校验逻辑validateAndGetRecord方法负责校验 token 是否有效并且在下载成功后把 token 置为已使用。核心逻辑如下// 文件路径src/main/java/com/example/video/service/impl/VideoDownloadServiceImpl.java Override public DownloadRecord validateAndGetRecord(String token) { if (StringUtils.isBlank(token)) { return null; } // 注意这里查询时可以加一个 userId 条件防止用户 A 用 token 下载用户 B 的内容 DownloadRecord record downloadRecordMapper.selectByTokenAndUserId( token, CurrentUser.getUserId()); if (record null) { return null; } // 判断是否过期 if (record.getExpireTime().isBefore(LocalDateTime.now())) { return null; } // 判断是否已经使用过 if (record.getStatus() 3) { return null; } // 标记为已使用。这里先标记再下载是稳妥做法 // 防止并发下同一个 token 被同时下载多次 downloadRecordMapper.updateStatusById(record.getId(), 3); return record; }这里有一个安全层面的细节校验 token 时要同时匹配当前登录用户 ID。如果只校验 token 本身的合法性攻击者拿到别人的下载链接之后只要 token 未过期就能直接下载。绑定用户 ID 之后越权下载的门槛提高了很多。下载完成、失败、超时的状态流转也需要设计token 使用后置为已使用状态 3如果文件不存在或者 IO 异常可以置为失败状态 2。状态是后续排查问题的关键线索。5. 让下载接口具备 AI 能力融合 SpringAI 与 LangChain4j 生成推荐语标题里提到了 LangChain4j 和 SpringAI这两者在推荐链路里各有分工推荐列表的候选集生成可以用 LangChain4j 做结构化输出而下载这个环节同样可以引入 AI 能力让下载不只是拿到一个文件还附带一段有上下文信息的推荐语。5.1 为什么下载环节也需要 AI用户在推荐列表点击下载通常意味着对内容产生了兴趣。这时候只给他一个文件缺失的是“为什么推荐给他”的上下文。如果下载完成后用户能在本地看到“因为你看过 XX 类视频推荐你观看这部”体验会有明显提升。更深一层下载日志里记录推荐理由文本后续做推荐效果归因时可以分析“带推荐语的下载转化率”和“不带推荐语的转化率”之间的差异。这是很多推荐系统忽略的细节。5.2 集成 SpringAI ChatClient 生成推荐语在 SpringBoot 项目中引入 SpringAI 后可以很方便地调用大模型接口。下面这个示例展示了如何在用户下载视频时根据视频元数据和用户偏好生成一句推荐语。// 文件路径src/main/java/com/example/video/service/ai/RecommendationTextService.java Service public class RecommendationTextService { private final ChatClient chatClient; public RecommendationTextService(ChatClient.Builder builder) { this.chatClient builder.build(); } /** * 根据视频信息生成下载推荐语 * prompt 中明确要求输出长度和格式避免返回过长文本 */ public String generateDownloadRemark(VideoInfo video, UserProfile profile) { String prompt 你是一名专业的视频推荐编辑。 用户最近关注的视频类型是%s。 现在系统要向用户推荐这部视频《%s》。 视频简介%s。 请生成一句不超过40字的推荐语直接输出推荐语内容不要有前缀和引用。 .formatted( profile.getInterestTags(), video.getTitle(), video.getDescription()); return chatClient.prompt() .user(prompt) .call() .content(); } }使用方式是在创建下载任务时调用String remark recommendationTextService.generateDownloadRemark(video, userProfile); record.setDownloadRemark(remark);前端拿到下载地址的同时能把推荐语一并展示出来。5.3 LangChain4j 的用途推荐结果的溯源与结构化LangChain4j 在这个项目中更适合做“推荐结果的结构化解析”。推荐引擎输出的原始结果往往是非结构化的自然语言比如“根据你的兴趣推荐以下3部视频A、B、C”。LangChain4j 可以定义输出格式把自然语言结果转成结构化对象。在下载链路中LangChain4j 的典型应用是当用户从推荐列表进入下载页时系统需要把这次的推荐上下文完整记录下来。LangChain4j 负责解析推荐结果里的视频 ID 列表和推荐原因字段传给下载接口确保下载记录能关联到正确的推荐 ID。// 文件路径src/main/java/com/example/video/service/ai/RecommendationParser.java public record VideoRecommendation(Long videoId, String reason) {} // 通过 LangChain4j 的 AiServices 绑定接口 public interface RecommendationExtractor { Description(从推荐结果文本中解析视频ID列表和推荐理由) String extract(String rawText); }实际项目中推荐结果是否走大模型取决于具体架构。如果推荐结果本来就从数据库结构化产生那 LangChain4j 的解析就不必引入。需要做的判断是你的推荐链路是否包含“自然语言生成结果”这一步。有则 LangChain4j 有价值没有则不必为了用而用。这一讲的重点仍然是下载接口本身。AI 部分是整个推荐系统价值的放大器但前提是下载链路本身稳定可靠。如果下载接口三天两头超时推荐语写得再好也无济于事。6. UniApp 前端下载与保存实现后端把下载地址给出来了前端如何把文件保存到手机相册或者本地目录这里要用到uni.downloadFile和uni.saveFile。6.1 前端先申请下载地址再下载推荐列表页或者视频详情页用户点击下载按钮后前端流程分两步先请求后端申请下载地址再调用下载 API 保存文件。// 文件路径pages/video/detail.js export default { data() { return { videoId: 0, recommendId: , downloadUrl: , downloading: false } }, methods: { // 点击下载按钮 async handleDownload() { if (this.downloading) { return } this.downloading true uni.showLoading({ title: 准备下载... }) try { // 第一步获取带令牌的下载地址 const res await this.$request({ url: /api/video/download/apply, method: POST, data: { videoId: this.videoId, recommendId: this.recommendId } }) if (res.code 200) { const downloadUrl res.data.downloadUrl this.startDownload(downloadUrl) } else { uni.showToast({ title: 获取下载地址失败, icon: none }) } } catch (e) { uni.showToast({ title: 网络异常, icon: none }) } finally { uni.hideLoading() this.downloading false } }, // 第二步执行下载 startDownload(downloadUrl) { uni.showLoading({ title: 下载中... }) uni.downloadFile({ url: downloadUrl, success: (result) { if (result.statusCode ! 200) { uni.showToast({ title: 下载失败, icon: none }) return } this.saveVideo(result.tempFilePath) }, fail: () { uni.showToast({ title: 下载失败, icon: none }) }, complete: () { uni.hideLoading() } }) }, // 保存到本地 saveVideo(tempFilePath) { uni.saveFile({ tempFilePath: tempFilePath, success: (res) { uni.showToast({ title: 已保存到本地, icon: success }) console.log(saved file path:, res.savedFilePath) }, fail: () { // 某些平台不支持 saveFile降级提示用户 uni.showToast({ title: 文件已下载请到下载目录查看, icon: none }) } }) } } }6.2 小程序平台差异注意事项不同小程序的下载行为差异比较大这里总结几个实际项目中容易踩的坑。第一个是合法域名问题。微信小程序里uni.downloadFile的域名必须配置在后台的 downloadFile 合法域名列表中否则会直接报错。开发时可以勾选“不校验合法域名”临时调试上线前必须配置好。第二个是文件路径问题。uni.saveFile在小程序端保存的是本地缓存目录用户不能直接在手机相册里看到。如果需要保存到相册视频类内容通常不是直接调用saveFile完成而是要结合各平台提供的相册接口或者引导用户通过其他方式保存。第三个是uni.downloadFile的success回调里要判断statusCode。200和206都算成功但如果接口返回了401或404文件流可能是一段错误信息的文本直接保存会得到一个打不开的文件。必须判断状态码后再保存。第四个是超时问题。视频文件较大时默认的超时时间可能不够需要在uni.downloadFile里显式设置timeout并且在超时失败后给用户一个清晰的提示。7. 下载接口常见问题与排查方法下载接口上线后遇到的问题往往比较集中这里整理一份高频问题排查清单。问题现象可能原因排查方式解决方案下载地址请求返回 401token 过期、用户 ID 不匹配检查 download_record 表中 token 的 expire_time 和 user_id重新申请下载地址确认当前登录用户与申请者一致小程序端下载报 url not in domain list下载域名未配置合法域名微信公众平台后台检查 downloadFile 合法域名配置域名并重新上传下载到本地后文件打不开前端未判断 statusCode 就保存了 JSON 错误信息查看下载文件大小检查是否远小于正常大小在前端 success 回调中先判断 statusCode大文件下载导致内存溢出后端把文件一次性读入 byte[]查看 JVM 内存日志确认下载接口占用内存异常改用 InputStreamResource 流式返回下载中断后无法续传接口未支持 Range 请求头检查响应头是否有 Accept-Ranges: bytes解析 Range 头并返回 206 Partial Content视频文件中文名乱码或无法保存Content-Disposition 文件名编码错误检查响应头中的 filename* 编码格式使用 RFC 5987 编码格式如 filename*UTF-8%E6...浏览器下载正常小程序下载失败小程序对重定向和响应体大小有限制抓包查看下载接口完整响应确保下载接口返回流不返回重定向链接排查下载问题时建议从三个层面入手先看响应状态码和响应头确认服务端是否按预期返回再看下载文件的大小和源文件比对是否一致最后看前端成功回调和临时目录中的文件内容判断是否保存到了错误的内容。8. 下载功能的最佳实践与生产环境建议基于前面完整的实现这一节把真正影响下载功能质量的实践要点总结出来。8.1 安全与权限下载接口必须登录后才能申请下载地址令牌生成时绑定用户 ID校验时同时匹配 token 和用户 ID。生产环境建议再加两个维度一是 IP 限流同一用户短时间内申请下载地址的次数要限制防止接口被刷二是令牌签名用 HMAC-SHA256 对用户 ID、视频 ID、过期时间做签名而不是单纯使用 UUID这样即使数据库泄露攻击者也无法轻易构造合法令牌。8.2 传输性能视频文件的传输瓶颈通常不在 CPU而在网络带宽和磁盘 IO。能走 CDN 的视频就不要让应用服务器直接扛流量。在没有条件上 CDN 的阶段至少要保证两点一是下载接口使用独立的 Servlet 线程池或异步响应避免大量下载请求占满 Tomcat 线程影响其他接口二是应用服务器和文件存储不要跨机房部署内网读取文件的速度和公网相差两个数量级。8.3 行为反馈闭环下载记录表的价值不只在排查问题更是推荐系统的数据燃料。建议在下载记录表中增加download_remark推荐语和device_platform设备平台字段把推荐理由、设备信息一并记录下来。后续做推荐效果分析时可以按推荐 ID 聚合统计某一次推荐带来了多少次下载、多少用户下载了多个视频。把这些数据回写用户画像下次推荐时能明显提升命中率。8.4 文件存储演进本地磁盘存储是项目初期的可行方案但它有两个天花板容量有限、故障恢复困难。视频文件增长到一定规模后建议按这个顺序演进第一步把文件路径抽象成存储服务接口。Controller 层只依赖VideoStorageService不直接操作File对象。第二步接入对象存储。上传时写入对象存储下载时由对象存储直接返回文件流或生成预签名 URL。第三步在前端下载 URL 增加 CDN 域名对象存储负责回源。演进过程中下载记录表基本不用改前端代码也只需要换一个 URL。8.5 日志与监控下载接口的日志至少要记录这几个字段:用户 ID、视频 ID、token、文件大小、耗时、状态码。引入链路追踪后把下载请求的 traceId 写入日志排查问题时能快速定位是应用层超时还是网络层中断。值得注意的是下载接口的耗时指标不应当被用来衡量接口性能文件大小不同耗时差异很大。更有效的监控指标是下载成功状态码比例、平均传输速率、断点续传请求占比。9. 总结与下一步实践建议第十一讲的核心内容到这就完整了。我们做完了三件事设计了带推荐溯源的下载记录表实现了支持 Range 断点续传的流式下载接口打通了 UniApp 从申请下载地址到保存文件的完整链路。同时把 SpringAI 和 LangChain4j 的能力融入下载场景让下载不再只是“拉文件”而是记录行为、附带推荐语、反馈画像的闭环环节。回过头来这一讲最重要的工程判断有两点第一下载地址要先申请后使用不能直接把文件路径暴露给前端。一次性令牌加用户绑定的组合是投入产出比最高的安全方案。第二流式传输是视频下载的底线。InputStreamResource比byte[]更适合大文件场景Range支持决定了小程序端能不能做断点续传。这两点做对了下载接口才有资格谈生产环境。下一讲可以继续深入推荐链路的下游下载行为数据如何进入用户画像SpringAI 生成的推荐语如何参与召回排序。也可以去完善播放器对接视频详情页直接内嵌播放器播放行为和下载行为互为补充形成更完整的用户行为链路。建议你把本讲的代码在本地跑通一遍用一个小视频文件做测试抓包观察两次下载请求一次不带 Range 头一次带Range: bytes0-99对比状态码和响应体长度的区别。这一步做完你对下载接口的理解会比读十遍文章都扎实。