简介:这是一份基于 Java、SSM、Vue 与 MySQL 的在线视频点播系统毕业设计项目,适合计算机相关专业学生用于毕业设计、课程设计或期末大作业。项目前后端代码完整,包含源码、数据库脚本、论文文档及常用软件工具,经过严格调试可直接运行,功能完善、界面简洁,便于二次开发,前端页面、后端接口、数据库建表语句等一应俱全,可对照源码理解完整业务实现。项目采用 IDEA 开发,数据库选用 MySQL 8.0,部署环境为 Tomcat 7.x/8.x 与 Maven,数据库管理推荐 Navicat。压缩包共 1414 个文件,以 Java、JSP、JS、CSS、HTML 等前后端代码为主,辅以 SQL 脚本、XML 配置、PNG/JPG/GIF 图片素材、TTF/WOFF 字体及少量 MP4 演示视频,整体约 48.04MB,目录结构清晰,便于按模块查找和学习。该页面已有 44 人学习,适合希望在较短时间内搭建可演示视频点播系统、并参考其论文与数据库设计完成毕设答辩的学生。

1. 一个在线视频点播系统被当成普通 CRUD 来写,是大部分高分毕设翻车的起点

拿到“基于 java+ssm+springboot+vue+mysql 的在线视频点播系统”这类需求,很多人的第一步是先把 video 标签放到页面上,能播一个 mp4 就宣布系统完成。真正到了答辩或项目评审时,被反复追问的恰恰是藏在播放器后面的几层:视频元数据放哪个表、播放记录怎么存、播放地址有没有裸奔、前端刷新之后为什么 404、MySQL 表设计能不能支撑分类分页。这篇文章按“数据库设计 → SSM 后端接口 → Vue 前端列表与播放链路 → 排坑”的顺序展开,副线是项目验收时怎么把每个模块的设计取舍讲清楚。适合准备自己动手实现一套前后端分离点播系统的同学参考。

2. 技术栈还原:SSM 和 Spring Boot 在一套系统里怎么各司其职

2.1 SSM 不是“老式 XML 工程”:先搞清标题里的三个缩写

标题同时出现 ssm 和 springboot,很多人第一反应是“要搭两个项目”,其实不对。SSM 指的是 Spring、SpringMVC、MyBatis 三个框架的组合。早年间做 SSM 要先建一个 web 项目,手写 web.xml、spring-mvc.xml、mybatis-config.xml 一堆配置文件,DispatcherServlet、注解驱动、MapperScannerConfigurer 全部手动声明,稍有不慎整个接口直接 404,日志里还未必报错。

Spring Boot 做的事情是把这些繁琐细节收敛为 starter 自动装配。 spring-boot-starter-web 自带 SpringMVC 和内嵌 Tomcat, mybatis-spring-boot-starter 负责创建 SqlSessionFactory 并扫描 Mapper。所以“java + ssm + springboot + vue + mysql”应该理解成: 以 Spring Boot 为基础外壳,内部用 SpringMVC 处理 HTTP 请求、用 MyBatis 访问 MySQL,前端不与后端共享模板,Vue 通过 JSON 接口通讯 。答辩时如果被问“SSM 和 SpringBoot 什么关系”,能把这个过程讲清楚,比背概念得分高得多。

常见反例是两种:一是仍然强行创建 structure 里塞入 webapp/WEB-INF 和一堆 XML,让 Spring Boot 越用越重;二是完全抛弃 Mapper XML,所有 SQL 用注解写在接口上,数据量大后又抱怨 MyBatis 动态 SQL 没法写。正确态度是:注解明显更短的地方用注解(比如按主键查单行),涉及多表联查、动态条件、批量更新的地方用 XML,两者本来就可以共存。

2.2 Maven 依赖先按这个清单给:版本为什么锁在 Spring Boot 2.x

在 pom.xml 中补一段最精简的依赖,整个项目能跑通全靠在它基础上加扩展,不会有额外的全家桶焦虑:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>2.3.2</version>
    </dependency>

    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

