Java在线视频点播中的断点续播与进度记录实践

AI资讯1小时前发布 jdkjadmin
0 00
Java在线视频点播中的断点续播与进度记录实践

Java在线视频点播中的断点续播与进度记录实践

断点续播看起来只是“保存一个秒数,下次再跳到那里”,但真正上线时,至少涉及三个不同问题:播放器如何恢复播放位置、服务端怎样按用户和视频保存进度、视频文件如何支持拖动与分段传输。把它们混为一谈,容易出现进度丢失、用户之间串档,或者“进度能保存但拖动不了”的情况。

本文用一个 Spring Boot 示例实现用户级播放进度接口,并配合浏览器播放器代码演示保存与恢复。示例使用内存存储,便于运行和验证;生产环境应替换为数据库或其他持久化存储。视频文件的静态分发、鉴权和 HTTP Range 处理则单独说明,因为它们与进度记录不是同一项功能。

一、适用场景与数据模型

进度记录适合有账号体系、用户可能跨设备观看,或者需要继续学习、继续观看的点播业务,例如课程、培训视频和影视点播。若视频只在当前页面短暂播放,使用浏览器本地存储也许足够;但本地存储无法天然跨设备同步,也不适合承担可信的服务端学习记录。

最基本的进度记录通常包含:

  • 用户标识:由服务端身份认证上下文取得,而不是相信请求正文里的 userId。
  • 视频标识:明确对应哪一条视频资源。
  • 播放位置:以秒为单位的非负数,表示下次恢复时的候选位置。
  • 更新时间:用于展示最近观看时间,或处理并发写入。

总时长可以由客户端播放器报告,但不能把它当成可信数据。服务端可使用视频资产元数据校验播放位置,并计算完成状态。本文的简化接口不把总时长写入数据库,因此不会因客户端报错就永久污染视频时长。

二、先区分“记进度”和“视频可拖动”

进度接口只保存诸如 125.5 秒的位置;它并不负责返回视频字节,也不会让播放器自动支持任意位置播放。浏览器要从文件中间开始取数据,通常需要视频源支持 HTTP Range 请求:客户端发送类似 Range: bytes=... 的请求,服务器或对象存储返回 206 Partial Content 及相应的 Content-Range。如果媒体服务器不支持范围请求,播放器可能无法可靠地跳转,或者只能从头下载。

Range 的单位是字节,进度接口的单位通常是秒。两者不能直接互换:视频容器的索引、关键帧、编码码率等因素都会影响字节位置与时间位置的关系。一般由浏览器播放器与媒体服务器处理时间定位和字节请求。

生产环境常见方案包括:由支持 Range 的 Web 服务器提供 MP4 文件;使用对象存储或 CDN 的范围读取能力;或者采用 HLS、MPEG-DASH 等分片流媒体方案。应检查实际响应头和播放行为,而不是仅凭“服务器能返回文件”就认为支持拖动。

三、接口设计与边界约定

示例采用两个接口:

  • GET /api/videos/{videoId}/progress:读取当前登录用户的进度。
  • PUT /api/videos/{videoId}/progress:覆盖保存播放位置。

选择 PUT 是因为示例把请求理解为“将这条进度更新为指定位置”,重复提交相同位置不会产生额外副作用。请求体只接收播放位置,不接收用户 ID。接口还限制了位置必须是有限的非负数;生产环境应进一步根据服务端视频时长拒绝明显越界的位置,或按业务规则裁剪。

客户端的 video.duration 在媒体元数据尚未加载时可能是 NaN,直播或未知时长媒体还可能是 Infinity。不要把这些值当作有效时长。本文保存时只发送有限的播放位置,恢复时则等待元数据加载后再设置 currentTime。

四、可运行的 Spring Boot 示例

