又是一年毕设季,后台常有人问“想做点带功能、好演示、又不会太难的系统,选什么好”。如果你也在java和ssm这套经典技术栈里纠结题目,我强烈建议你认真看看“在线视频播放网站”这个方向。它不像图书管理系统那么单薄,又比电商秒杀这种高并发课题容易驾驭,核心链路包含用户体系、文件上传、视频流式播放、收藏评论、进度记忆,演示效果非常直观。这篇文章我会从一个完整落地项目的角度,把idea环境下用SSM从零搭建在线视频播放网站的关键设计、表结构、播放接口原理和那些网上搜不到的坑一次讲清楚,适合拿去参考复现,也适合答辩前自查一遍。
1. 选题评估与技术选型:SSM视频站在毕设里为什么“含金量适中”
1.1 视频网站相比图书商城、后台管理系统,赢在哪
先聊选题。很多同学第一反应是做商城或后台管理,但这类系统的最大问题是“逻辑太透明”——无非是一堆增删改查页面,答辩时老师一眼就能看穿深度。视频播放网站不一样,它有一个天然的演示高潮:当你把鼠标点到视频卡片,进入播放页,进度条开始走、视频流畅播放出来,场内所有人的注意力都会被吸引。观赏性带来的是印象分,而印象分在答辩环节往往比代码量更值钱。
再从技术覆盖面看,视频网站几乎把SSM项目的常见要点全占齐了:注册登录涉及SpringMVC的请求校验和会话管理;视频上传涉及文件流处理、存储路径设计、格式校验;播放页涉及静态资源映射和流式响应;播放记录涉及异步请求和数据库更新;收藏、评论、分类检索涉及MyBatis动态SQL和一对多关联查询。也就是说,做完这一个项目,你其实把SSM三个框架的核心用法都过了一遍,后续面试被问到MyBatis动态SQL、SpringMVC拦截器、事务传播行为时都能举出真实场景。
1.2 SSM vs Spring Boot:毕业设计到底选哪个
近两年越来越多同学问“现在企业都上Spring Boot了,我毕设还用SSM是不是过时了”。我的观点是:如果你是零基础或者对Spring底层的配置机制还比较模糊,SSM反而是更好的学习载体。为什么?因为SSM要求你亲手配置web.xml、spring-mvc.xml、spring-mybatis.xml,你会在这个过程中明白DispatcherServlet在哪注册、SqlSessionFactory的Bean怎么创建、Mapper代理是怎么被扫描出来的。而Spring Boot默认配置把这些全藏起来了,项目确实更容易跑通,但答辩时老师只要追问一句“你的数据源是怎么注入的”,你就很难接得住。
当然,我也见过不少学校明确规定要用Spring Boot的,那你可以参照本文的业务拆分与表结构设计,把外层框架替换成Spring Boot,核心思路完全通用。本文后续内容仍以SSM为主线,因为它在IDEA里从零搭建的过程更繁琐,也更容易暴露问题,把这些处理好了,任何框架都难不倒你。
还有一点选型细节:JDK建议用1.8,Tomcat用8.5或9.0,MySQL用5.7。这套组合在SSM环境下兼容性最稳,网上能查到的报错案例也最多,遇到问题不至于孤立无援。IDEA建议用社区版加Tomcat插件的方式,或者直接用Ultimate版内置的Application Server集成,两个方案我都试过,后面会细说。
2. 功能模块与数据库设计:先想清楚演示路径,再动手建表
写代码前最重要的一件事,不是画架构图,而是把“答辩演示时你要点哪些按钮、看到什么效果”列成清单,再倒推功能模块和表结构。我当时的演示路径是这样的:注册新用户 → 登录 → 首页看到视频分类和最新视频列表 → 点击视频卡片进入播放页 → 视频播放,进度条正常走动 → 刷新页面,进度记忆还在 → 点收藏、给评论 → 个人中心看到收藏列表和播放历史。这个路径定下来,功能边界立刻清晰了。
2.1 模块边界划定
整个系统我拆成了七个模块,每个模块的职责和边界如下表所示:
| 模块 | 核心功能 | 涉及的表 |
|---|---|---|
| 用户模块 | 注册、登录、个人信息管理 | user、user_role |
| 视频分类模块 | 分类维护与首页导航 | video_category |
| 视频信息模块 | 视频上传、列表展示、详情查询 | video |
| 播放模块 | 视频流式输出、鉴权、播放进度记录 | play_record |
| 收藏模块 | 用户收藏与取消收藏 | favorite |
| 评论模块 | 用户评论展示与新增 | comment |
| 搜索模块 | 按标题模糊搜索 | video |
不要贪多。很多人喜欢在毕设里硬加弹幕、会员、充值、后台权限管理,结果前端工作量爆炸,核心链路反而做得粗糙。我的经验是:宁可每个模块都做得完整扎实,也别铺十一个半成品模块。弹幕和会员这类功能一旦要做得好看,所花的时间堪比重做一遍项目,完全得不偿失。
2.2 核心表结构与字段设计
我建表时的一个原则:主键统一用BIGINT自增,创建时间统一用create_time,状态字段统一用TINYINT。这套规范看起来简单,但能让三层代码里的字段命名高度一致,减少MyBatis映射时到处写resultMap的麻烦。下面给出几张核心表的精简建表语句,字段注释都写在SQL里,可以直接对着改。
用户表:
CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `password` VARCHAR(128) NOT NULL COMMENT '密码(建议MD5加盐)', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像路径', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';视频表:
CREATE TABLE `video` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '视频标题', `description` VARCHAR(500) DEFAULT NULL COMMENT '视频简介', `cover` VARCHAR(255) DEFAULT NULL COMMENT '封面图路径', `video_url` VARCHAR(255) NOT NULL COMMENT '视频相对路径', `category_id` BIGINT DEFAULT NULL, `user_id` BIGINT DEFAULT NULL COMMENT '上传用户', `duration` INT DEFAULT 0 COMMENT '视频时长,单位秒', `play_count` INT DEFAULT 0 COMMENT '播放次数', `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频表';播放记录表:
CREATE TABLE `play_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `video_id` BIGINT NOT NULL, `progress_second` INT DEFAULT 0 COMMENT '播放进度(秒)', `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_video` (`user_id`, `video_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='播放记录表';这里有个容易忽略的设计点:play_record表我加了一个UNIQUE KEY uk_user_video,目的是让“一个用户对一个视频只有一条进度记录”,更新时直接INSERT ... ON DUPLICATE KEY UPDATE progress_second = VALUES(progress_second)。如果不加这个唯一键,每次上报进度就不断插新记录,一个月后你的播放记录表会膨胀到几万行垃圾数据,个人中心的历史播放列表也会变得特别难看。
2.3 分类与评论表的设计思路
分类表很简单,只有id、name、sort三个字段就够。评论表需要冗余一个username字段,而不是在查询时JOIN用户表取昵称,这样评论列表的渲染速度会明显更快,代码也更简单。虽然是反范式设计,但在毕设这种数据量级别下,冗余字段带来的开发效率提升远大于理论上的存储浪费。
CREATE TABLE `comment` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `video_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `username` VARCHAR(50) DEFAULT NULL COMMENT '冗余用户名,避免联表查询', `content` VARCHAR(500) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_video` (`video_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';表与表之间的外键我没有物理创建,只在逻辑上关联。有些学校的评阅老师喜欢看外键,但实际开发里外键会带来很多删除限制的麻烦,比如用户删号时如果评论表里还有关联数据,物理外键会直接让你删不掉。我的建议是逻辑外键加索引,删除时手动在Service里做级联清理,这样既满足老师对关联的需求,又不会把自己卡死。
3. IDEA环境搭建与SpringMVC配置:让三层架构按这个顺序跑通
3.1 IDEA中创建Maven Web项目与Tomcat关联
SSM项目建议直接建Maven工程,不要手动往WEB-INF里塞jar包。很多人卡在第一步,就是因为在网上下了一个半手工的SSM项目,jar包冲突得一塌糊涂。新建项目时选Maven -> Create from archetype -> maven-archetype-webapp,GroupId用com.example,ArtifactId用项目名。记得先在IDEA的Build Tools -> Maven里配置好本地仓库和自己的settings.xml,否则默认下载源在国外,等依赖能急死人。
pom.xml里最核心的依赖版本我列一下,直接参考:
<properties> <spring.version>5.2.6.RELEASE</spring.version> <mybatis.version>3.5.5</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.22</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.11.2</version> </dependency> <dependency> <groupId>commons-fileupload</groupId> <artifactId>commons-fileupload</artifactId> <version>1.4</version> </dependency> </dependencies>这里特别提醒一点:不要为了“省事”导入javax.servlet-api后打包时又带进去,会导致Tomcat启动时类加载冲突。servlet-api的scope一定要设成provided,让Tomcat自己去提供。如果你不确定,直接在Deployment里把Web项目的lib目录翻一遍,确认没有servlet-api.jar存在。
关联Tomcat时,我推荐用IDEA Ultimate自带的Tomcat Server集成。点开Run -> Edit Configurations -> + -> Tomcat Server -> Local,Application server指向本机Tomcat目录,Deployment选项卡里选war exploded,Application context设为/video。这里有个多年来的经典坑:如果你选了war包模式,热部署会失效,每次改Java代码都要重新build整个包,开发体验非常痛苦。war exploded是解压目录,IDEA会直接把classes和resources同步到Tomcat的webapps下,改代码后按Ctrl + Shift + F10热编译一下就能生效。
3.2 Spring与MyBatis配置分工详解
SSM的配置文件看起来多,其实思路只有一个:Spring容器负责管理业务层和MyBatis的SqlSessionFactory,SpringMVC容器只负责Controller层的Bean。按照这个原则拆分,就不会把配置写得一锅粥。
web.xml里要做两件事:加载Spring根容器、注册SpringMVC的前端控制器。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mybatis.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> </web-app>注意点:SpringMVC的url-pattern用的是/而不是*.do。用/意味着所有请求都进DispatcherServlet,这样才能实现REST风格的路径,配合静态资源映射能覆盖视频封面等目录的访问。用*.do虽然老教程多,但对video/play/1这种路径匹配很别扭,还要额外处理后缀,强烈不建议在视频站里用。
spring-mybatis.xml里配置数据源和事务管理:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/video_db?useUnicode=true&characterEncoding=utf8"/> <property name="username" value="root"/> <property name="password" value="123456"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.video.entity"/> <property name="configuration"> <bean class="org.mybatis.spring.boot.autoconfigure.MybatisProperties"/> </property> </bean>等等,MybatisProperties这个类是Spring Boot的,这里不要照抄。直接在SqlSessionFactoryBean里嵌套一个org.apache.ibatis.session.Configuration的bean,用来开驼峰映射和日志:
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.example.video.entity"/> <property name="configuration"> <bean class="org.apache.ibatis.session.Configuration"> <property name="mapUnderscoreToCamelCase" value="true"/> </bean> </property> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.video.dao"/> </bean>mapUnderscoreToCamelCase这个配置极其重要。数据库字段是play_count,Java属性是playCount,不开驼峰映射,你查询出来的对象里playCount永远是null,排查起来还特别隐蔽,因为它不报错。
3.3 静态资源和上传解析器的配置细节
spring-mvc.xml里,除了组件扫描Controller,还有两个必配项:静态资源映射和CommonsMultipartResolver。视频站的封面、上传的临时文件都涉及静态资源访问,不配<mvc:resources>的话,浏览器请求图片路径会被DispatcherServlet拦截后报404。
<mvc:resources mapping="/cover/**" location="file:/E:/video_res/cover/"/> <mvc:resources mapping="/video_res/**" location="file:/E:/video_res/"/> <bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="104857600"/> <property name="defaultEncoding" value="UTF-8"/> </bean>关于这套配置,我多说几句。file:开头的绝对路径的意思是,访问/cover/xxx.jpg时,Spring会去磁盘上的E:/video_res/cover/目录找文件。这是SSM项目里处理大文件存储的标准做法,视频文件千万不要放src/main/webapp/upload底下,因为IDEA每次重新打包war exploded时可能把目录清掉。直接把上传的文件放项目外,用虚拟映射暴露访问路径,重启不丢,打包不重,这个设计能帮你少掉一大半头发。
CommonsMultipartResolver的maxUploadSize我给了100MB,500MB以内的视频基本够用。如果你要支持超过这个体积的视频,光改这里没用,还得到Tomcat的conf/web.xml里同步调整,后面第4节会展开讲。
4. 视频上传与本地存储方案:路径设计决定你少踩多少坑
4.1 为什么选本地磁盘而不是OSS、七牛云
视频网站的毕设,存储方案通常是本地磁盘、OSS对象存储或FastDFS分布式文件存储。很多同学一听“分布式”就兴奋,非得上FastDFS,最后光搭环境就耗了三天。我要说的是:毕设的评分逻辑是链路完整 +技术点可讲,不是技术栈越重越好。你完全可以在答辩时说“本系统以本地磁盘为主,同时抽象了存储接口,后续可扩展OSS”,这句话比硬上三台虚拟机跑FastDFS然后在传输文件时报错强一百倍。
本地磁盘方案最大的优势是链路短。上传接口拿到MultipartFile,直接写磁盘,返回一个URL路径,前端拿到路径就能预览,没有任何中间环节的调优问题。代价是不支持横向扩展,但毕设本来就没几个人并发访问,这点代价可以完全忽略。
4.2 存储目录结构设计
我建议把资源目录统一放在一个独立磁盘根路径下,比如E:/video_res/,里面按业务拆子目录:
E:/video_res/ ├── cover/ 封面图 ├── video/ 视频正文,按日期分子目录 │ └── 20240601/ │ └── xxx.mp4目录结构影响的是文件名的唯一性和可维护性。我生成的视频文件名用yyyyMMddHHmmss + 随机数,避免中文文件名和空格带来的URL编码问题。很多浏览器不支持URL里的中文,你上传一个“搞笑视频合集.mp4”,前端video标签直接加载失败,排查半天最后发现是文件名的锅,这种经历真的不想再有第二次。
上传Controller的大致逻辑如下:
@RequestMapping(value = "/upload", method = RequestMethod.POST) @ResponseBody public Result upload(@RequestParam("file") MultipartFile file, @RequestParam("title") String title, HttpServletRequest request) { if (file.isEmpty()) { return Result.error("文件为空"); } String originalFilename = file.getOriginalFilename(); // xxx.mp4 String extName = originalFilename.substring(originalFilename.lastIndexOf(".")); // 校验扩展名,只允许 mp4、webm、ogg if (!Arrays.asList(".mp4", ".webm", ".ogg").contains(extName.toLowerCase())) { return Result.error("不支持的视频格式"); } String datePath = new SimpleDateFormat("yyyyMMdd").format(new Date()); String finalName = System.currentTimeMillis() + "_" + new Random().nextInt(1000) + extName; String savePath = "E:/video_res/video/" + datePath + "/" + finalName; File dest = new File(savePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); // 保存 video 记录,video_url 存相对路径如 /video_res/video/20240601/xxx.mp4 Video video = new Video(); video.setTitle(title); video.setVideoUrl("/video_res/video/" + datePath + "/" + finalName); videoService.insert(video); return Result.success(video); }这里一个关键点是:数据库的video_url存的是可访问的URL路径,而不是磁盘绝对路径。磁盘路径是内部实现,URL路径是外部访问入口,两者在保存时要明确分层。后续播放接口会根据这个URL路径再加一层鉴权,这点下一节详细说。
4.3 Tomcat的隐含限制:maxPostSize与maxSwallowSize
上传文件时报org.apache.catalina.connector.Request.parseParameters或客户端直接收到413,90%的情况不是你的代码问题,而是Tomcat自带的请求体积限制在作怪。Tomcat 8.5里POST请求的默认maxPostSize是2MB,这意味着只要表单里带了大文件,Tomcat在解析参数阶段就会拒绝请求。
修改方案有两种。简单粗暴的是在Tomcat的conf/web.xml里加一段:
<multipart-config> <max-file-size>209715200</max-file-size> <max-request-size>209715200</max-request-size> <max-request-size>209715200</max-request-size> </multipart-config>等等,multipart-config需要放在Servlet的annotation里,Tomcat全局web.xml里加这个不生效。正确做法是在conf/server.xml的<Connector>节点加属性:
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" maxPostSize="209715200" maxSwallowSize="209715200" />maxPostSize管表单POST的解析体上限,maxSwallowSize管Tomcat吞掉请求体的上限,两个都调大,上传200MB的文件才不会被中途掐断。如果是用IDEA集成Tomcat的方式,直接改IDEA配置里的Tomcat server.xml不方便,最稳妥的办法是到Tomcat安装目录的conf/server.xml里改完,重启服务。还有一个更通用的方案:前端不用表单提交文件,而是用XMLHttpRequest的FormData异步上传,这种情况Tomcat对form解析的限制会绕过一部分,但maxSwallowSize依然会卡你,所以还是建议两个参数一起调。
5. 播放核心链路:Range请求、进度记忆与防盗链
5.1 播放接口为什么不能直接返回文件地址
很多SSM视频站教程的做法是:把视频存到webapp/static/video/xxx.mp4,前端<video src="${pageContext.request.contextPath}/static/video/xxx.mp4">直接播放。这个方案演示确实没问题,但它有两个致命缺陷。第一,用户打开浏览器开发者工具,直接在Network面板看到完整的视频URL,复制粘贴就能把视频下载走,你的“在线播放”就只剩一个壳子。第二,你无法统计播放次数、无法记录播放进度,因为这些请求完全绕过了后端应用。
正确的做法是:视频的真实磁盘位置只存在于服务端,客户端永不感知。播放时前端访问/video/play/{id},后端根据视频ID找到磁盘路径,用RandomAccessFile结合HTTP Range头做分段输出。这样后端在整个链路里掌握了播放行为,也就能顺带完成播放次数+1和进度记忆的写入。
5.2 基于Range的伪流媒体输出实现
现代浏览器的<video>标签默认就会发送Range头,比如Range: bytes=0-,如果你不做206响应,直接200返回整个文件,浏览器虽然也能播,但拖动进度条时会变得非常卡顿,因为浏览器得重新从头缓冲。所以正确的播放响应必须识别Range头。
核心代码大概长这样:
@RequestMapping(value = "/play/{id}", method = RequestMethod.GET) public void play(@PathVariable Long id, HttpServletRequest request, HttpServletResponse response) throws IOException { Video video = videoService.getById(id); if (video == null) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } String filePath = video.getDiskPath(); // E:/video_res/video/xxx.mp4 File file = new File(filePath); if (!file.exists()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } String range = request.getHeader("Range"); long start = 0; long end = file.length() - 1; if (range != null && range.startsWith("bytes=")) { String[] arr = range.replace("bytes=", "").split("-"); start = Long.parseLong(arr[0]); if (arr.length > 1 && !arr[1].isEmpty()) { end = Long.parseLong(arr[1]); } } long contentLength = end - start + 1; response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setContentType("video/mp4"); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + file.length()); response.setHeader("Content-Length", String.valueOf(contentLength)); try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(start); byte[] buffer = new byte[4096]; long totalRead = 0; int len; while (totalRead < contentLength && (len = raf.read(buffer, 0, (int) Math.min(buffer.length, contentLength - totalRead))) != -1) { response.getOutputStream().write(buffer, 0, len); totalRead += len; } } }这段代码有几个细节值得你注意。RandomAccessFile的seek(start)可以把读取起点精确跳转到请求的字节位置,这是实现拖拽播放的基础。Math.min那行保证最后一次读取不会越过end边界,避免多输出字节导致播放器计算时长出错。Content-Type一定要设video/mp4,如果你存的是webm格式就要改成video/webm,否则部分浏览器会直接用下载方式处理响应。
如果你不想手写这么底层的逻辑,Spring里也有ResourceRegion可以简化实现,但毕设答辩时能讲明白Range手动实现的过程,反而是一个很大的加分项。老师会问“为什么你的视频能拖拽播放”,你把这行代码指给他看,价值比背一百个面试题都大。
5.3 播放进度上报与播放次数
播放进度的核心是那个uk_user_video唯一键。前端播放器监听timeupdate事件,每5秒向后端发一次/record/progress请求,带上videoId、progressSecond和视频总时长。后端用一个INSERT ... ON DUPLICATE KEY UPDATE完成“有则更新、无则插入”,代码非常简洁:
@PostMapping("/record/progress") @ResponseBody public Result reportProgress(@RequestParam Long videoId, @RequestParam Integer progressSecond, HttpSession session) { Long userId = (Long) session.getAttribute("loginUserId"); if (userId == null) { return Result.error("请先登录"); } playRecordService.saveOrUpdate(userId, videoId, progressSecond); return Result.success(); }播放次数不要在这里更新。很多人在play接口里写play_count + 1,结果每次拖拽进度都触发play请求,播放次数被刷到几十万。我的做法是:只有进入播放页时,由播放页的初始化接口调用一次play_count + 1,这个接口和真正的视频流接口分离,统计口径就清晰了。
5.4 防盗链的实用级方案
坦白说,SSM里做真正的防盗链很复杂,要搞 Token 签名、IP 黑白名单、Referer 校验。但毕设有毕设的做法,我提供一个性价比最高的方案:在play路径里加一个时间戳签名参数,前端播放时先向后端请求一个带签名的播放地址,签名有10分钟有效期。
简单实现如下:
public String generatePlayUrl(Long videoId, Long userId) { long expire = System.currentTimeMillis() + 10 * 60 * 1000; String sign = md5(videoId + "_" + userId + "_" + expire + "_salt"); return "/video/play/" + videoId + "?expire=" + expire + "&sign=" + sign; }play接口里先校验签名有效性,过期或签名不匹配直接返回403。用户就算拿到地址,10分钟后也失效,常规的复制链接下载方式就被堵住了。粘贴到迅雷之类工具里下载时,由于URL带动态签名,迅雷校验不过也无法完成下载,足够应付演示场景。
6. 常见问题与完整排查链路:这些坑我劝你提前知道
这一节我挑几个在整个项目开发中真实遇到、且网上答案往往不完整的问题,给大家完整还原排查思路,而不是只给一句“改一下配置”的结论。
6.1 上传文件后访问404,排查链路还原
现象:上传接口返回成功,浏览器访问返回的/video_res/video/20240601/xxx.mp4地址,页面404。
排查步骤:第一,先在浏览器里直接访问磁盘路径的映射URL,确认是SpringMVC静态资源映射的问题,还是文件根本没存到对应位置。第二,到IDEA的File -> Project Structure里看Web项目的Web Resource Directory设置,确认没有把项目输出目录弄乱。第三,检查spring-mvc.xml里的mvc:resources配置,location属性必须写file:前缀;如果漏了file:,Spring会以为你指向的是classpath路径。
这个问题的隐藏根源还有可能是Tomcat的docBase没指对。在IDEA的Deployment配置里,Application context填的是访问根路径,而外部目录的资源映射是Tomcat内部处理的,如果mvc:resources写的是相对路径,就会被根路径干扰。用绝对路径加file:前缀后,所有版本都稳定。
6.2 启动时Bean冲突或SqlSessionFactory创建失败
现象:Tomcat启动时报SqlSessionFactoryBean相关的BeanCreationException,或者提示Invalid bound statement (not found)。
完整排查链路:先看spring-mybatis.xml里的mapperLocations路径是否和实际mapper目录匹配,classpath:mapper/*.xml必须在resources/mapper下,注意如果放在src/main/java下,Maven默认不会把它打进classes目录。这个问题非常经典,初学者喜欢把UserDao.xml和UserDao.java放在同一个包下,mapperLocations却写的classpath:mapper/*.xml,结果永远找不到。
解决方法是:Mapper接口的XML统一放src/main/resources/mapper/,并且打开IDEA右侧Maven面板,执行clean加compile,到target/classes/mapper目录下确认XML是否真实存在。如果不在,说明Maven资源过滤设置有问题,需要在pom.xml里声明:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> </resource> </resources> </build>6.3 播放页跨域请求失败
如果你把前端页面和后端接口分开了,比如前端在IDEA里单独跑、后端在Tomcat上跑,播放页的fetch请求会出现跨域报错。SSM里启用跨域有三种方式:在Controller上加@CrossOrigin、写一个HandlerInterceptor统一加Header、在后端Filter里设置响应头。我建议直接用Filter方案,因为视频站的封面加载和视频流本身都是异步请求,统一加头最省事:
public class CORSFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { HttpServletResponse response = (HttpServletResponse) res; response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE"); response.setHeader("Access-Control-Max-Age", "3600"); response.setHeader("Access-Control-Allow-Headers", "x-requested-with, Content-Type"); chain.doFilter(req, res); } }这里有一个安全提醒:Access-Control-Allow-Origin设成*在演示环境没问题,但如果你的系统里做了登录Cookie认证,*会导致携带Cookie的跨域请求失败,浏览器会直接拒绝。因为想要带Cookie,就必须指定具体源,不能用通配符。毕设里如果前后端同源部署,根本不需要这个Filter,跨域问题自然不存在;只有前后端分端口联调时才需要临时加,联调完删掉即可。
6.4 视频文件体积大导致IDEA编译卡顿
这个问题不算报错,但特别影响开发心情。视频文件如果放在项目内或target目录里,IDEA的索引和Maven的资源拷贝每次都会扫描这些大文件,CPU直接飙满。我的建议是:视频素材统一放E:/video_res,不参与IDEA项目文件索引;如果被迫放在项目内,就在.gitignore或IDEA的File Types里忽略.mp4后缀,让索引跳过。
另外,war exploded模式下,IDEA往Tomcat的webapps目录同步文件时,遇到大视频资源会特别慢。所以上传资源的存储路径必须绝对独立于Web应用目录,这也是我反复强调项目外存储的原因——它不是一个风格偏好,而是能让开发体验发生质变的工程决策。
最后分享两个实战体会
这类在线视频播放网站做完之后,我个人最大的体会是:它的核心复杂度不在业务功能的数量,而在视频文件的“流式输出”和“路径管理”这两块。很多时候界面已经全部写完了,上传也正常了,但播放器就是转圈不播,最终查下来都是Range响应头没设置、或者存储路径与URL映射不一致导致的。把这两条链路彻底打通,系统就成功了一大半。
再给一个很实用的小技巧:答辩前,一定要预置一份演示数据。手动在页面上一一上传视频又慢又容易出现格式问题,我建议先写一段SQL脚本,把已上传到E:/video_res里的视频文件名直接插入到video表,配合后台写好的封面图URL,首页一打开就是整整齐齐的视频卡片,播放点进去即出画面。现场的稳定性,永远是准备的副产品。