参数选择上有几点我一般会在注释里留给接手的人看。Spring Boot 用 2.7.18 而不是 3.x,原因是毕设机器很多还是 JDK 8,Spring Boot 3 强制 JDK 17 和 jakarta 命名空间,遇到 javax.servlet 改 jakarta.servlet 这类问题纯属给自己找活干。 mybatis-spring-boot-starter 2.3.2 与 Spring Boot 2.7 兼容性良好,能自动引入 MyBatis 3.5.x。MySQL 驱动用的是 com.mysql:mysql-connector-j ,这是 MySQL 官方从 8.0.31 开始使用的新坐标,老资料里的 mysql-connector-java 也能用,但不建议再复制。

2.3 application.yml 里最容易暴露问题的三个配置

视频点播系统比通用管理系统多出来的配置主要是“上传文件大小”和“本地磁盘映射”,下面这份配置可以直接落到工程里:

server:
  port: 8080

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/video_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root
  servlet:
    multipart:
      max-file-size: 1024MB
      max-request-size: 1024MB

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.vod.entity
  configuration:
    map-underscore-to-camel-case: true

file:
  upload-dir: D:/vod/uploads

解释其中最影响运行的三组参数:第一,JDBC URL 里 characterEncoding=utf8mb4 是同步客户端与 MySQL 服务的字符集,如果某个字段存了 Emoji 或冷门中文,只建库用 utf8mb4 而连接串不带这个参数,写库后大概率出现问号;第二, serverTimezone=Asia/Shanghai 这个参数不加会在 MySQL 8 下报服务器时区异常,凌晨跑批任务时数据时间整整差 8 小时;第三, map-underscore-to-camel-case: true 让数据库里的 create_time 自动映射成实体类里的 createTime ,少写一大串 resultMap。

视频文件通常超过 100MB,所以 multipart.max-file-size 不能参照一些通用管理系统模板默认的 1MB/10MB,我一般直接给到 1024MB。 file.upload-dir 是自定义配置,用来避免把视频目录硬编码在业务代码里,一个系统里多处代码引用 D:/vod/ 这种常量是后期维护最头疼的事。

2.4 一次请求从 Vue 到 MySQL 的流转路径

先把后端目录结构与职责对齐:Controller 层只做参数校验和响应包装,Service 层处理业务规则如“下架视频不允许被播放”,Mapper 层通过 MyBatis 操作数据库。一个典型请求是“前端进入页面时获取视频列表”,它的流转过程如下:

  1. Vue 组件调用 axios 发起 GET /api/video/list?categoryId=1&page=1&size=10 。
  2. SpringMVC 的 DispatcherServlet 根据 @RestController 和 @GetMapping("/api/video/list") 匹配到 VideoController。
  3. VideoService 接收参数后做业务判断,例如分类不存在时直接返回空列表。
  4. VideoMapper 接口方法执行,MyBatis 根据绑定关系找到同名 XML 中的 SQL,执行后返回实体集合。
  5. 结果逐层返回到 Controller,包装成统一的 Result 对象,Jackson 序列化成 JSON。
  6. Vue 的 html 页面拿到响应后渲染出卡片列表。

这个链路里最容易让新手困惑的是第 4 步名称绑定:Mapper 接口 VideoMapper.selectPage(...) 对应的 XML 文件会有 id="selectPage" 的 <select> 节点,namespace 必须是 com.example.vod.mapper.VideoMapper 。如果启动时报 Invalid bound statement (not found) ,第一反应不是查 SQL 语法而是去确认 XML 文件的 namespace 和接口全限定名是否一致。这个错误在视频点播系统里出现频率极高,因为不少开发者是从网上的低代码模板复制代码,包名一换,忘了同步 namespace。

3. 数据库表设计与 SSM 接口落地:先把表建对,接口就顺了

3.1 先定边界:视频文件不进 MySQL,MySQL 只存元数据

很多第一次做点播系统的人,看到 MySQL 就试图把视频二进制也塞进数据库,这是整场项目里最根本的设计失误。视频文件动辄几百 MB,放入数据库带来的后果非常实际:备份文件急剧膨胀、查询缓存失效、数据库连接长时间被大字段占用。点播系统要求播放器能按需拉取视频字节,这通常依赖 HTTP Range 分段请求,属于静态文件服务器的强项,靠数据库存 BLOB 很难做得流畅。