下面示例使用 Java 17、Spring Boot 3 和 Spring Security 的 HTTP Basic 认证。内存 Map 只用于演示接口行为:应用重启后数据会丢失,且多实例部署时各实例数据不共享。运行示例时,接口通过认证用户名区分用户;生产系统应接入实际的登录体系,并从服务端认证主体取得用户标识。

项目结构:

resume-demo/
  pom.xml
  src/main/java/com/example/resume/ResumeApplication.java

pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>resume-demo</artifactId>
  <version>1.0.0</version>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
    <relativePath/>
  </parent>
  <properties>
    <java.version>17</java.version>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugins>
    </plugins>
  </build>
</project>

ResumeApplication.java:

package com.example.resume;

import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@SpringBootApplication
@RestController
@RequestMapping("/api/videos")
public class ResumeApplication {
    private final ConcurrentMap<Key, Progress> store =
            new ConcurrentHashMap<>();

    public static void main(String[] args) {
        SpringApplication.run(ResumeApplication.class, args);
    }

    @GetMapping("/{videoId}/progress")
    public Progress getProgress(
            @PathVariable String videoId,
            java.security.Principal principal) {
        validateVideoId(videoId);
        Key key = new Key(principal.getName(), videoId);
        return store.getOrDefault(key, new Progress(0, null));
    }

    @PutMapping("/{videoId}/progress")
    public Progress saveProgress(
            @PathVariable String videoId,
            @RequestBody SaveRequest request,
            java.security.Principal principal) {
        validateVideoId(videoId);

        if (request == null || request.positionSeconds() == null) {
            throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, "positionSeconds is required");
        }
        double position = request.positionSeconds();
        if (!Double.isFinite(position) || position < 0) {
            throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, "positionSeconds must be finite and non-negative");
        }

        Key key = new Key(principal.getName(), videoId);
        Progress progress = new Progress(position, Instant.now());
        store.put(key, progress);
        return progress;
    }

    private static void validateVideoId(String videoId) {
        if (videoId == null || !videoId.matches("[A-Za-z0-9_-]{1,80}")) {
            throw new ResponseStatusException(
                    HttpStatus.BAD_REQUEST, "invalid videoId");
        }
    }

    public record SaveRequest(Double positionSeconds) {}
    public record Progress(double positionSeconds, Instant updatedAt) {}
    private record Key(String username, String videoId) {}

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .authorizeHttpRequests(auth -> auth
                        .anyRequest().authenticated())
                .httpBasic(Customizer.withDefaults())
                // 仅为便于演示用 curl 调用。浏览器生产环境不要随意关闭 CSRF 防护。
                .csrf(csrf -> csrf.disable())
                .build();
    }

    @Bean
    UserDetailsService users() {
        PasswordEncoder encoder =
                PasswordEncoderFactories.createDelegatingPasswordEncoder();
        return new InMemoryUserDetailsManager(
                User.withUsername("demo")
                        .password(encoder.encode("change-this-password"))
                        .roles("USER")
                        .build());
    }
}

启动应用:

mvn spring-boot:run

保存一条进度并读取:

curl -u demo:change-this-password \
  -H "Content-Type: application/json" \
  -X PUT \
  -d '{"positionSeconds":125.5}' \
  http://localhost:8080/api/videos/lesson-01/progress

curl -u demo:change-this-password \
  http://localhost:8080/api/videos/lesson-01/progress

示例用户名和密码仅用于本地验证,不能直接作为生产凭据。HTTP Basic 应通过 HTTPS 使用;生产环境不应把长期密码硬编码在代码或前端中。

五、浏览器端保存与恢复

下面代码假设页面中存在一个 <video> 元素,且该元素加载的视频与 videoId 对应。示例用同源接口和浏览器的 HTTP Basic 凭据进行演示;在真实产品中,应配合既有登录会话、令牌策略及相应的 CSRF 防护。

<video id="player" controls preload="metadata"
       src="/media/lesson-01.mp4"></video>

