简介:这是一个面向高校计算机专业毕业设计及课程设计的在线视频点播系统项目,采用Java+SSM+Vue+MySQL技术栈,包含完整的前后端代码与数据库脚本;项目已获导师指导并以高分通过,功能完善、界面美观、操作简便、后台管理便捷,具备较强的工程参考价值。资源包共1414个文件,大小48.04MB,除Java源码和JSP动态页面外,还包含大量CSS、JavaScript等前端资源、图片素材、SQL数据库脚本、论文Word文档及相关环境配置工具,可按目录快速定位源码、数据库、论文和部署工具。项目无需修改即可导入IDEA并配合Maven、Tomcat、Navicat等工具运行,适合作为毕业设计、课程设计或期末大作业的参考底稿,也是一份可直接演示、二次开发的完整工程。已有44人学习下载,推荐给具备JavaWeb基础、需要可运行系统参考的计算机专业学生。

1. 基于 SSM+Vue+MySQL 的视频点播系统,毕业设计里最容易翻车的核心链路

如果一个毕设项目标称是“在线视频点播系统”,评审现场最容易翻车的往往不是登录和增删改查,而是视频文件传上去之后根本播不出来,或者播到一半就卡死。点播系统和普通后台管理系统最大的区别在于,视频是“大文件 + 持续流式读取”,而 SSM 和 MySQL 本身并不擅长直接处理文件流。整套系统真正的工作量集中在两条链路上:一条是上传端,从浏览器分片上传到服务端合并,再调用 FFmpeg 转码;另一条是播放端,从 MySQL 查出视频地址,映射成 m3u8 格式,再由 Vue 里的播放器拉流。这套 java+ssm+vue+mysql 的在线视频点播系统,适合毕业设计需要完整可运行代码、想展示全链路能力的人,也适合工作后接手老项目、需要快速补课的一线工程师。下面按我平时落地这类项目的习惯,把架构、表、接口、前端播放和部署验证全部过一遍。

2. SSM+Vue 在线视频点播的系统骨架:架构分层与 MySQL 表设计

2.1 系统分层:SSM 的 Controller-Service-Dao 不该只在静态图里

先明确系统边界。前端 Vue 管交互和播放,后端 SSM 只负责三类事:登录鉴权、视频元数据管理、上传和转码调度。三层的价值在于,当视频文件由前端分片提交时,Controller 只接参数,Service 负责归档文件和更新状态,Dao 只做 SQL,任何一层出问题都能从日志定位。很多代码课设把文件合并逻辑直接写在 Controller 里,功能能通,但后来想加“转码中”状态或重试机制,必须把整个方法重写。

