简介:基于Spring Boot与MySQL实现的爱看电影网站设计与实现项目,是一份面向计算机相关专业课程设计与毕业设计的工程资料。项目围绕影库信息管理展开,重点实现影片简介、电影推荐、图片展示等内容的动态更新与数据库持久化,能够帮助读者掌握Spring Boot整合MySQL的常见开发模式。资源共21个文件,压缩包大小12.41MB,包含完整项目源码、pom.xml与iml工程配置、两个可直接导入的SQL数据库脚本,以及用于说明系统设计与实现思路的论文文档;另附10张运行截图,便于对照页面效果快速理解。已有568人学习/下载,适合正在完成电影网站类课题、希望参考完整项目结构的学生使用。借助压缩包中的完整内容,可以完成从数据库导入、项目导入运行到功能调试的完整实践,也能为课程设计报告、毕业设计论文的撰写提供对照素材;其中信息管理模块的实现逻辑清晰,可作为学习Spring Boot业务分层开发的入门参考,同时压缩包内也提供了从数据表设计、后端接口到前端页面展示的一整套方案,具有较强的可操作性与参考价值。
1. 从“爱看电影”到 Spring Boot + MySQL:这个选题真正在练什么
“基于 Spring Boot + MySQL 的爱看电影网站设计与实现”这类选题,几乎是每年课程设计和毕业设计里出现频次最高的组合之一。很多人以为它是个花架子网站,实际上它正好覆盖了后端项目最值得练的三块内容:关系型数据怎么建模、HTTP 请求怎么通过四层架构落到数据库、以及检索和推荐这类“看起来是搜索引擎功能、实际用 SQL 也能做”的业务。Spring Boot 负责把接口、事务、状态管理串联起来,MySQL 则负责把影片、用户、评分这些强关系数据稳定地存下来。
这篇文章按我自己实现这类项目的顺序走:先设计表结构和索引,再写四层架构的后端代码,然后用 SQL 与内存算法处理电影检索和简单推荐,最后补上 Actuator 暴露、时区、事务一致性这些上线前必须处理的细节。整个方案不依赖任何前端框架,适合刚学完 Spring Boot 基础、想自己完整搭出一套项目的人,也适合带新人的工程师用来拆解业务边界。
2. Spring Boot + MySQL 的电影数据模型设计:从表结构到索引
2.1 用三张核心表承接业务:用户、影片、评分
拿到“爱看电影网站”这样的题目,第一件事不是写启动类,而是先想接口。接口基本能反推出表的边界:用户注册登录、影片列表、影片详情、关键词搜索、评分、收藏。把这些接口落到 MySQL 里,第一版我不建议拆太多表,用四张就够:用户表、影片表、评分表、收藏表。表拆得太细,后续写 JOIN 和事务时一致性问题会成倍增加。
这里有一个容易被讲师挑刺的问题:为什么不用一个“记录”表把评分和收藏合在一起?因为评分有数值,收藏没有,而且统一表里的score字段会大量为 NULL。MySQL 对 NULL 的索引处理和统计都不如具体值友好,所以拆开更干净。评分表用联合主键天然防止同一个用户对同一部电影重复评分,这个方案比应用层判重更可靠。
| 表名 | 核心字段 | 主要职责 |
|---|---|---|
user | id, username, password_hash, avatar_url, created_at | 账号与登录凭证 |
movie | id, title, directors, actors, genre, cover_url, released_at, summary | 影片元数据 |
score | user_id, movie_id, score, created_at | 用户评分记录 |
favorite | user_id, movie_id, created_at | 收藏关系 |
score和favorite里不单独放自增主键,直接用(user_id, movie_id)做联合主键。好处是数据库层面阻止重复评分,坏处是后续想按“评分时间倒序”拉数据时,这个主键起不到排序作用,所以需要给created_at单独建一个辅助索引。不要小看这种设计,真实项目里“用户多次评分”和“用户收藏了又取消”是很常见的需求,联合主键会在细节处保护数据。
2.2 建表 SQL 与 MySQL 字符集、时区设置
在 MySQL 8.0 环境下我习惯这样建表。注意 8.0 默认字符集已经是utf8mb4,但为了部署到老版本数据库时不踩坑,仍然显式声明。
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `password_hash` varchar(100) NOT NULL, `avatar_url` varchar(255) DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE `movie` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(120) NOT NULL, `directors` varchar(255) DEFAULT NULL, `actors` varchar(500) DEFAULT NULL, `genre` varchar(100) DEFAULT NULL, `cover_url` varchar(500) DEFAULT NULL, `released_at` date DEFAULT NULL, `summary` text, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_genre` (`genre`), KEY `idx_released` (`released_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE `score` ( `user_id` bigint NOT NULL, `movie_id` bigint NOT NULL, `score` tinyint NOT NULL COMMENT '1-10 分', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`, `movie_id`), KEY `idx_movie_score` (`movie_id`, `score`), CONSTRAINT `fk_score_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_score_movie` FOREIGN KEY (`movie_id`) REFERENCES `movie` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;这段 SQL 里有两个容易忽略的细节。score表我保留了物理外键,因为课程设计阶段需要展示“参照完整性”,但实际生产环境通常会把外键从表上拿掉,改由 Service 层保证逻辑一致性,否则高并发写入时 MySQL 要额外做外键校验,锁范围会变大。idx_movie_score索引是专门给“某部电影的平均分”和“按评分排序”用的,这样WHERE movie_id = ? ORDER BY score DESC可以直接走覆盖索引,不需要回表读整行。
MySQL 安装配置做完后,还要处理my.ini或容器环境里的时区。我遇到过最典型的问题是 JDBC 连接时日期比北京时间早 8 小时,原因是 MySQL 的time_zone是SYSTEM,而系统时区被当成 UTC。建议在连接 URL 里显式加上serverTimezone=Asia/Shanghai,并且在建表时把DEFAULT CURRENT_TIMESTAMP作为时间字段的默认值,这样插入时不会因为代码里没 set 时间而出现 NULL。
2.3 索引设计:常用检索条件要在 explain 里验证
索引设计不能靠猜,最终要以EXPLAIN输出为准。爱看电影网站的常见查询有几类:按类型查影片列表、按上映年份筛选、按标题模糊搜索、按用户查他打过分或收藏过的电影。前三类查询如果表只有几万行,走全表扫描也不会有体感差异;但如果数据量到百万级,或者演示现场被人用ORDER BY反复拖页,索引缺失就会立刻暴露。
索引不是越多越好。movie表我建议至少建两个组合索引:(genre, released_at)用于分类页排序,(rating_count, rating_avg)用于热门榜。不要给summary这种text字段建常规索引,MySQL 对长文本字段的索引有长度限制,强行建会导致整表写入变慢。热搜里常提到的“mysql 创建索引”和“mysql 排序”通常就是这类问题,核心结论是:排序字段要和过滤字段一起进复合索引,才能避免 filesort。
ALTER TABLE movie ADD INDEX idx_genre_released (genre, released_at); ALTER TABLE movie ADD INDEX idx_rating (rating_count, rating_avg);执行完加这两个索引后,用EXPLAIN SELECT * FROM movie WHERE genre = '科幻' ORDER BY released_at DESC LIMIT 20;看输出。如果Extra列还是Using filesort,说明查询字段和索引顺序不匹配。因为genre是等值过滤字段,released_at是排序字段,索引顺序也是先 genre 再 released_at,这样 MySQL 才能按索引顺序直接读取。注意:如果genre允许 NULL,这一条联合索引的等值匹配可能失效,所以建表时genre最好加NOT NULL DEFAULT '未知',看起来是琐碎的小事,却是保证索引生效的真正常见做法。
3. Spring Boot 四层架构下的后端实现:从 Repository 到 Controller
3.1 四层架构的边界,以及 JPA 与 MyBatis 的选择
很多人在 Spring Boot 项目里把业务逻辑直接写在 Controller 里,代码量少的时候很爽,一旦要做事务控制和单元测试就非常痛苦。常见规范是把项目拆成Controller、Service、Repository、Entity四层。Entity 对应 MySQL 表,Repository 封装数据访问,Service 处理业务规则和事务边界,Controller 只负责参数接收、校验和响应状态。这样做的好处是:切换数据库或换 ORM 时,Service 层不必改动。
具体到爱看电影网站,我推荐用 Spring Data JPA 起步,因为资源型 CRUD 用 JPA 能少写很多样板 SQL。如果你点的技术栈是 MyBatis,也没有问题,只是要自己维护 XML 或注解 SQL。热搜里“spring boot jparepository 这个是什么”问题的本质,就是一个已经帮你实现了findById、save、findAll等常见方法的接口,你只需要声明方法名和参数。对当前项目,extends JpaRepository<User, Long>就自动拥有了单表增删改查能力,复杂查询用@Query写原生 SQL,我一般都按这个思路做。
3.2 用户注册与影片分页接口的落地代码
注册接口是第一个要写的,因为它同时用到 Entity、Repository 和事务效果。用户密码一定不能存明文,password_hash字段建议使用 BCrypt 哈希,Spring Security 内置的BCryptPasswordEncoder可以直接注入,不需要为了它把整个安全框架的功能都实现一遍。
@Entity @Table(name = "user") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String passwordHash; private String avatarUrl; private LocalDateTime createdAt; } public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); } @Service public class UserService { private final UserRepository userRepository; private final BCryptPasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, BCryptPasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } @Transactional public User register(String username, String rawPassword) { if (userRepository.findByUsername(username).isPresent()) { throw new IllegalArgumentException("用户名已存在"); } User user = new User(); user.setUsername(username); user.setPasswordHash(passwordEncoder.encode(rawPassword)); return userRepository.save(user); } }注册接口这里要留意两点。@Transactional放在 Service 而不是 Controller,是为了让事务作用在整个注册逻辑上;如果 Service 里抛异常,save操作会整体回滚。另一个点是findByUsername返回Optional,这样调用方必须显式处理“查不到”的情况,而不是等到空指针才去排查。
影片分页接口通常被做成两个:一个供列表页使用,一个供详情页使用。列表页分页参数page和size必须做上限限制,我发现很多新手把size直接透传给数据库,别人请求size=10000一下就把后端打垮。Spring Data JPA 的分页可以用下面的写法:
@GetMapping("/api/movies") public Page<Movie> listMovies(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(required = false) String genre) { if (page < 1) page = 1; if (size < 1 || size > 48) size = 12; Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "releasedAt")); return movieService.findByGenre(genre, pageable); }注意这里的 Page 从 1 开始对客户端友好,但PageRequest.of接收的是从 0 开始的页码,所以要page - 1。如果genre为空字符串,应该在 Service 里转成null,否则genre = ''会被当成一个真实值,查询结果为空。这种参数语义问题是联调时最常见的返工地段,受伤一次之后你就会养成写参数校验的习惯。
3.3 文件上传与海报图存储:图片不落数据库
“spring boot 上传文件”是项目里躲不开的点,因为电影封面必须支持管理员上传。正确做法是图片文件存磁盘或对象存储,MySQL 里只保存cover_url字符串。如果把图片转成byte[]放进数据库,单表会迅速膨胀,备份和查询都会变慢。
@PostMapping("/api/upload") public String upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new IllegalArgumentException("文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename != null ? originalFilename.substring(originalFilename.lastIndexOf('.')) : ".jpg"; String storedName = UUID.randomUUID().toString().replace("-", "") + ext; Path dest = Path.of(uploadDir).resolve(storedName).normalize(); if (!dest.startsWith(Path.of(uploadDir))) { throw new SecurityException("非法路径"); } file.transferTo(dest); return "/upload/" + storedName; }文件名使用 UUID 是刻意为之。直接用用户上传的文件名有两个风险:一是包含../会被拼出目录穿越路径,二是中文文件名在不同操作系统下编码不同。我已经用normalize()和startsWith做了二次校验,即使原始文件名里有攻击性字符也会被挡在外面。/upload/目录需要配置静态资源映射,在application.yml里追加spring.web.resources.static-locations或自定义WebMvcConfigurer的addResourceHandlers。这里数据库永远只存相对路径,页面拼接域名后访问,当后续迁移到 CDN 时只需要改配置,不需要动数据。
4. 电影检索、排序与简单推荐:SQL 与内存算法的结合
4.1 关键词搜索:LIKE 的适用场景与全文索引的分界线
爱看电影网站的搜索框最常见的实现就是按影片标题模糊匹配,也就是 MySQL 的LIKE '%关键词%'。这种写法在数据量小时没什么问题,几万行以内加不加索引区别不大。真正的坑在于 SQL 注入,不少人喜欢用字符串拼接,"%'" + keyword + "'%"一旦被传入单引号就会破坏语句结构。正确写法是用参数占位符或concat函数:
SELECT * FROM movie WHERE title LIKE CONCAT('%', #{keyword}, '%') ORDER BY released_at DESC LIMIT 20;LIKE '%keyword%'由于前后都有通配符,常规 B+ Tree 索引帮不上忙,只能走全表或扫描范围。这是 MySQL 索引设计里的常识性边界,也是面试常问的“为什么模糊查询会全表扫”。如果你有中文分词需求,比如搜索“科幻 动作”要拆成两个词,LIKE就完全不够用了,常见做法是引入全文索引或 ES。对于课程设计级别的数据量,在 MySQL 里加FULLTEXT索引并用MATCH AGAINST已经足够,但注意全文索引对中文支持不如英文平滑,需要 ngram 分词器,所以我会优先用LIKE配合合理评分排序,让演示结果更快出来。
4.2 基于评分与评分人数的排序逻辑
排行榜页面几乎每家公司都写过,核心是“评分高的人多才靠前”。如果只按AVG(score)排序,一部只有一个人打了 10 分的冷门电影会排第一,这在产品上完全没有说服力。常用算法是给平均分一个置信度下限,最简化的 SQL 写法如下:
SELECT m.id, m.title, AVG(s.score) AS avg_score, COUNT(s.score) AS rating_count FROM movie m LEFT JOIN score s ON s.movie_id = m.id GROUP BY m.id, m.title HAVING rating_count >= 5 ORDER BY avg_score DESC, rating_count DESC LIMIT 10;HAVING rating_count >= 5是一个可调参数。注意执行顺序:WHERE在分组前过滤,HAVING在分组后过滤,所以评分人数的过滤不能写在WHERE。另外LEFT JOIN要连上,因为一部新电影可能一条评分都没有,如果用INNER JOIN,它会从排行榜里消失。ORDER BY avg_score DESC, rating_count DESC里第二排序字段是“比分数相同的情况下,人多的靠前”,这个细节在实际评审演示时比任何华丽代码都更能体现你理解业务。
如果要支持前端按类型和地区筛选,就不要在 Java 代码里拼接 50 个if。用 QueryDSL 或 JPA Specifications 可以用代码生成动态查询,但学起来有成本。我的习惯是先用@Query写一条包含:genre和:keyword的原生 SQL,分别判断空字符串时用OR 1=1或<script>标签拼条件。注意在原生 SQL 中,排序字段的拼接必须做白名单校验,否则会出现 SQL 注入。项目中直接让前端传sort=avg_score而不是sort=AVG(s.score),在后端做一个Map映射,比“校验字符串是否合法”更安全,这是我在生产项目里常用的技巧。
4.3 简单协同过滤:用 MySQL 找出“喜欢同一部电影的人”
推荐算法听起来高级,但爱看电影网站的推荐逻辑用 SQL 就能跑出结果。最简单的思路叫基于记忆的协同过滤:先找出你要推荐的用户 A 评分过的电影,再找那些在这些电影上也给出了高分的用户群,最后把这个用户群高分评价、但 A 没看过的电影推荐出去。这个逻辑不用套框架,全链路用 SQL 表达如下:
SELECT m.id, m.title, AVG(h.score) AS predict_score FROM score h JOIN score a ON h.movie_id = a.movie_id AND a.user_id = 101 AND h.user_id != 101 AND h.score >= 8 JOIN movie m ON m.id = h.movie_id WHERE NOT EXISTS ( SELECT 1 FROM score seen WHERE seen.user_id = 101 AND seen.movie_id = h.movie_id ) GROUP BY m.id, m.title ORDER BY predict_score DESC LIMIT 5;这段 SQL 里a代表用户 101 已打分的电影,h代表其他用户对同一部电影的打分。NOT EXISTS子查询保证结果不会把用户已经看过的电影再推荐一遍。这样做的问题是第一版只能推荐“用户看了好评电影后,其他高分电影的‘重复曝光’”,无法覆盖长尾兴趣,但对演示目的已经非常充分。性能上这个查询会扫score表的很大范围,所以评分表上的(movie_id, score)联合索引在这时就会起到关键作用。如果数据量超过十万条评分,我会在后续优化里把“相似用户计算”做成定时任务,把相似结果先算好落到一张关系表里,而不是每次请求都实时跑一组 JOIN。
考察这个标题下完整搜索引擎的需求,“存储过程”也是热搜词之一。这里虽然没必要为了用而用,但如果你的数据库表结构固定、推荐逻辑也要复用,就可以把上面的推荐 SQL 封装成 MySQL 存储过程get_recommendations_for_user(userId INT)。存储过程的好处是网络传输只传结果集不传 SQL,逻辑变更时不用重新部署 Java 服务。但坏处是版本管理差,测试困难,我的建议是只在需要把复杂查询固定下发给 DBA 审核的场景才用,项目代码里还不至于依赖它。
5. 上线前必做的验证与排错:从 Actuator 到 MySQL 事务
5.1 Spring Boot Actuator 未授权访问的修复
如果工程里引入了spring-boot-starter-actuator,默认行为并不是只暴露健康检查。旧版本中不少端点会暴露出来,比如/actuator/env可以读出环境变量,/actuator/heapdump能直接把 JVM 堆下载下来,里面很可能含有数据库密码、接口密钥甚至用户的登录态。因此标题里“设计与实现”之后一定要有一节教读者做防护,否则本地运行没事,一放到比赛或演示环境就会翻车。
management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: never上面这段配置的意思是仅开放health、info和metrics,关闭env、heapdump、shutdown等敏感接口。注意show-details: never也很重要,否则health接口会透露数据库和磁盘的状态明细,给攻击者提供探测信息。如果服务需要被监控系统定期抓取指标,则可以在内网单独开一个 actuator 端口,并用server.address=127.0.0.1限定来源,这样既保住了监控能力,也不把端点暴露到公网。
5.2 时区、编码、连接池导致的三类经典报错
MySQL 部署后最容易踩到的第一个坑是Communications link failure或The server time zone value 'CST' is unrecognized。解决办法在 JDBC URL 上补参数?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8&useSSL=false&allowPublicKeyRetrieval=true。这里的useSSL=false只是为了避免本地调试时证书报错,生产环境如果要开 SSL 则应当使用受信任的证书而不是直接关掉。
第二类是中文乱码。乱码通常不是 Java 代码的问题,而是 MySQL 客户端连接字符集和表字符集不一致。连接参数已经加了characterEncoding=UTF-8,建库时还要确认DEFAULT CHARSET=utf8mb4。如果数据已经插入,用ALTER TABLE movie CONVERT TO CHARACTER SET utf8mb4;可以修复,但注意这会对表加元数据锁,线上大表要在低峰执行。
第三类是连接池耗尽。Spring Boot 默认的 HikariCP 最大连接数只有 10,如果演示时有人开多个浏览器标签,或者前端并发发三次请求,连接池很容易被慢查询占满。可以按机器配置调整:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 10000这里的connection-timeout是拿不到连接时的等待时间,而不是连接持久的生命周期。把它设成 10 秒是为了快速失败,不然接口会一直挂起,用户端看起来就像“网站卡死了”。实际压测时从SELECT * FROM information_schema.processlist能快速看到哪些 SQL 占了连接,这是排查连接池问题时最快捷的路。
5.3 用事务与锁保证“评分 + 统计”的一致性
网站里评分按钮触发的操作不止是插入一条score,通常还要同步把电影表的评分次数和评分总分更新一下,否则每次列表页都要做昂贵的AVG聚合。这两个更新必须处于同一个事务。我用一个显式事务接口来说明:
@Transactional public void rateMovie(Long userId, Long movieId, int score) { if (score < 1 || score > 10) { throw new IllegalArgumentException("评分必须在 1-10 之间"); } scoreRepository.save(new Score(userId, movieId, score)); movieRepository.increaseRatingCount(movieId, score); }这里有一个并发隐患:两个用户同时首次给同一部电影评分,increaseRatingCount如果是UPDATE movie SET rating_count = rating_count + 1,InnoDB 的行锁会保证最终正确。但如果你先用SELECT rating_count查出来,在 Java 里加一再写回去,就会出现丢失更新。所以我服务器端写的是原子递增 SQL,不读取原始值。
如果数据库里要支持“当前评分人数正好达到某个阈值时更新电影标签”,那就需要SELECT * FROM movie WHERE id = ? FOR UPDATE手动加锁。注意FOR UPDATE要放在事务里才会生效,并且务必在事务内尽早提交或回滚,否则锁会一直被持有到事务结束。评分场景最怕“把长事务开了 20 秒没关闭”,这会让所有同电影的查询全部阻塞。爱看电影网站这种场景,用一个很小的显式锁,配合事务边界,就足够在演示时展示“避免超卖”那个经典的并发问题。
最后一个实用技巧:验证推荐逻辑时,不要只查一条 SQL 的结果对不对,而是在 Navicat for MySQL 或 MySQL Workbench 里手工造 10 条用户评分数据,分别覆盖“数万人高分好评”和“只有一条满分”两种边界,然后对照接口返回顺序。这一步不需要多写代码,却是评审现场最能让评委相信“你懂数据”的做法。毕竟这个项目最终交付的是一个 .zip 压缩包,而压缩包里的代码能否跑通并解释清楚,才是唯一有意义的验收标准。
本文还有配套的精品资源,点击获取