<script>
const videoId = "lesson-01";
const player = document.getElementById("player");
const endpoint =
  `/api/videos/${encodeURIComponent(videoId)}/progress`;

let lastSentAt = 0;
let lastSentPosition = -1;
let restored = false;
let saveInFlight = false;

async function loadProgress() {
  const response = await fetch(endpoint, {
    credentials: "same-origin"
  });
  if (!response.ok) {
    throw new Error(`读取进度失败:${response.status}`);
  }
  return response.json();
}

async function restoreProgress() {
  try {
    const progress = await loadProgress();
    player.addEventListener("loadedmetadata", () => {
      if (restored) return;
      restored = true;

      const duration = player.duration;
      const position = Number(progress.positionSeconds);

      if (!Number.isFinite(duration) ||
          !Number.isFinite(position) ||
          position <= 0) {
        return;
      }

      // 接近结尾时从头播放,避免恢复到最后几秒后体验不佳。
      const safeEnd = Math.max(0, duration - 2);
      player.currentTime = Math.min(position, safeEnd);
    }, { once: true });
  } catch (error) {
    console.error(error);
  }
}

async function saveProgress(force = false) {
  const position = player.currentTime;
  if (!Number.isFinite(position) || position < 0) return;
  if (saveInFlight) return;

  const now = Date.now();
  if (!force && now - lastSentAt < 10000) return;
  if (!force && Math.abs(position - lastSentPosition) < 3) return;

  lastSentAt = now;
  lastSentPosition = position;
  saveInFlight = true;

  try {
    const response = await fetch(endpoint, {
      method: "PUT",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ positionSeconds: position })
    });
    if (!response.ok) {
      throw new Error(`保存进度失败:${response.status}`);
    }
  } catch (error) {
    console.error(error);
  } finally {
    saveInFlight = false;
  }
}

player.addEventListener("loadedmetadata", restoreProgress, { once: true });
player.addEventListener("timeupdate", () => saveProgress(false));
player.addEventListener("pause", () => saveProgress(true));
player.addEventListener("ended", () => {
  // 已看完时可按产品约定保存为 0,或由后端另记完成状态。
  saveProgress(true);
});
window.addEventListener("pagehide", () => saveProgress(true));
</script>

播放器的 timeupdate 事件会频繁触发,不能每次都请求服务端。示例至少间隔 10 秒、且位置变化达到 3 秒才常规保存,并在暂停时尝试立即保存。间隔和阈值应按业务调整:课程需要更细的观看记录,影视点播通常不必每秒写一次。

页面关闭时的异步请求不保证一定完成。浏览器可能冻结或终止页面,因此 pagehide 只能作为补充,不能作为唯一保存时机。若使用 navigator.sendBeacon,需要注意它主要适用于小型 POST 请求,不能直接替代任意带 JSON 请求体的 PUT;跨域、凭据、CSRF 和服务端路由也都要单独验证。即便采用 beacon,也不应把它视为可靠事务机制。

六、生产环境需要补齐的服务端能力

1. 持久化与索引

示例中的 Map 会在进程退出后清空。关系型数据库可建立以 (user_id, video_id) 为唯一键的进度表,并为用户查询最近观看记录建立合适索引。写入时使用数据库的 upsert 或事务性更新,避免先查后插在并发请求下产生重复记录。

CREATE TABLE video_progress (
  user_id       VARCHAR(128) NOT NULL,
  video_id      VARCHAR(80)  NOT NULL,
  position_sec  DOUBLE PRECISION NOT NULL,
  updated_at    TIMESTAMP NOT NULL,
  PRIMARY KEY (user_id, video_id)
);

具体字段类型应依据数据库选择。若系统需要审计或学习合规记录,仅保留“最后位置”通常不够,还要设计不可变的播放事件或服务端学习记录,并明确哪些数据具有业务证明效力。

2. 防止旧请求覆盖新进度