合理的边界是这样:MySQL 保存视频的标题、封面、分类、时长、播放次数、存储路径这些元数据;真正的大文件以普通文件形式放在磁盘目录,比如 D:/vod/uploads/xxx/index.m3u8 ,后端把该目录映射为静态资源 URL。前端拿到的是一个以 /video/ 开头的 URL,而不是数据库里的物理路径。这套边界的拓展性很好,将来视频量起来就算要迁移到云厂商的对象存储,也只需要改动存储环节,表结构和接口都不用推翻。

三张核心表之间的主外键关系也很直白:category 与 video 是一对多,video 与 play_record 是一对多,play_record 依靠用户和视频联合唯一键,保证每个用户对一部视频只有一条历史记录。所有表都加主键、都加上 create_time ,这是后续做播放量排行和用户行为分析的基本保障。

3.2 建表 SQL:四张表把数据结构一次写明白

CREATE DATABASE IF NOT EXISTS video_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE video_db;

CREATE TABLE `user` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  `password` VARCHAR(128) NOT NULL COMMENT '不建议明文,存 MD5/BCrypt 哈希',
  `nickname` VARCHAR(50) DEFAULT '',
  `role` TINYINT NOT NULL DEFAULT 1 COMMENT '1:普通用户 2:管理员',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

CREATE TABLE `category` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(30) NOT NULL,
  `sort` INT NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频分类';

CREATE TABLE `video` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `category_id` INT NOT NULL,
  `title` VARCHAR(100) NOT NULL,
  `cover` VARCHAR(255) DEFAULT '',
  `url` VARCHAR(255) NOT NULL COMMENT '视频相对路径',
  `duration` INT NOT NULL DEFAULT 0 COMMENT '时长,单位秒',
  `play_count` INT NOT NULL DEFAULT 0,
  `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1:上架 0:下架',
  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_category` (`category_id`),
  CONSTRAINT `fk_video_category` FOREIGN KEY (`category_id`) REFERENCES `category` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='视频信息表';

CREATE TABLE `play_record` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `user_id` INT NOT NULL,
  `video_id` INT NOT NULL,
  `progress_seconds` INT NOT NULL DEFAULT 0 COMMENT '上次播放到第几秒',
  `update_time` DATETIME NOT NULL 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='播放记录表';

严格按照上面的字段注释建表有个额外价值:答辩 PPT 里的“数据库设计说明”可以直接从注释和字段取值说明改写出来,基本不需要另编一套空中楼阁。video 表里 url 存储的是相对路径,例如 /video/sample/index.m3u8 ,不存 http://127.0.0.1:8080/video/... ,这样本地开发环境和将来部署到服务器,只需改配置里的域名,不需要刷掉整表数据。play_record 表用的是“用户 + 视频 + 进度秒数”,想做成“继续观看”功能时会非常顺手。

初始数据也需要同步准备,至少要有管理员账号、几个分类和一部可播放的测试视频:

INSERT INTO `user` (`username`, `password`, `role`) VALUES
('admin', 'e10adc3949ba59abbe56e057f20f883e', 2),
('demo', 'e10adc3949ba59abbe56e057f20f883e', 1);

INSERT INTO `category` (`name`, `sort`) VALUES
('电影', 1), ('剧集', 2), ('纪录片', 3);

INSERT INTO `video` (`category_id`, `title`, `cover`, `url`, `duration`, `status`) VALUES
(1, '片头演示视频', '/video/demo/index.m3u8', 600, 1);

密码字段里 e10adc... 是明文 123456 的 MD5 作为示例,毕设项目能跑通即可,正式商用必须换 BCrypt。我见过有人偷懒直接用明文,答辩老师顺手查一下数据库就能挑出毛病,属于可以选择性避免的低级风险。

3.3 分页查询的 Mapper 与动态 SQL:加分类条件也不用重写 SQL

列表页需要“按分类筛选 + 分页”,如果只写死一个 SELECT * FROM video ,后面改需求就是一场灾难。MyBatis 动态 SQL 的典型写法先看接口定义再对应 XML:

