
简介这是一套基于 Java、SSM、Vue 与 MySQL 构建的在线视频点播系统面向计算机相关专业学生适用于毕业设计、课程设计及期末大作业。项目已通过导师指导并获高分评价下载后无需修改即可运行使用。资源包共 1414 个文件包含 Java 源码、JSP 页面、JS/CSS 前端资源、图片、SQL 脚本以及论文文档其中 JS 与 CSS 负责页面交互与样式JSP 与 Java 实现后端业务逻辑SQL 为数据库初始化脚本压缩包整体约 48.04MB可在 IDEA、MySQL 8.0、Tomcat 7/8 与 Maven 环境中部署。目前已有 44 人学习/下载。除了完整可运行的系统源码和数据库脚本外还附带论文与常用工具能够帮助读者快速理解 SSM 框架的整合方式、Vue 前端与后端的数据交互以及 MySQL 表结构设计对于希望快速搭建高完成度点播系统、用于答辩展示或二次开发的学习者这套材料具有很高的实用价值。1. 基于javassmmysql的在线视频点播系统先搞清楚这套东西值不值得做看到“基于javassmvuemysql的在线视频点播系统”这个包名第一反应是典型的毕设全家桶后端用Java跑SSM框架前端用Vue做单页应用数据落在MySQL里再附带一份能交差的项目源码和论文。这类资源的价值不在“下载下来能跑”而在你能不能花两三天把它真正落地并借这个过程把 java、ssm、vue、mysql 这几个关键词连成一条完整的业务链路。它适合两种人一是JavaWeb课程设计需要交完整系统的学生二是刚学完SSM想补一个前后端分离项目的新手。这套系统要做的事说穿了就三件用户能登录、能看到视频列表、点开能播放后台能维护视频。围绕这三件事数据库至少要有用户表、视频表和播放记录表后端要拆出认证、列表、播放、记录四个模块前端要有一个像样的播放页。技术栈看起来偏老但SSM对理解Java基础帮助很大Vue现在仍是主流MySQL几乎每个后端面试都会问到拿它当毕设或简历项目的性价比不错。下面按我落地这类项目的顺序来写从建表到调接口再到播放器踩坑全程可以照着做。2. 在线视频点播系统的表设计用户、视频、播放记录三张核心表2.1 Vue前端的功能页面和路由参数怎么定先别急着写代码。一个点播系统前端页面不需要很多登录/注册页、首页视频列表、视频播放页、后台管理页。播放页必须知道“当前播的是哪个视频”这个信息用vue-router的动态路由来传最合理。路径写成/video/:id进入播放页后this.$route.params.id就能拿到视频主键再拿它去调后端接口。视频id不要放在 query 里因为分享链接时?id123可能被各种工具截断动态路由的语义也更清楚。前端目录我一般这样分views/Home.vue视频列表卡片布局点击跳播放页。views/VideoPlayer.vue播放器容器负责取路由参数、请求详情、初始化播放器。views/Admin.vue后台管理做视频上传和表单编辑。router/index.js路由注册。路由文件尽量用懒加载别把全部页面一次性打包进去。下面是简化版路由配置// router/index.js import Vue from vue import VueRouter from vue-router Vue.use(VueRouter) const routes [ { path: /, redirect: /home }, { path: /login, component: () import(/views/Login.vue) }, { path: /home, component: () import(/views/Home.vue) }, { path: /video/:id, name: VideoPlayer, component: () import(/views/VideoPlayer.vue) }, { path: /admin, component: () import(/views/Admin.vue) } ] export default new VueRouter({ mode: hash, // 用 hash 模式避免刷新时 404 routes })这里用mode: hash是关键。用 history 模式看起来更干净但生产环境如果用 Tomcat 或 Nginx 没做 fallback 配置刷新页面就会 404。很多第一次拿 Vue 和 Java 后端联调的人都会在这个点上翻车。毕设项目最省事的就是 hash 模式URL 里多个#不影响演示。路由配好之后播放页里这样读视频 id// views/VideoPlayer.vue export default { name: VideoPlayer, computed: { videoId() { return this.$route.params.id } }, created() { this.loadVideoDetail() }, methods: { async loadVideoDetail() { const res await this.$axios.get(/api/video/detail/${this.videoId}) this.video res.data.data } } }这里的$axios是封装好的 axios 实例。我习惯在main.js给 axios 加一个请求拦截器统一处理后端返回的code/message/data结构页面代码会清爽很多。如果不想封装得太复杂至少要做到所有请求走同一个 baseURL并且把 token 放进请求头。后面接口调不通时只需要在浏览器 Network 面板看请求头不用逐个页面去猜。2.2 SSM后端API接口划分Controller、Service、Mapper三层怎么拆后端是评分老师最看重的部分。SSM 三个字母放在一起实际上是一个分层约定Spring 管对象SpringMVC 管接口路由MyBatis 管数据库访问。在视频点播系统里最少需要下面这一组接口功能请求方式路径参数说明用户登录POST/api/auth/loginusername/password校验后返回用户信息和token视频列表GET/api/video/listpageNum/pageSize分页返回视频列表视频详情GET/api/video/detail/{id}路径参数返回标题、封面、播放地址保存播放记录POST/api/play/recorduserId/videoId/progress更新播放进度后台保存视频POST/api/admin/video/save表单字段管理员新增/编辑视频接口路径统一用/api开头是为了方便前端 devServer 做接口转发也为以后把服务拆出去留余地。Controller 里不要直接返回Map习惯性定义Result包装类包含code、message、data三个字段前端拦截器按code 0判断成功。这个约定前后端都省心。下面是一段典型的视频列表接口代码// VideoController.java RestController RequestMapping(/api/video) public class VideoController { Autowired private VideoService videoService; GetMapping(/list) public ResultPageInfoVideoVO list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { PageInfoVideoVO page videoService.pageVideos(pageNum, pageSize); return Result.ok(page); } GetMapping(/detail/{id}) public ResultVideoVO detail(PathVariable(id) Integer id) { return Result.ok(videoService.getDetail(id)); } }几个习惯能帮你少踩坑RequestParam一定要写defaultValue否则前端漏传一个参数整个接口直接 400返回前端前把数据库实体转成 VO不要把User、DO直接暴露分页对象用PageInfo它已经帮你算好 total、pages、size。Service 层尽量面向接口写。定义一个VideoService用VideoServiceImpl实现以后想加 Redis 缓存、改播放统计只需要动实现类不碰 Controller。这个习惯也常出现在 java 面试题里能说清楚“面向接口编程”到底解决了什么问题比单纯背概念有用得多。Mapper 层只负责 SQL。视频列表需要和分类表联查时写一个 XML 的select即可。注意 MyBatis 的驼峰映射数据库字段是cover_url实体里是coverUrl记得在application.properties里加上mybatis.configuration.map-underscore-to-camel-casetrue不加这一行联查结果里封面、创建时间这些字段全是null这类问题在控制台看到 SQL 都不容易发现。2.3 MySQL建表脚本视频表为什么只存URL不存二进制文件数据库部分别只建一张用户表交差至少要有用户表、视频表、播放记录表三张。视频文件不要塞进数据库只存 URL 地址。原因很简单BLOB 字段会把数据库文件撑得很大Tomcat 再把二进制流吐给浏览器内存和带宽都扛不住。生产环境视频文件一般放对象存储或 Nginx 静态目录数据库只存相对路径访问时拼成完整 URL。下面这套建表脚本可以作为起步-- 用户表 CREATE TABLE tb_user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(128) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 视频表 CREATE TABLE tb_video ( id INT NOT NULL AUTO_INCREMENT, title VARCHAR(200) NOT NULL, cover_url VARCHAR(500) DEFAULT NULL, video_url VARCHAR(500) NOT NULL, category_id INT DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_title (title), KEY idx_category_status (category_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 播放记录表 CREATE TABLE tb_play_record ( id BIGINT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, video_id INT NOT NULL, progress_seconds INT NOT NULL DEFAULT 0, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_video (user_id, video_id), KEY idx_video_update (video_id, update_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段细节有几个重点。VARCHAR长度标题 200URL 500基本够用URL 不要用TEXTTEXT 建索引有前缀长度限制也不方便做唯一约束。DATETIME用DEFAULT CURRENT_TIMESTAMP自动填充创建时间比在 Java 里new Date()再塞进去省事。status用TINYINT控制上架状态列表查询只 showstatus 1删除是改状态而不是物理删除这样播放记录和关联引用不会断。播放记录表是这套系统里最容易设计错的一张表。它记录的是“用户看到第几秒”不是交易流水所以同一个用户 同一部视频最多一行来了新进度就 update。建表时直接加UNIQUE KEY uk_user_video (user_id, video_id)这已经决定了后面去重的逻辑。idx_video_update是给“某个视频最近被谁看过”这类后台功能准备的。我一般不建物理外键。表之间的关联在 Service 层控制视频删除时先处理播放记录再改视频状态。外键在 MyBatis 批量操作时很容易变成性能瓶颈也会让导出数据、清空测试数据变得很麻烦。索引也不是越多越好像tb_play_record这种写多读少的表一个唯一索引加一个联合索引就够再多反而拖慢写入。注意数据库字符集必须用 utf8mb4不要用 utf8。utf8 在 MySQL 里存不了 emoji而且 cover_url 里一旦出现特殊字符容易报Incorrect string value。3. 让视频播放器真的能播m3u8、vue-video-player与跨域配置3.1 为什么选择 m3u8 而不是 mp4先把一个概念说清楚m3u8不是一个视频文件它是一份文本播放列表里面写了很多.ts小分片的地址。播放器拿到 m3u8 后跟服务端要一段缓冲一段边下边播。在线视频点播里m3u8 这种 HLS 格式比单文件 mp4 实用得多支持分片缓存支持码率切换也方便控制访问权限。如果直接对video标签塞一个 mp4本地演示也许能放但一旦放到服务器上带宽和启动耗时都会暴露问题。m3u8 的另一个理由是浏览器兼容性。Safari 原生 video 能直接播 HLSChrome 和 Edge 不支持原生 m3u8必须在播放器层面引入 HLS 处理逻辑。所以“vue播放m3u8播放器”这个诉求本质上绕不开给播放器加一层转码解析能力。毕设系统比较合适的做法是做一个“生成播放地址”的接口后端把 m3u8 地址返回给前端前端只负责播放。这样系统结构更像真实点播平台而不是一个简单的video静态页。3.2 搭一个挣得起 HLS 的 Vue 播放器组件依赖我用video.js7 及以上它默认内置videojs-http-streaming可以直接播 HLS如果只用hls.js自己驱动控制条、音轨切换都要自己写工作量不小。视频组件的核心代码如下template div classplayer-box video refvideoElement classvideo-js vjs-big-play-centered controls playsinline preloadmetadata /video /div /template script import videojs from video.js import video.js/dist/video-js.css export default { name: HlsPlayer, props: { src: { type: String, required: true } }, data() { return { player: null } }, mounted() { this.player videojs(this.$refs.videoElement, { autoplay: false, controls: true, sources: [ { src: this.src, type: application/x-mpegURL } ] }) this.player.on(error, (err) { console.error(player error:, err) }) }, beforeDestroy() { // 路由切换时一定要销毁否则会留下计时器和DOM节点 if (this.player) { this.player.dispose() this.player null } } } /script关键参数是type: application/x-mpegURL它告诉 video.js 这是 HLS 流不是普通 mp4内部才会走 HLS 解析器。autoplay: false是有意设置的现代浏览器对带声音自动播放有限制强制muted也影响体验不如让用户点一下。preloadmetadata只加载元数据不会把整个分片列表全部拉下来。playsinline保证 iPhone 上不会自动全屏。父页面接上视频地址后template div HlsPlayer v-ifvideo.videoUrl :srcvideo.videoUrl / /div /templatev-if很关键。如果后端接口还没返回地址就渲染播放器video.js 会初始化一个空 player之后更新的 src 未必生效。正确做法是先拿到videoUrl再挂载组件或者说用v-if控制子组件生命周期。3.3 跨域和静态资源转发播放器黑屏时的排查顺序开发环境里Vue 跑 8080后端 Tomcat 跑 8081前端请求/api/video/detail/1要能落到后端常见的做法是在vue.config.js配置接口转发// vue.config.js module.exports { devServer: { port: 8080, proxy: { // 所有 /api 开头的请求都转发到后端 /api: { target: http://localhost:8081, changeOrigin: true } } } }这段配置解决前端到后端的跨域。但注意它只转发了/api开头的请求。如果视频地址是http://localhost:8081/files/xxx.m3u8播放器直接拿这个地址去播请求不会经过/api跨域问题照样存在。解决视频资源跨域常见做法是后端映射一个/files/**静态资源路径让前端 devServer 也把它转回 8081。后端写法// WebConfig.java Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String location file:D:/video-repo/; registry.addResourceHandler(/files/**) .addResourceLocations(location); } Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(http://localhost:*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns是 Spring 5.3 之后的写法如果你的 SSM 还是 Spring 4就换成allowedOrigins把允许的源地址写全。遇到播放器黑屏不要马上怀疑组件。先打开浏览器 Network 面板看两类请求第一xxx.m3u8返回 200 还是 403第二列表里的.ts请求是否被 CORS 拦截。如果.ts请求在 Console 里报跨域就在后端给/files/**加Access-Control-Allow-Origin。如果 m3u8 能打开ts 一直 403多半是目录权限或鉴权接口问题。这个排查顺序能省下不少玄学调试时间。4. SSM接口层避坑分页、事务与MySQL连接池的5个血泪经验骨架搭好之后真正决定项目能不能演示的是接口层。这个章节写的是我在类似项目里碰到次数最多的几类问题每一条都是“现象 → 原因 → 解决”完整链路。4.1 分页接口翻车pageNum 从 0 还是 1 开始total 为什么对不上现象视频列表第一页数据不对前端传current1后端用 PageHelper 分页结果返回的列表像是第二页的内容但 total 又看着正常。原因PageHelper 的页码从 1 开始Element UI 的 Pagination 组件 current 也从 1 开始两边本来是对得上的。问题出在有人“好心”在 Service 里写了一句pageNum - 1理由是 MySQL LIMIT 从 0 开始。结果前端第一页实际上被减成了第 0 页数据错位。还有一个更隐蔽的问题PageHelper.startPage()和真正的 Mapper 查询之间不能插别的查询。看下面这段错误示例// 错误startPage 和 mapper 中间插了另一个查询 PageHelper.startPage(pageNum, pageSize); videoMapper.selectVideoById(1); // 这个查询会把分页吃掉 videoMapper.selectVideoWithCategory(); // 这里已经不分页了startPage设置的 ThreadLocal 状态会在下一次 MyBatis 查询时被消费。中间一旦插入别的 Mapper 查询分页就作用到错误的对象上列表不翻车才怪。正确写法是public PageInfoVideoVO pageVideos(Integer pageNum, Integer pageSize) { // startPage 后紧跟真正要分页的查询 PageHelper.startPage(pageNum, pageSize); ListVideoVO list videoMapper.selectVideoWithCategory(); return new PageInfo(list); }前端传什么后端就透传什么pageNum 默认值给 1。不要自己加偏移量也不要为了兼容 Element Pagination 做额外转换。分页逻辑的规则统一后前后端都轻松。4.2 Transactional 事务失效private方法、异常被catch、rollbackFor现象后台批量更新视频状态时主表改了关联的记录没改而且控制台没报错。原因Spring 事务基于 AOP 代理只有外部调用经过代理时才会生效。Transactional加在 private 方法上代理根本不会拦截加到 public 方法上但方法内部通过this.xxx()自调用也不会经过代理还有一种常见情况是方法里把所有异常都在 catch 里吞掉事务没有回滚机会。另外Spring 默认只对RuntimeException回滚对 checked exception 不回滚。如果你抛了一个Exception事务照样提交。用Transactional不加rollbackFor Exception.class等于给事务留了后门。正确的播放记录保存写法Service public class PlayRecordServiceImpl implements PlayRecordService { Autowired private PlayRecordMapper playRecordMapper; Autowired private UserService userService; Override Transactional(rollbackFor Exception.class) public void saveProgress(Integer userId, Integer videoId, Integer progressSeconds) { // 写入播放记录 playRecordMapper.upsertPlayRecord(userId, videoId, progressSeconds); // 假设还要更新用户连续观看天数这一步失败时整个事务一起回滚 userService.updateUserStat(userId); } }这里两个写操作必须在一个事务里。saveProgress方法是 public由 Controller 调用能正常触发代理。rollbackFor Exception.class保证任何异常都回滚。如果你看过一些 java 面试八股文会发现“Spring 事务失效场景”几乎都对应这几条项目中真正改一遍比背十遍强得多。如果事务加了但建表用的是 MyISAM 存储引擎事务也不会生效。InnoDB 才支持事务MyISAM 在并发插入时还会整表锁死毕设项目务必统一用 InnoDB。4.3 播放记录并发写入重复进度与锁等待超时现象用户拖进度条前端快速上报多次进度后端报Lock wait timeout exceeded; try restarting transaction或者记录表里同一个用户同一视频出现两行。原因应用层用“先 select再 insert再 update”的方式来写播放记录两个会话同时 select 都没找到记录都走 insert重复行就进来了。锁等待则是更新同一行时一个事务持锁太久另一个事务超过 innodb_lock_wait_timeout 默认的 50 秒。解决表加唯一索引写入用 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE一条语句完成避免先查再插的竞态。MyBatis 的 Mapper XML 写成这样!-- PlayRecordMapper.xml -- insert idupsertPlayRecord INSERT INTO tb_play_record(user_id, video_id, progress_seconds) VALUES (#{userId}, #{videoId}, #{progressSeconds}) ON DUPLICATE KEY UPDATE progress_seconds VALUES(progress_seconds), update_time NOW() /insert这段 SQL 依赖uk_user_video唯一索引。命中重复键时MySQL 执行 update否则执行 insert。高并发下两条插入请求同时到达时InnoDB 会锁住唯一索引节点其中一条等待另一条提交后自动触发 update不会插入重复数据。提示MySQL 8.0.20 开始VALUES()函数标记为弃用新项目可以用AS new_row语法替代如果 MySQL 版本是 5.7VALUES()完全没问题。还有一点ON DUPLICATE KEY UPDATE返回 1 表示插入成功返回 2 表示更新成功返回 0 表示值没变化别拿它当写入成功与否的唯一判断。4.4 数据库连接池参数没调连接数打满后系统直接瘫痪现象单用户操作完全正常用 JMeter 并发二三十个请求后后端报Connection is not available, request timed out数据库端也出现Cant connect to local MySQL server through socket /tmp/mysql.sock。原因连接池最大连接数配置过高或不合理Tomcat 线程抢不到连接更常见的是应用每请求通过DriverManager.getConnection()新开连接用完不关把 MySQL 连接数打满。还有 Linux 下把 jdbc url 写成localhost:3306JDBC 会尝试走 unix socket 而不是 TCP导致 socket 相关报错。解决用 Druid 连接池并在application.properties里明确限制spring.datasource.druid.initial-size5 spring.datasource.druid.min-idle5 spring.datasource.druid.max-active20 spring.datasource.druid.max-wait10000 spring.datasource.druid.test-while-idletrue spring.datasource.druid.validation-querySELECT 1参数含义max-active是连接池最多拥有的活动连接数毕设场景 20 足够max-wait10000是拿不到连接时最多等 10 秒超过就报错validation-query用于探活连接归还时执行SELECT 1避免把已断掉的连接发给业务。如果项目不是 Spring Boot是传统 SSM 的 XML 配置Druid 的配置思路一样只是换到spring-datasource.xml里。连接池报错后先看数据库端SHOW VARIABLES LIKE max_connections; SHOW STATUS LIKE Threads_connected;如果连接数已经打满多半是代码里没用 Spring 管理连接或者finally里没关连接。手动编写 JDBC 是最常见的原因Connection、Statement、ResultSet三个资源一个都不能漏。用 MyBatis 配合 Spring 管理后事务结束连接会归还池子这个问题基本就消失了。5. 验收与升级给毕设加一条可复现的验证路径5.1 三个验证点写完不是结束要能证明系统是“跑得通”而不是“能启动”。我习惯准备三组验证。第一组是接口回归。用 curl 把登录、列表、详情、保存进度四个接口按顺序调一遍curl -X POST http://localhost:8081/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} curl http://localhost:8081/api/video/list?pageNum1pageSize5确认返回结构里code、data、total都有值。第二组是播放链路打开 Chrome Network确认.m3u8和.ts都返回 200拖动一次进度条看/api/play/record收到请求。第三组是数据库一致性验证连续播放同一视频两次查tb_play_record只有一行SELECT COUNT(*) FROM tb_play_record WHERE user_id 1 AND video_id 2;再用EXPLAIN看索引是否被命中EXPLAIN SELECT * FROM tb_play_record WHERE user_id 1 AND video_id 2;type不是ALL就说明走了索引。5.2 低成本加分方向如果基础功能已经跑通想再给自己加一点工作量我推荐三个方向。第一把播放进度存 Redis定期落库下次打开视频弹窗问“是否从上次进度继续播放”这个功能很小但演示效果很好第二给视频表加一个play_count字段在后端播放接口里做自增首页按播放量排序第三把视频地址从本地路径改成对象存储 URL数据库只存 key这部分改动只涉及配置层不影响表结构。我做这类项目有个习惯不管源码包是谁写的拿到手第一件事是清掉示例数据然后按上面三组验证自己跑一遍。跑不通就拆开查查不清楚就重构跑得通最后至少挑一个功能亲手改一轮。能讲清楚“为什么这样写”的地方才是你真正得到的东西。希望你手里的在线视频点播系统不只是过查重的论文而是一个能让你站稳脚说话的完整作品。希望帮到你。本文还有配套的精品资源点击获取