分层规划时我一般约定请求路径前缀, /api/video/** 归视频模块, /api/user/** 归用户模块,前端通过 axios 统一走 /api 。下面这张表是每个模块里最容易被忽略的职责边界:

层 典型对象 该做的事 不该做的事
Controller VideoController/UserController 参数校验、统一返回 JSON、异常捕获 写文件、拼 SQL、调 ffmpeg
Service VideoService 接口实现 上传事务、转码状态机、播放量更新 直接操作 HttpSession
Dao/Mapper VideoInfoMapper 单表 CRUD、状态更新 SQL 拼接动态条件以外的业务判断

在技术选型上,SSM 与 Spring Boot 相比确实多了大量 XML 配置,但对于毕设场景反而是加分项,因为论文里能把 bean 装配、MyBatis 映射、事务传播机制写得更有内容。实际落地时只需要保证 Spring 核心、SpringMVC、MyBatis 三个框架的版本兼容,同时把 mapper 扫描配置写进 spring 配置文件即可,前端不用关心后端用什么容器配置。

2.2 核心表设计:video_info、video_history 和分类表的 MySQL 建表语句

在线视频点播至少需要四张表:用户表、视频分类表、视频信息表、播放记录表。最容易设计错的是视频信息表,很多人会把视频文件直接存成 BLOB,结果数据库膨胀到几个 G,备份一次要半天,播放时还要先读出字节再写临时文件。正确做法是 MySQL 只存文件路径和转码后的 HLS 地址,真实文件放在服务器指定的磁盘目录,比如 /data/videos 和 /data/hls 。这样 MySQL 始终保持轻量,转码线程才能以文件方式访问。

video_info 表的核心建表语句如下:

CREATE TABLE video_info (
  id           BIGINT       NOT NULL AUTO_INCREMENT COMMENT '主键',
  title        VARCHAR(128) NOT NULL COMMENT '视频标题',
  cover_url    VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
  category_id  BIGINT       NOT NULL COMMENT '所属分类ID',
  src_url      VARCHAR(255) DEFAULT '' COMMENT '原始文件磁盘路径',
  hls_url      VARCHAR(255) DEFAULT '' COMMENT 'm3u8播放相对路径',
  hls_file_url VARCHAR(255) DEFAULT '' COMMENT 'ts切片所在目录路径',
  duration     INT          DEFAULT 0 COMMENT '时长,单位秒',
  status       TINYINT      NOT NULL DEFAULT 0 COMMENT '0待处理 1转码中 2正常 3失败',
  view_count   INT          DEFAULT 0 COMMENT '播放次数',
  creator_id   BIGINT       NOT NULL COMMENT '上传用户ID',
  create_time  DATETIME     NOT NULL COMMENT '上传时间',
  update_time  DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (id),
  KEY idx_status (status),
  KEY idx_category_id (category_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频点播信息表';

这里的 status 字段是所有逻辑的抓手。前端页面显示“转码中”“播放”“失败”全靠它,后台定时任务也会扫描 status=1 的记录做失败重试。 hls_file_url 单独记录 ts 文件目录是为了后面用 Nginx 做静态资源映射时不用拼接路径。所有字符串字段都用了 utf8mb4 ,如果还用老的 utf8 ,标题里的 emoji 或者生僻字会直接报错或变成问号。

播放记录表的联合索引需要注意:

CREATE TABLE video_history (
  id          BIGINT NOT NULL AUTO_INCREMENT,
  user_id     BIGINT NOT NULL,
  video_id    BIGINT NOT NULL,
  progress    INT    DEFAULT 0 COMMENT '观看进度秒数',
  create_time DATETIME NOT NULL,
  PRIMARY KEY (id),
  KEY idx_user_video (user_id, video_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

联合索引 (user_id, video_id) 支撑“我最近看过哪些视频”这种查询,也避免同一个用户对同一视频插多条记录。写论文画 ER 图时,只需要把 video_info 作为中心,category_id 关联分类表,creator_id 关联用户表,history 表作为用户与视频的多对多桥表,整体关系非常清晰。

2.3 数据源配置、事务回滚与 MyBatis 映射细节

SSM 项目中数据源我优先用 Druid,不光是因为连接池监控方便,更因为迁移到新环境时只需要调整 jdbc.url 就能对接不同版本的 MySQL。核心配置如下:

jdbc.url=jdbc:mysql://localhost:3306/vod_db?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
jdbc.username=root
jdbc.password=yourpass

mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.example.vod.entity

spring.datasource.druid.initial-size=5
spring.datasource.druid.max-active=20
spring.datasource.druid.min-idle=2

rewriteBatchedStatements=true 对分片合并后批量更新视频状态很有用,能减少 SQL 网络往返次数。 serverTimezone=Asia/Shanghai 必须显式指定,否则 JDBC 驱动和 MySQL 的时区不规则生成 create_time 时会有 8 小时漂移,答辩时被问到时间不对很尴尬。

MyBatis 中插入视频并回填自增主键,是后面更新状态的前提:

<insert id="insertVideo" parameterType="VideoInfo" useGeneratedKeys="true" keyProperty="id">
  INSERT INTO video_info (title, category_id, src_url, status)
  VALUES (#{title}, #{categoryId}, #{srcUrl}, #{status})
</insert>

useGeneratedKeys="true" 会让 MySQL 自增的 id 直接赋给传入对象的 id 属性。上传接口收到分片合并完成后,必须依赖这个 id 去构建 hls 目录名,否则没法保证目录唯一。事务边界则放在 Service 层:插入 video_info 和创建物理目录是两件事,目录创建失败时至少先回滚数据库记录,避免出现“数据库有记录、磁盘没文件”的脏状态。

3. SSM 后端实现:分片上传、FFmpeg 转码与视频播放接口

3.1 分片上传 Controller 怎么接:五个参数缺一不可

视频文件不能像图片那样直接用 MultipartFile 提交。Tomcat 的 maxPostSize 默认只有 2MB,几百 MB 的视频传一次必然报错。常见做法是前端把文件按固定大小切成 5MB 一个 chunk,每个 chunk 附带编号和后端约定的 uploadId。后端按照编号把分片写入同一个目录,合并时按顺序读出来拼成完整文件即可。

后端接口定义如下:

@RestController
@RequestMapping("/api/video")
public class VideoUploadController {

    private static final String UPLOAD_DIR = "/data/vod_upload/";

    @PostMapping("/upload")
    public Result upload(@RequestParam("file") MultipartFile file,
                         @RequestParam("chunkNumber") int chunkNumber,
                         @RequestParam("totalChunks") int totalChunks,
                         @RequestParam("filename") String filename,
                         @RequestParam("ext") String ext) throws IOException {
        String uploadId = DigestUtils.md5DigestAsHex((filename + ext).getBytes());
        Path chunkDir = Paths.get(UPLOAD_DIR, uploadId);
        if (!Files.exists(chunkDir)) {
            Files.createDirectories(chunkDir);
        }
        file.transferTo(chunkDir.resolve(chunkNumber + ".part"));

        if (chunkNumber == totalChunks) {
            videoService.mergeChunks(uploadId, filename, ext);
            return Result.ok("all-done");
        }
        return Result.ok("chunk-received");
    }
}

分片接口的参数是整套上传链路的契约:

参数 发起方 说明
file 前端分片文件 每一片都传同一个文件小片段
chunkNumber 前端 从 1 开始,不能从 0 开始,否则合并排序容易写错
totalChunks 前端 由 file.size 除以分片大小后向上取整
filename 前端 原始文件名,不含扩展名
ext 前端 扩展名如 mp4 ,用于最后重命名

上传目录用文件名 MD5 作为子目录,能天然避免不同用户传同名文件目录冲突。 transferTo 是最容易抛 IO 异常的地方,如果 tomcat 用户对 /data/vod_upload 没有写权限,这里的报错不会告诉你是权限问题,只会显示 java.io.IOException ,所以创建目录后最好立刻检查可写性。分片全部到达后触发 mergeChunks ,这个接口如果返回值是 all-done ,前端就切换到轮询状态接口,否则继续传下一片。

3.2 合并分片并转码成 m3u8:为什么不能同步调用 ffmpeg

合并分片时最保险的做法是用 Files.list 按文件名排序后逐个字节复制,因为前面分片编号是 1.part 到 10.part ,按字符串排序会导致 10 排在 2 前面,所以必须用数值比较器。合并完成后把原始文件存到 /data/videos/ 下,并更新 video_info 的 status 为 1,表示进入转码队列。

转码操作不建议直接在 mergeChunks 里同步调用 ffmpeg,因为一次转码可能耗时几十秒甚至几分钟,HTTP 请求早就超时了。我用固定线程池做异步任务,合并完立刻返回前端,转码完成后回调更新数据库:

private final ExecutorService transcoder = Executors.newFixedThreadPool(
        Math.max(2, Runtime.getRuntime().availableProcessors() / 2));

@Transactional
public void mergeChunks(String uploadId, String filename, String ext) {
    // 合并逻辑:读取 uploadId 目录下的所有 .part 文件,复制到 targetPath
    Path targetPath = Paths.get("/data/videos", uploadId + "." + ext);
    if (!Files.exists(targetPath)) {
        Files.createFile(targetPath);
    }
    // video_id 是刚插入的 video_info 主键
    Long videoId = insertRecord(targetPath.toString());
    transcoder.submit(() -> transcodeToHls(videoId, targetPath.toString()));
}

private void transcodeToHls(Long videoId, String src) {
    String hlsDir = "/data/hls/" + videoId;
    Files.createDirectories(Paths.get(hlsDir));
    ProcessBuilder pb = new ProcessBuilder(
            "ffmpeg", "-y", "-i", src,
            "-profile:v", "baseline", "-level", "3.0",
            "-hls_time", "10", "-hls_list_size", "0",
            "/data/hls/" + videoId + "/index.m3u8");
    pb.redirectErrorStream(true);
    Process process = pb.start();
    process.waitFor();
    videoService.updateHlsUrl(videoId, "/hls/" + videoId + "/index.m3u8");
}

这段代码里没有处理失败重试,实际生产要在 process.waitFor() 后判断退出码,非 0 把 video_info 状态改成 3。FFmpeg 参数含义如下:

参数 作用
-profile:v baseline 使用 H.264 Baseline profile,兼容性和兼容性最好的编码级别
-hls_time 10 每个 ts 切片 10 秒,切片越长越省请求数,但拖动时间轴精度越低
-hls_list_size 0 让 m3u8 索引文件保留所有切片,不生成滚动列表

ProcessBuilder 比 Runtime.exec 更容易读取输出,且能准确传递参数数组,不会出现命令行参数引号转义陷阱。转码时用 videoId 当做目录名,文件名固定 index.m3u8,这样前端播放地址完全由后端拼接,不需要考虑原始文件名是否带中文或空格。

3.3 播放接口与静态资源映射:让视频能从磁盘流式读出

播放页面请求 GET /api/video/play/{videoId} 时,后端先查 status。只有 status 是 2 才返回 m3u8 地址,否则返回 202 和当前状态,让前端继续轮询。为了实现真正的流式播放,需要把 /data/hls/ 映射成 HTTP 静态资源。在 SpringMVC 配置里加一段资源映射:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/hls/**")
            .addResourceLocations("file:/data/hls/");
}

/hls/** 是前端播放器能直接请求的 URL 前缀, file:/data/hls/ 是磁盘目录绝对路径。这里不能写成相对路径,否则依赖进程启动时的工作目录,部署到 Linux systemd 环境经常找不到文件。播放接口同时要注意 Content-Type,部分播放器把 m3u8 文件按 text/plain 解析会报错,因此 @RequestMapping 上可以声明 produces 为 application/vnd.apple.mpegurl 。ts 切片则让浏览器按 video/mp2t 处理,这些都是隐性能提升稳定性但不会出现在题目中的细节。

4. Vue 前端联调:m3u8 播放器与 API 代理的关键点

4.1 开发环境的跨域代理与生产环境的地址收敛

Vue 开发时端口是 8080,SSM 后端可能跑在 8081,如果直接发 axios 请求,浏览器会拦截跨域响应。开发阶段最省事的是用 Vite 的 proxy 把 /api 转到后端,这样浏览器视角永远是同源:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8081',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
})

changeOrigin: true 会把请求头中的 Host 改写成 target 地址,防止后端容器校验 Host 时拒绝。 rewrite 会把 /api 去掉,因为后端 Controller 的 RequestMapping 里写的是 /video/upload ,而不是 /api/video/upload 。

生产环境中则不应该开着 Vite,而是把打包后的 dist 文件部署到 Nginx,让 Nginx 同时承担前端静态资源和后端 API 反向代理的责任。无论哪种环境,前端 axios 封装里的 baseURL 建议只写成相对路径,不写死 http://localhost:8081 ,否则换台机器部署就要改一堆代码。

4.2 用 video.js 接入 m3u8 与播放器参数配置

m3u8 不是普通视频文件,浏览器原生 video 标签并不会直接播放,需要借助 hls.js 或 video.js 把 m3u8 索引解析成视频流。Vue 项目里我用 video.js 加 videojs-contrib-hls,因为它的 API 简单,控制条样式完整,毕设论文也好写“基于 HLS 协议的播放器设计”这一节。

先安装依赖:

npm install video.js videojs-contrib-hls

组件内初始化代码:

<template>
  <div>
    <video ref="videoPlayer" class="video-js vjs-big-play-centered"></video>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import videojs from 'video.js'
import 'video.js/dist/video-js.css'

const videoPlayer = ref(null)
let player = null

onMounted(() => {
  player = videojs(videoPlayer.value, {
    controls: true,
    preload: 'auto',
    fluid: true,
    autoplay: false,
    muted: false,
    language: 'zh-CN'
  })
})

function playM3u8(url) {
  player.src({ src: url, type: 'application/x-mpegURL' })
  player.play()
}
</script>

这里的 fluid: true 让播放器宽度自适应容器高度,视频比例不固定时也不会出现黑边。 autoplay 在手机浏览器上经常被强制拦截,所以默认设为 false,用户点击播放后再调用 play() 。 preload: 'auto' 对点播系统来说会让播放器提前加载 m3u8 索引和首段切片,首屏时间更短,但如果是个超长列表页,建议改成 metadata 减少带宽消耗。

video.js 最常踩的坑是初始化时容器已经渲染但高度为 0,所以 onMounted 里必须确保外层元素已经有确定高度。另一个坑是切换视频时旧播放器没有销毁,多次调用 player.dispose() 后会出现内存泄漏。Vue Router 路由离开时需要在 onUnmounted 里执行 player.dispose() 。

4.3 轮询转码状态与播放错误处理

上传完成后,用户端不能只停留在“上传成功”。后端转码可能需要几秒到几十秒,前端需要轮询状态接口来切换按钮文案。常见做法是 setInterval 每 2 秒请求一次:

let pollTimer = null

async function waitForReady(videoId) {
  clearInterval(pollTimer)
  pollTimer = setInterval(async () => {
    const { data } = await videoApi.getVideoStatus(videoId)
    if (data.status === 2) {
      clearInterval(pollTimer)
      playUrl.value = data.hlsUrl
    } else if (data.status === 3) {
      clearInterval(pollTimer)
      errorMsg.value = '视频转码失败,请重新上传'
    }
  }, 2000)
}

onUnmounted(() => {
  clearInterval(pollTimer)
})

轮询最容易被忽略的是组件卸载后定时器还在跑,所以 onUnmounted 必须清理。如果视频量大,更专业方案是后端提供状态回调接口,转码完成后主动通知前端,但对毕设来说轮询足够。播放错误处理则要根据 video.js 的 player.on('error') 事件判断网络层问题,遇到 404、网络中断、hls 文件缺失时,给用户一个可读提示而不是控制台报错。

5. 部署与验证:让这套视频点播系统在服务器上跑稳的 3 个细节

5.1 细节一:Nginx 接管 m3u8 和 ts 的静态资源

转码后的 HLS 文件如果继续让 Tomcat 处理,并发读取几十个 ts 分片时会大量占用 Tomcat 线程,导致业务接口阻塞。部署时应该让 nginx 直接处理 /hls/ 路径,配置一次即可:

location /hls/ {
    alias /data/hls/;
    expires 1d;
    add_header Cache-Control "public";
    types {
        application/vnd.apple.mpegurl m3u8;
        video/mp2t ts;
    }
}

alias 会把 /hls/202/ 映射到 /data/hls/202/ ,注意需要确保末尾斜杠。如果后端接口返回的 hlsUrl 是 /hls/videoId/index.m3u8 ,那么播放器请求就会直接命中这个 location。 types 配好之后,响应头会自动带上正确的 Content-Type。

5.2 细节二:用 curl 验证上传到播放全链路

部署完成后,先不发前端,直接在服务器上验证三件事。第一步检查上传接口:

curl -X POST -F "file=@/tmp/livedemo.mp4" \
  -F "chunkNumber=1" -F "totalChunks=1" \
  -F "filename=livedemo" -F "ext=mp4" \
  http://localhost:8080/api/video/upload

如果返回 all-done ,说明上传链路正常。第二步查询状态:

curl http://localhost:8080/api/video/play/1

状态为 2 后,第三步带 Referer 拉取 m3u8:

curl -I -H "Referer: http://yourdomain.com" http://localhost/hls/1/index.m3u8

返回 200 且 Content-Type 为 application/vnd.apple.mpegurl ,就可以打开前端测试播放。这一步能帮你快速把问题定位在 Java 后端还是 Nginx 配置上。

5.3 细节三:MySQL 时区、连接池与磁盘权限的三个隐藏坑

新机器 mysql 默认字符集可能是 latin1 ,用中文标题插入直接变问号。部署后先执行 SHOW VARIABLES LIKE 'character_set_database'; ,如果不对就修改 my.cnf 并重启 MySQL。JDBC 连接串里的 serverTimezone 在 MySQL 5.7 和 8.0 之间不一致时,可能会出现 create_time 与实际时间相差 8 小时,时间类的坑最难排,因为接口表面没有报错。连接池更不能一味调大,Druid 配 max-active=20 、 validationQuery=SELECT 1 就足够几百人同时在线,否则 mysql 默认连接数被占满后反而会频繁报 Connection is not available 。这三个问题十有八九会出现在新环境部署的第一天,提前验证一遍能省出大量排错时间。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