@Mapper
public interface VideoMapper {

    List<VideoVO> selectPage(@Param("categoryId") Integer categoryId,
                             @Param("offset") int offset,
                             @Param("limit") int limit);

    long count(@Param("categoryId") Integer categoryId);
}

XML 实现:

<select id="selectPage" resultType="com.example.vod.vo.VideoVO">
    SELECT v.id, v.title, v.cover, v.url, v.duration, v.play_count,
           c.name AS categoryName
    FROM video v
    LEFT JOIN category c ON v.category_id = c.id
    <where>
        v.status = 1
        <if test="categoryId != null">
            AND v.category_id = #{categoryId}
        </if>
    </where>
    ORDER BY v.create_time DESC
    LIMIT #{offset}, #{limit}
</select>

<select id="count" resultType="long">
    SELECT COUNT(*)
    FROM video v
    <where>
        v.status = 1
        <if test="categoryId != null">
            AND v.category_id = #{categoryId}
        </if>
    </where>
</select>

代码逻辑说明: <where> 标签会自动处理开头的 AND,不会因为分类条件为空时生成多余的关键词; offset 与 limit 是手动拼进去的,适合数据量在几千条以内的毕业设计体量,接入 MyBatis 分页插件会更省心。注意 LEFT JOIN 而不是 JOIN ,因为有的视频可能没有关联分类,LEFT JOIN 能保证不丢数据,只是 categoryName 为空。

这里有一个很多人避而不谈的坑:玩 SQL 拼接时,offset 和 limit 必须是整数,不能直接把前端字符串拼进去。写接口时用 @RequestParam(defaultValue = "1") int page 这种方式接收参数,然后 offset = (page - 1) * size ,可以避免用户传 page=abc 直接打到数据库层。

3.4 播放地址的静态映射:让前端看到的永远是表面路径

后端不能直接把 D:/vod/ 这种物理盘符交给前端,否则任何一个用户知道路径规则后,可以自行拼 URL 把整站视频扫走。常见做法是配置一个虚拟路径映射:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("${file.upload-dir}")
    private String uploadDir;

    @Value("${file.video-prefix:/video/}")
    private String videoPrefix;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        String absolutePath = Paths.get(uploadDir).toAbsolutePath().toUri().toString();
        registry.addResourceHandler(videoPrefix + "**")
                .addResourceLocations(absolutePath);
    }
}

