简介:这份资源是面向计算机、通信、人工智能、自动化等相关专业学生与教师的SSM电影网站毕业设计完整源码包,采用SSM框架结合MySQL数据库实现,适合作为毕业设计、期末课程设计或课程大作业的参考方案,也可供基础较好的学习者在此基础上二次开发、调整功能。压缩包共78个文件,约7.02MB,包含15个jsp页面文件、12个java后端源码、7个xml配置、4个css样式、2个js脚本以及1个sql数据库脚本,另附图片、字体等静态资源,覆盖前端展示、后端控制与数据持久化的完整结构。目前已有114人浏览学习,项目经过调试测试可正常运行,答辩评审分达到98分,具有较高的学习借鉴价值。读者可从中获取清晰的SSM分层目录结构、数据库建表脚本与页面交互实现思路,便于快速理解电影网站的业务流程与开发要点。
1. 从零搭一套 SSM 电影网站:毕业设计里最容易被低估的三个坑
很多同学拿到「基于 SSM+MySQL 的电影网站设计与实现」这个题目时,第一反应是去搜一套现成源码,跑起来能登录、能看列表、能下单,就觉得稳了。真正答辩被问住的地方,往往不是功能多少,而是三件事:SSM 三层到底怎么分工、电影这种「多对多 + 图片 + 分类」的数据模型怎么设计、以及前后端分离和传统 JSP 混用时请求怎么走。这篇笔记就按我实际带过几套类似系统的经验,把从建库到跑通核心业务的路径拆开讲,重点放在能复现、能改参数、能排错上。适合正在做 JavaWeb 方向毕业设计、需要一套能讲清楚也能跑起来的电影网站的同学,也适合想拿 SSM 练手但一直卡在配置上的开发者。
2. SSM 三层怎么切:别把 Service 写成第二个 Controller
SSM 是 Spring + SpringMVC + MyBatis 的合称,但真正决定代码质量的不是这三个框架本身,而是你把业务逻辑放在哪一层。我见过太多毕业设计项目,Controller 里直接调 Mapper,Service 层只剩一个空壳转发,答辩时老师一句「你的业务逻辑在哪」就答不上来。这一章先把分层讲透,再落到具体目录结构和依赖配置。
2.1 为什么电影网站特别需要 Service 层
电影网站的核心业务不是单表增删改查。一部电影属于多个分类,一个分类下有多部电影;用户可以收藏、评分、评论;排片和场次又跟影院、时间绑定。这些操作天然是跨表的,如果全塞进 Controller,事务根本没法控制。
举个最典型的场景:用户下单买票。你需要同时做四件事——扣减场次余票、生成订单记录、写入订单明细、更新用户积分。这四步必须在一个事务里,任何一步失败都要回滚。如果写在 Controller 里,你得手动开事务,代码又臭又长;写在 Service 里,一个@Transactional注解就解决了。
@Service public class OrderServiceImpl implements OrderService { @Autowired private ScheduleMapper scheduleMapper; @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; // 整个下单流程在一个事务里,任一步抛异常全部回滚 @Override @Transactional(rollbackFor = Exception.class) public OrderResult createOrder(Integer userId, Integer scheduleId, Integer seatCount) { // 1. 乐观锁扣减余票,affectedRows 为 0 说明票被抢光 int affected = scheduleMapper.reduceStock(scheduleId, seatCount); if (affected == 0) { throw new BizException("余票不足,请重新选择场次"); } // 2. 生成订单主记录 Order order = new Order(); order.setUserId(userId); order.setScheduleId(scheduleId); order.setSeatCount(seatCount); order.setStatus(0); // 0 待支付 orderMapper.insert(order); // 3. 更新用户积分,积分规则写在 Service 而不是 SQL 里,方便调整 userMapper.addPoints(userId, seatCount * 10); return new OrderResult(order.getId(), "下单成功"); } }这段代码的关键点有三个。第一,@Transactional的rollbackFor必须写Exception.class,因为 Spring 默认只对运行时异常回滚,业务里抛的自定义异常如果是受检异常就不会回滚,这是血泪经验。第二,扣库存用update ... set stock = stock - #{count} where id = #{id} and stock >= #{count}这种写法,靠数据库行锁保证并发安全,比先查再改可靠得多。第三,积分规则写在 Java 里而不是 SQL 里,后面产品说「积分翻倍」你改一个数字就行,不用动数据库。
2.2 目录结构与 Maven 依赖的最小集合
分层讲完,落到工程结构。我一般用这样的包结构,清晰且答辩好讲:
com.example.movie ├── controller // 接收请求,参数校验,返回 JSON ├── service │ ├── impl // 业务逻辑实现,事务边界在这里 │ └── MovieService.java ├── mapper // MyBatis 接口,只做数据访问 ├── entity // 数据库实体,和表一一对应 ├── dto // 前端传参和返回对象,和 entity 分开 ├── config // Spring、MyBatis、事务配置 └── common // 统一返回结果、异常处理、工具类Maven 依赖不用贪多,下面这几个是跑起来的最小集合:
<dependencies> <!-- Spring 核心,版本用 5.3.x 稳定 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> <!-- SpringMVC,处理 web 请求 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.30</version> </dependency> <!-- MyBatis 与 Spring 整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 驱动,8.x 对应数据库 8.0 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 连接池,Druid 监控页面答辩时是加分项 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> <!-- JSON 处理 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.3</version> </dependency> </dependencies>参数说明:Spring 5.3.x 是 5.x 最后一个稳定分支,兼容 JDK 8 到 17,毕业设计环境一般用 JDK 8 或 11,选它最稳。MyBatis 用 3.5.x 配合 mybatis-spring 2.1.x,注意 mybatis-spring 2.x 要求 Spring 5.x,别混用 1.x 版本,否则启动报NoClassDefFoundError。MySQL 驱动 8.x 的类名是com.mysql.cj.jdbc.Driver,URL 要带时区参数serverTimezone=Asia/Shanghai,否则连接报时区错误。
2.3 Spring 与 MyBatis 整合的两个配置文件
整合的核心是把SqlSessionFactory交给 Spring 管理,同时开启 Mapper 接口扫描。我一般用 Java Config 而不是 XML,因为答辩时讲起来更直观,也不容易写错命名空间。
@Configuration @MapperScan("com.example.movie.mapper") // 扫描 Mapper 接口,省去每个都加 @Mapper public class MyBatisConfig { @Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/movie_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("your_password"); ds.setInitialSize(5); // 初始连接数 ds.setMaxActive(20); // 最大连接数,毕业设计 20 足够 ds.setValidationQuery("SELECT 1"); // 连接有效性检测 return ds; } @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); factory.setDataSource(dataSource); // 开启驼峰映射,user_name 自动映射到 userName org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); factory.setConfiguration(config); return factory.getObject(); } }@MapperScan的值必须和你的 Mapper 包路径完全一致,写错了启动时报No qualifying bean of type 'xxxMapper'。mapUnderscoreToCamelCase打开后,数据库的movie_name会自动映射到实体的movieName,省掉大量resultMap配置,但前提是你的字段命名规范,别一会儿下划线一会儿驼峰。连接池的maxActive别设太大,本地开发 20 足够,设成 100 反而可能因为数据库最大连接数限制导致获取连接超时。
3. 电影网站的数据模型:多对多关系怎么落地才不返工
数据模型是电影网站的地基,地基歪了后面全是补丁。这一章把核心表设计、多对多关系处理、以及图片和分类的存储方案讲清楚,每个决策都给出理由。
3.1 核心表结构与字段类型选择
电影网站最少需要这几张表:用户表、电影表、分类表、电影分类关联表、评论表、订单表、场次表。下面给出关键表的建表语句,重点看字段类型和索引。
-- 电影表:核心表,字段类型要经得起推敲 CREATE TABLE `movie` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `movie_name` VARCHAR(100) NOT NULL COMMENT '电影名', `director` VARCHAR(50) DEFAULT NULL COMMENT '导演', `actors` VARCHAR(255) DEFAULT NULL COMMENT '主演,逗号分隔', `release_date` DATE DEFAULT NULL COMMENT '上映日期', `duration` SMALLINT UNSIGNED DEFAULT NULL COMMENT '时长,分钟', `score` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分,一位小数', `poster_url` VARCHAR(255) DEFAULT NULL COMMENT '海报地址', `intro` TEXT COMMENT '简介', `status` TINYINT DEFAULT 1 COMMENT '1 上架 0 下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_name` (`movie_name`), -- 按名称搜索 KEY `idx_status` (`status`) -- 按状态过滤 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 分类表 CREATE TABLE `category` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `cate_name` VARCHAR(30) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 电影分类关联表:多对多的关键,联合主键防重复 CREATE TABLE `movie_category` ( `movie_id` INT UNSIGNED NOT NULL, `category_id` INT UNSIGNED NOT NULL, PRIMARY KEY (`movie_id`, `category_id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段类型的选择理由:score用DECIMAL(3,1)而不是FLOAT,因为浮点数存 8.7 可能变成 8.699999,前端显示会出问题,DECIMAL 精确存储。duration用SMALLINT UNSIGNED,最大 65535 分钟足够,比 INT 省空间。intro用TEXT而不是VARCHAR(2000),因为简介长度不可控,TEXT 不占行内空间。关联表用联合主键(movie_id, category_id),天然防止同一部电影重复关联同一分类,比加唯一索引更直接。
3.2 多对多查询:一次查出电影及其分类
多对多最麻烦的是查询。列表页要显示每部电影的分类标签,如果先查电影再循环查分类,就是典型的 N+1 问题,20 部电影要发 21 条 SQL。正确做法是用LEFT JOIN一次查出来,在 Java 里组装。
<!-- MovieMapper.xml 中的列表查询 --> <select id="selectMovieWithCategory" resultMap="MovieWithCategoryMap"> SELECT m.id, m.movie_name, m.score, m.poster_url, c.id AS cate_id, c.cate_name FROM movie m LEFT JOIN movie_category mc ON m.id = mc.movie_id LEFT JOIN category c ON mc.category_id = c.id WHERE m.status = 1 ORDER BY m.create_time DESC LIMIT #{offset}, #{limit} </select> <!-- 自定义 resultMap 处理一对多嵌套 --> <resultMap id="MovieWithCategoryMap" type="com.example.movie.dto.MovieVO"> <id property="id" column="id"/> <result property="movieName" column="movie_name"/> <result property="score" column="score"/> <result property="posterUrl" column="poster_url"/> <!-- collection 把同一电影的多个分类聚合成 List --> <collection property="categories" ofType="com.example.movie.entity.Category"> <id property="id" column="cate_id"/> <result property="cateName" column="cate_name"/> </collection> </resultMap>逻辑说明:LEFT JOIN保证即使电影没有分类也能查出来。resultMap里的<id>标签很关键,MyBatis 靠它判断哪些行属于同一部电影,如果写成<result>而不是<id>,同一部电影的多个分类会被拆成多条记录,前端看到重复电影。collection的ofType是集合元素类型,不是集合本身类型,写错会报类型转换异常。
参数说明:LIMIT #{offset}, #{limit}中 offset 是起始行,limit 是每页条数,分页参数在 Service 里算:offset = (pageNum - 1) * pageSize。别用LIMIT #{pageNum}, #{pageSize}直接传页码,那是常见错误,第二页会从第 2 行开始而不是第 11 行。
3.3 图片存储:存路径还是存二进制
海报图片的存储方案直接影响数据库性能和部署难度。我见过把图片转成 Base64 存进 TEXT 字段的,一部电影海报几百 KB,列表页查 20 部就是几 MB 的数据传输,数据库直接拖垮。
常见做法有三种,对比如下:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 存文件路径 | 数据库轻量,图片可 CDN 加速 | 需管理文件目录,迁移要同步 | 毕业设计首选 |
| 存 Base64 | 无需文件服务,单表搞定 | 数据库膨胀,查询慢 | 仅头像等小图 |
| 存对象存储 URL | 扩展性好,不占服务器 | 需第三方服务,有成本 | 生产环境 |
毕业设计我一般选第一种:图片上传到服务器某个目录,数据库只存相对路径如/upload/poster/20250101_abc.jpg。上传接口用 SpringMVC 的MultipartFile接收,注意配置MultipartResolver的最大文件大小,默认是 1MB,海报图很容易超。
@PostMapping("/upload") public Result uploadPoster(@RequestParam("file") MultipartFile file) throws IOException { // 校验文件类型,防止上传脚本文件 String original = file.getOriginalFilename(); String suffix = original.substring(original.lastIndexOf(".")).toLowerCase(); if (!Arrays.asList(".jpg", ".jpeg", ".png", ".webp").contains(suffix)) { return Result.fail("只支持 jpg/png/webp 格式"); } // 用时间戳重命名,避免中文名和重名问题 String newName = System.currentTimeMillis() + suffix; File dest = new File("/data/upload/poster/" + newName); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); // 目录不存在就创建 } file.transferTo(dest); return Result.success("/upload/poster/" + newName); }关键点:文件后缀必须校验,否则有人上传.jsp文件再访问就可能执行脚本,这是安全红线。重命名用时间戳而不是原文件名,避免中文乱码和覆盖。transferTo的目标目录要提前确认存在,Spring 不会自动建目录,报FileNotFoundException多半是这个原因。
4. 从建库到跑通第一个接口:可复现的操作顺序
这一章给出一条从零到能访问接口的完整路径,每一步都有命令和验证方法。按这个顺序走,能避开大部分环境问题。
4.1 建库建表与初始化数据
先建库,字符集必须用utf8mb4,否则电影名里的特殊字符和 Emoji 存不进去。
CREATE DATABASE movie_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE movie_db; -- 执行前面的建表语句,再插入测试数据 INSERT INTO category (cate_name) VALUES ('动作'), ('喜剧'), ('科幻'), ('爱情'); INSERT INTO movie (movie_name, director, score, status) VALUES ('示例电影一', '某导演', 8.5, 1), ('示例电影二', '某导演', 7.2, 1); INSERT INTO movie_category (movie_id, category_id) VALUES (1, 1), (1, 3), (2, 2);验证方法:执行SELECT m.movie_name, c.cate_name FROM movie m LEFT JOIN movie_category mc ON m.id = mc.movie_id LEFT JOIN category c ON mc.category_id = c.id;,应该看到示例电影一对应动作和科幻两个分类,示例电影二对应喜剧。如果分类显示为 NULL,检查关联表数据是否插入成功。
4.2 web.xml 与 SpringMVC 配置
传统 SSM 项目用 web.xml 启动,虽然现在流行 SpringBoot,但毕业设计用传统方式更能体现你对原理的理解。核心配置是DispatcherServlet和ContextLoaderListener。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="4.0"> <!-- Spring 容器,加载 Service 和 Mapper --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:applicationContext.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- SpringMVC 前端控制器,加载 Controller --> <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> <!-- 字符编码过滤器,解决 POST 中文乱码 --> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>url-pattern写/而不是/*,区别在于/只拦截静态资源以外的请求,/*会连 jsp 都拦截导致死循环。forceEncoding设为 true 保证响应也强制 UTF-8,否则返回的 JSON 中文可能乱码。这两个参数是排查乱码问题的第一站。
4.3 写第一个查询接口并验证
Controller 只做参数接收和结果返回,业务逻辑调 Service。
@RestController @RequestMapping("/api/movie") public class MovieController { @Autowired private MovieService movieService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize) { // 参数边界校验,防止负数导致 SQL 报错 if (pageNum < 1) pageNum = 1; if (pageSize < 1 || pageSize > 50) pageSize = 10; List<MovieVO> list = movieService.listMovies(pageNum, pageSize); return Result.success(list); } }验证步骤:启动 Tomcat 后访问http://localhost:8080/api/movie/list?pageNum=1&pageSize=5,应该返回 JSON 数组。如果返回 404,检查@RequestMapping路径和 web.xml 的 url-pattern;如果返回 500 且日志有Invalid bound statement,说明 Mapper XML 的 namespace 或 id 和接口对不上;如果返回空数组但数据库有数据,检查status过滤条件和分页参数。
5. 避坑与排查:那些让项目卡半天的典型问题
这一章记录 5 个我实际踩过或帮别人排查过的问题,每个都按现象、原因、解决三步写,照着查能省大量时间。
5.1 启动报 DataSource 初始化失败
现象:Tomcat 启动时报Error creating bean with name 'dataSource',后面跟着Access denied for user。
原因:数据库用户名密码错误,或者 MySQL 8 的认证插件caching_sha2_password和旧版驱动不兼容。
解决:先确认用户名密码能在命令行mysql -u root -p登录成功。如果是认证插件问题,执行ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';切换插件,或者升级驱动到 8.0.x。注意改完要FLUSH PRIVILEGES;。
5.2 查询返回字段全是 null
现象:接口返回 200,但对象里字段都是 null,数据库明明有值。
原因:数据库字段是下划线命名,实体是驼峰命名,但没开启驼峰映射。
解决:在 MyBatis 配置里加config.setMapUnderscoreToCamelCase(true),或者在 XML 里手写resultMap做映射。前者更省事,但要求命名严格对应,movie_name只能映射到movieName,映射到movie_name或movieNAME都不行。
5.3 事务不回滚
现象:下单接口抛异常了,但库存已经扣了,订单也生成了。
原因:@Transactional没生效,常见有三种:方法不是 public、同类内部调用、异常类型不在回滚范围。
解决:确认注解方法用public修饰;如果是在同一个类里 A 方法调 B 方法,B 的事务不会生效,要拆到不同类或用代理;加rollbackFor = Exception.class覆盖受检异常。另外确认配置类上加了@EnableTransactionManagement。
5.4 分页第二页数据重复
现象:第一页显示 1 到 10 条,第二页还是从第 1 条开始。
原因:LIMIT参数传的是页码而不是偏移量。
解决:LIMIT第一个参数是偏移量,公式是(pageNum - 1) * pageSize。在 Service 里算好再传给 Mapper,别在 SQL 里写LIMIT #{pageNum}, #{pageSize}。这个错误很隐蔽,因为第一页偏移量是 0,看起来正常,翻页才暴露。
5.5 静态资源 404
现象:接口能访问,但 CSS、JS、图片全部 404。
原因:DispatcherServlet的 url-pattern 是/,把所有请求都拦截了,包括静态资源。
解决:在 spring-mvc.xml 里加<mvc:default-servlet-handler/>,让找不到的请求交回给 Tomcat 默认 Servlet 处理。或者用<mvc:resources mapping="/static/**" location="/static/"/>显式映射静态资源目录。前者更省事,后者更可控。
6. 让答辩加分的一个技巧:用 AOP 统一记录接口耗时
功能跑通只是及格线,答辩想拿高分得展示你对工程化的理解。这里给一个具体技巧:用 Spring AOP 给所有 Controller 方法加耗时统计,既能体现你懂切面编程,又能在演示时拿出真实数据说明性能。
思路是定义一个切面,拦截controller包下所有方法,记录执行时间并打印。代码不长,但讲清楚原理能撑起一个答辩问题。
@Aspect @Component public class ApiLogAspect { private static final Logger log = LoggerFactory.getLogger(ApiLogAspect.class); // 切点:controller 包下所有类的所有方法 @Pointcut("execution(* com.example.movie.controller..*(..))") public void apiPoint() {} // 环绕通知,能拿到方法执行前后的时间 @Around("apiPoint()") public Object logCost(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); String method = joinPoint.getSignature().toShortString(); try { Object result = joinPoint.proceed(); // 执行原方法 long cost = System.currentTimeMillis() - start; // 超过 500ms 打警告,方便定位慢接口 if (cost > 500) { log.warn("[慢接口] {} 耗时 {}ms", method, cost); } else { log.info("[接口] {} 耗时 {}ms", method, cost); } return result; } catch (Throwable e) { long cost = System.currentTimeMillis() - start; log.error("[异常] {} 耗时 {}ms,异常:{}", method, cost, e.getMessage()); throw e; // 异常必须继续抛,否则全局异常处理器收不到 } } }参数说明:execution(* com.example.movie.controller..*(..))中第一个*是返回类型任意,..表示包及子包,第二个*是方法名任意,(..)是参数任意。@Around比@Before+@After好在能拿到返回值和控制执行时机。joinPoint.proceed()必须调用,否则原方法不执行。异常捕获后要重新抛出,不然事务和全局异常处理都会失效。
启用 AOP 需要在配置类加@EnableAspectJAutoProxy,并引入aspectjweaver依赖。演示时你可以故意调一个没加索引的查询,让日志打出「慢接口」,然后现场加索引对比耗时,这个操作比背概念有说服力得多。
我自己的习惯是每套项目都留一个这样的切面,不只为答辩,调试时哪个接口慢一眼就能看到,比在几十个方法里加System.currentTimeMillis()干净得多。做毕业设计别只盯着功能清单,留一两个能讲出所以然的工程细节,往往比多写三个 CRUD 页面更值钱。希望帮到你。
本文还有配套的精品资源,点击获取