播放器可能同时发出多个保存请求,网络延迟会导致较早的位置最后到达。示例按请求到达顺序覆盖,因而不保证跨请求顺序。生产系统可以增加客户端递增版本号、服务端更新时间条件,或在客户端串行化保存请求。单纯取较大的 position 也不是通用方案:用户主动回看时,位置变小是合理行为。

3. 合理校验位置

有限且非负只是基础校验。服务端掌握视频时长时,可按产品规则拒绝明显大于时长的位置,或者裁剪到合法范围。对未知时长、直播回放和可变媒体时长,应定义各自的语义。浮点秒数用于播放器定位通常足够;如果业务要求严格的课程章节或帧级定位,应采用明确的整数单位或结构化进度模型。

4. 认证、授权与隐私

进度属于用户数据。保存和读取都必须绑定已认证身份,并检查该用户是否有权访问对应视频。不能因为客户端传入了别人的 userId 就允许读写其进度;更不能把身份标识放在可任意修改的查询参数中并直接信任。

接口还应遵循最小暴露原则:校验视频 ID 格式、限制请求体大小、对异常请求限流,并避免在日志中记录密码、令牌或不必要的个人信息。上述正则只是示例格式约束,不是身份认证、访问授权或防注入的替代品。使用 JPA、参数化 SQL 等方式访问数据库,仍需正确处理权限和输入。

本文为方便 curl 演示关闭了 CSRF 防护。若生产系统使用 Cookie 会话认证,跨站请求伪造风险需要通过 Spring Security 的 CSRF 机制及前端令牌传递等方式处理;HTTPS 保护传输机密性,但不能替代 CSRF 防护。若采用无状态令牌认证,也应按实际认证架构配置,而不是照搬示例配置。

七、常见错误排查

  • 读取接口返回零,但视频能拖动。 媒体定位与进度保存是两条链路,检查进度接口是否成功写入、用户身份是否一致。
  • 设置 currentTime 报错或没有效果。 通常是元数据尚未加载、时长未知、媒体不可定位,或目标位置不在有效范围。等待 loadedmetadata 后再恢复,并检查媒体服务的 Range 支持。
  • 每次刷新都从头开始。 检查请求是否被认证拦截、视频 ID 是否变化、保存请求是否失败,以及读写是否落在同一存储中。
  • 一个账号的多个页面互相覆盖。 这是最后写入胜出策略的自然结果。若产品需要跨设备冲突提示或精确播放会话管理,应增加版本、设备或会话维度。
  • 进度偶尔少几秒。 浏览器关闭时最后一次请求可能未到达;缩短保存间隔能减少损失,但会增加请求量。可在暂停、切换视频、定期播放时保存,并结合可靠的前端重试策略。
  • 把客户端 duration 当成完成证明。 客户端上报的数据可被修改,不适合作为可信考核依据。需要完成判定时,应由服务端结合播放事件、业务规则和必要的反作弊策略处理。

八、实现时的检查清单

  • 身份来自认证主体,数据键至少包含用户和视频。
  • 播放位置做非负、有限值校验,并按服务端视频元数据处理越界值。
  • 前端等待媒体元数据后恢复,处理未知时长与接近结尾的情况。
  • 进度请求做节流或去抖,不把每次 timeupdate 都写入数据库。
  • 存储具备持久化和并发更新策略,多实例部署不依赖进程内 Map。
  • 视频源独立验证 Range、鉴权和 CDN 行为;秒数进度不是字节范围。
  • 生产环境启用 HTTPS,并根据认证方式正确配置 CSRF、授权、限流和日志脱敏。

可靠的断点续播不是单独的一次数据库写入,而是播放器事件、进度接口、用户身份、持久化策略和媒体定位能力之间的协作。把这些边界拆开设计,再依据产品的精度、并发和审计要求选择实现方式,通常比简单地“保存 currentTime”更容易得到稳定、可维护的结果。

© 版权声明

相关文章