参数说明: addResourceHandler 里的 /video/** 是用户访问的虚拟路径, addResourceLocations 指向磁盘真实目录,两个值都通过配置文件注入,迁移部署时只改 yml 不碰代码。这样做还能顺便解决一个视频播放的硬问题:浏览器播放 m3u8 时,如果视频通过 file:// 协议访问,大多数浏览器出于安全策略直接拒绝,映射成 HTTP 后则一切正常。

控制器返回给前端的数据里,视频地址始终是 ${视频前缀}/${文件名} 的相对或半相对路径。真正要做防盗链的,可以在视频 Handler 上加请求头校验或 Referer 判断,能挡住不少“直接在浏览器地址栏拉视频下载”的实际场景。不过坦白讲,静态资源防盗链在纯前后端分离里能做到的有限,属于防守型增强。

4. Vue 前端链路:路由参数、接口代理与 m3u8 播放

4.1 工程初始化:Vue 2 还是 Vue 3 先选好,别边写边换

视频点播系统的前端建议直接用一个 Vue CLI 初始化,相比手工创建单文件组件,脚手架能省下大量配置时间。选 Vue 2 还是 Vue 3,核心标准不是哪个新,而是哪一套生态资料能兜住你的问题:Vue 2 + Element UI 的中文资料最海量,v-model、组件通信、后台管理系统模板应有尽有;Vue 3 + Element Plus 生态也成熟了,但很多网上找来的 SSM 毕设前端模板还是 Vue 2。我给出的方案以 Vue 2 + vue-router 3 为主,这也是大多数“基于 ssm+vue 的毕设”实际在用的组合。

创建命令:

vue create vod-frontend
cd vod-frontend
npm install vue-router@3 axios vue-video-player@5 videojs-contrib-hls
npm run serve

解释一下依赖: vue-router@3 是 Vue 2 专用的版本号,装成 4.x 会直接报错,这也是新手环境配置里很常见的版本冲突。前端安装依赖时会有一段较长的等待,最好开一个能看到实时日志的终端。如果嫌每次装依赖太慢,可以保留 package-lock.json ,下次 npm ci 安装速度会明显变快。

4.2 路由设计:播放页参数怎么优雅地传到组件里

路由配置决定系统有哪些页面入口,如果全部页面写在同一个组件里,组件会被撑得很长,也不好维护。核心路由按实际业务流程拆成四个:

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Category from '../views/Category.vue'
import Player from '../views/Player.vue'
import Admin from '../views/Admin.vue'

Vue.use(VueRouter)

const routes = [
  { path: '/', name: 'home', component: Home },
  { path: '/category/:cid', name: 'category', component: Category, props: true },
  { path: '/video/:id', name: 'player', component: Player, props: true },
  { path: '/admin', name: 'admin', component: Admin }
]

export default new VueRouter({
  mode: 'hash',
  routes
})

关键设计在 /video/:id 这个动态路由。 :id 是路由参数,前端跳转播放页时执行 this.$router.push({ name: 'player', params: { id: 1 } }) ,页面 URL 会变成 #/video/1 。加了 props: true 后,路由参数会直接作为组件的 prop 传入,组件内部不需要再去解析 $route.params ,对播放器的复杂组件来说更干净。

模式上选 hash 而不是 history 是认真考虑过的:hash 模式下 URL 里带 # ,页面刷新时请求路径始终是根路径,不需要后端做 fallback; history 模式虽然地址好看,但部署到 nginx 或 Spring Boot 后,不额外配置路由回退就会出现“刷新后 404”。毕业设计时间紧张,先用 hash 把功能做完,有余力再研究 history 模式。

4.3 axios 请求封装与本地代理:解决开发环境的跨域

前端开发服务器跑在 5173 或 8080,后端接口跑在 8080(也常用 8081),两者端口不同必然触发跨域。开发期最常见的做法是用 webpack-dev-server 的 proxy 转发,让前端通过相对路径访问接口:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:8080',
        changeOrigin: true
      }
    }
  }
}

同时在前端封装一个 axios 实例:

import axios from 'axios'

const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

request.interceptors.response.use(
  response => {
    const res = response.data
    if (res.code !== 200) {
      return Promise.reject(new Error(res.message || '请求失败'))
    }
    return res.data
  },
  error => {
    if (error.response && error.response.status === 401) {
      window.location.hash = '/login'
    }
    return Promise.reject(error)
  }
)

export default request

代码说明:拦截器里做了两层处理,一是后端返回统一结构 { code, message, data } ,只有 code 为 200 时才放行;二是遇到 401 直接跳登录页。对于点播系统来说,必须把“未登录用户能否直接播放”这个逻辑想在前面:前端跳转容易,真正防住还是要靠后端接口校验登录态。proxy 配置只在开发阶段生效,生产部署时 nginx 需要配置等价的反向代理,否则前后端就彻底跨开了。

4.4 播放器组件:一段可以在播放页直接用的 m3u8 播放代码

m3u8 是 HTTP Live Streaming 的索引格式,Video 标签原生不支持时,必须借助 video.js 生态来解析。这里直接用 vue-video-player,它在 Vue 2 组件里的写法足够简洁:

<template>
  <div class="player-container">
    <video-player
      class="video-player vjs-custom-skin"
      ref="videoPlayer"
      :options="playerOptions"
      @play="onPlayerPlay"
      @pause="onPlayerPause"
      @timeupdate="onPlayerTimeupdate"
    ></video-player>
  </div>
</template>

<script>
import 'video.js/dist/video-js.css'
import 'vue-video-player/src/custom-theme.css'
import { videoPlayer } from 'vue-video-player'
import 'videojs-contrib-hls'

export default {
  name: 'VideoPlayer',
  components: { videoPlayer },
  props: {
    src: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      playerOptions: {
        autoplay: false,
        controls: true,
        fluid: true,
        sources: [
          {
            type: 'application/x-mpegURL',
            src: this.src
          }
        ]
      }
    }
  },
  methods: {
    onPlayerPlay() {
      this.$emit('video-play')
    },
    onPlayerPause() {
      this.$emit('video-pause')
    },
    onPlayerTimeupdate(player) {
      const currentTime = player.currentTime()
      this.$emit('save-progress', Math.floor(currentTime))
    }
  }
}
</script>

组件逻辑说明: sources 数组里的 type 必须是 application/x-mpegURL ,这就是 m3u8 的标准 MIME 类型; videojs-contrib-hls 引入后会自动接管带 .m3u8 后缀的地址并完成分片请求与合并。 fluid: true 让播放器宽度跟随容器,不会撑破后台管理页面的卡片布局。 timeupdate 在播放时高频触发,我通常会在组件内部做节流,每 5 秒才保存一次进度,避免把 play_record 表打爆。

关于“vue 播放 m3u8 免安装”的说法:本质上 Chrome/Firefox 桌面版都不原生支持 HLS,还是要依赖这些 JS 插件。区别仅在于你是引入一个完整播放器组件,还是自己用 hls.js 手动处理 Media Source Extensions。第一次做点播系统,vip 建议用 player 包裹的方式,代码量最少。

5. 避坑排查:视频点播系统最容易翻车的 5 个现场

5.1 现象:后端启动失败,控制台报 ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'

原因:这句报错出现在“后端连 MySQL”的场景下,最直接的原因是 MySQL 服务根本没有启动。在 Linux 或 macOS 上,JDBC URL 如果写成 localhost:3306 会被解释成走 unix socket 文件,而不是 TCP 连接,一旦找不到 socket 文件就会抛出这个错误;Windows 上则更容易表现为服务未启动。

解决:先确认 MySQL 进程状态,Linux 执行 systemctl status mysql ,macOS 用 brew services list ,Windows 在“服务”里找 MySQL 并启动。JDBC URL 建议不写 localhost 而是写 jdbc:mysql://127.0.0.1:3306/video_db ,这样可以强制走 TCP,绕开 socket 文件缺失的问题。如果按 mysql 安装教程装过之后仍连不上,排查顺序是端口是否被占用、data 目录是否初始化干净、mysql.user 表是否被清掉。

5.2 现象:播放器控件出现,但画面一直转圈,m3u8 请求 404

原因:多数不是播放器问题,而是静态资源映射路径对不上。比如数据库里 url 存的是 /video/1/index.m3u8 ,但磁盘目录实际在 D:/vod/uploads/video/1/index.m3u8 ,而配置的映射是 /video/** 对应 D:/vod/uploads/ ,此时请求路径会变成 D:/vod/uploads/video/1/index.m3u8 ,多了一层 video 目录。另一种常见因素是 m3u8 里的分片 ts 文件是相对路径,与 m3u8 不在同一目录,导致拉取分片时 404。

解决:先直接访问 http://127.0.0.1:8080/video/1/index.m3u8 看是否能返回文件内容;能打开就说明映射正常,剩下的是 m3u8 内部路径问题。排查时可以打开开发者工具的 Network 面板,看是哪个 ts 请求失败,再反向检查目录结构。FFmpeg 转 m3u8 时我习惯把 -hls_base_url 设为空,确保分片路径与 m3u8 同级,这个细节能省去大量 debug 时间。

5.3 现象:Vue 请求 /api/video/list 返回 HTML 或 404 页面,而不是 JSON

原因:后端没有匹配到这个接口,SpringMVC 会交给默认错误处理,最终响应一个 HTML 错误页。这类问题集中在三个地方:Controller 的 @RequestMapping 路径写错、RestController 没有被扫描到、或 MyBatis 的 Invalid bound statement 导致 Service 层对 Mapper 的调用全部失败。

解决:先看启动日志里有没有 VideoController 相关的 Mapped 记录信息;再用浏览器直接访问 http://127.0.0.1:8080/api/video/list ,如果 404 则确认包扫描范围(启动类里加 @MapperScan("com.example.vod.mapper") ),确认 Controller 类是否在启动类的子包下。还有一个容易忽略的是 application.yml 里配置了 server.servlet.context-path ,例如 /vod ,那么前端代理与接口路径都要增加该前缀,唯一不加的人可能只有后端本地测试。

5.4 现象:MySQL 写入中文后显示乱码,或批量 update 报参数处理错误

原因:绝大多数情况是建库时字符集没指定,用了默认的 latin1 或 utf8mb3。MySQL 8 之前的默认字符集对中文支持并不理想,需要显式指定 utf8mb4 。另外短视频名里如果出现 Emoji,比如“🔥 热门合集”,普通 utf8 存不进去,必须使用 utf8mb4;同时 mysql update 语法里字符串不加引号、时间字段传错格式也会带来乱码或警告。

解决:建库语句统一用 DEFAULT CHARACTER SET utf8mb4 ,JDBC URL 带上 characterEncoding=utf8mb4 ,这样跨库、跨终端都稳定。更新类型字段时使用 UPDATE video SET title = #{title} WHERE id = #{id} 这种预编译写法,让 MyBatis 处理转义,而不是在前端拼好 SQL 字符串。乱码一旦出现,修改 DDL 并重新导入数据通常比在代码里反复补 filter 更干净。

5.5 现象:播放页在本地 Hash 模式正常,部署到服务器后点返回按钮地址栏变了但页面内容不刷新

原因:这不是 404,而是带参数的同一组件路由复用时,Vue 没有重新触发生命周期钩子。比如从 /category/1 点进 /video/1 ,再换一部视频跳到 /video/2 ,Player 组件认为是同一个组件实例, created 不会再次执行,导致 src 还是旧的。

解决:在播放页组件里 watch $route.params.id 变化然后重新拉取视频地址,或者干脆在跳转时给 router-view 加 :key="$route.fullPath" ,强制路由变化时重建组件。我一般采用后者,代码改动最小,通用性也好。值得注意的是,这个坑在点击排行榜、分类列表、最近播放三个入口都在跳视频详情时会被放大,越晚遇到越影响体验。

6. 验收前的最后 10 分钟:按顺序跑通这套流程

6.1 一次性启动检查的命令清单

换到一台干净的机器,或者把原环境重启一遍,按这个顺序自己做一次完整启动:先启动 MySQL 并确认能用 mysql -uroot -p 登录;再确认后端启动类所在包路径已包含 @MapperScan ,执行 mvn spring-boot:run 后日志里出现“Started Application in x.xxx seconds”;最后 npm run serve ,用浏览器打开前端页面。整套流程走完如果在 5 分钟以内,说明环境没有隐藏依赖。

启动顺序里最容易因为“上次直接关了 IDE”而翻车的是数据库服务和前端端口占用。 npm run serve 默认端口如果被占用,Vue Cli 有时会询问是否自动换端口,此时接口代理 target 没有变,但前端页面源地址变了,这时需要重新确认跨域相关配置不会受端口变化影响。

6.2 演示脚本:从登录到播放记录恢复控制在 3 分钟

给一个我习惯用的验收顺序:用 demo 账号登录 → 首页分类筛选 → 任意点开一部视频 → 快进到 30 秒暂停 → 退出重新进入该视频页面 → 验证“继续播放”位置恢复 → 切换播放页与首页多次刷新 → 最后切换到管理员账号,展示视频列表与上下架操作。这几个动作几乎覆盖了本项目全部核心点:登录鉴权、分类分页、播放器播放 m3u8、播放进度保存、路由参数传递、权限区隔。

每一段演示对应能讲一句设计理由。比如“我能讲清楚为什么播放进度存数据库而不存 localStorage:更换浏览器后进度依然存在”;“路由用 hash 模式是我不想在部署环节引入 Spring Boot 的 fallback 转发”。这类坦诚的话术比说一堆概念更有说服力,也更容易在问答环节拿到主动权。我在做自己的项目时也习惯把演示路径写成一个简短清单,放在项目 README 里,答辩前照着走两遍,能避免现场找不到测试账号或忘记密码这类尴尬。

这个习惯救过我很多次,希望帮到你。

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

Logo

邀请您加入社区

更多推荐