☰
电影数据库课程设计全流程:选型、建表、清洗、分析与报告
2026/10/9 18:23:25 网站建设 项目流程

简介:这是一套基于Python与MongoDB的WEB电影数据库课程设计完整方案,面向计算机相关专业做数据库大作业、课设或初期项目演示的学生。内容覆盖数据导入与脱敏处理、基于Flask的前后端交互、用户观影记录检索、关键词查询、风格热门榜等核心功能,并配有数据分析文档、E-R图、界面截图及操作报告,适合从入门到进阶的Python开发者参考复用。共77个文件,主要包括Python脚本、HTML/CSS/JS前端页面、Markdown文档、PNG/JPG截图和配置文件等,压缩包仅4.96MB,下载后可快速按目录定位源码、文档或数据集。已有359人学习浏览。资源为作者答辩平均96分的真实作业,所有代码均测试通过;除功能实现外,还额外整理云服务器部署、MongoDB安装与副本集配置、docker排错思路、索引优化等环境搭建经验,能有效减少踩坑成本,值得作为完整课设模板借鉴。

1. 一个电影数据库大作业的完整形态:从数据到报告都有什么

前两天有个同学拿着他的数据库大作业来找我,说是“电影数据库的数据系统”,代码写了大半,但老师问他数据从哪来、怎么保证不重复、报告里要放什么,他全卡住了。这种情况我见得很多——不是 SQL 没学会,而是把大作业理解成了“写代码”,忽略了后面的数据集、分析、文档和操作报告其实也是产品的一部分。

这门课的大作业之所以选“电影数据库”,是因为电影数据天然带多对多关系、带数值字段、带时间维度,足够把范式、外键、聚合查询、可视化全串起来。合适的对象是那些需要交一份完整课程设计的人:既要有能跑的 Python 代码,又要有数据文件、分析图表和一份能直接打印的报告。这篇文章就顺着这个交付物的顺序讲:选型、建表、造数、分析、避坑、写文档,六段走完,照着做就能凑齐一套能答辩的成果。

2. 数据库选型与六张核心表:为什么 SQLite 最适合课堂演示

2.1 为什么 SQLite 比 MySQL 更适合答辩演示:选型一句话

如果你的课程没有强制指定数据库,我的第一建议永远是 SQLite,而不是 MySQL。原因很实在:SQLite 是文件型数据库,整个数据库就是一个 .db 文件,U 盘拷走、换电脑打开、答辩现场演示,全都不需要安装服务、配置账号密码、处理端口占用。这对课程设计场景是致命的省心,你不需要在演示前半小时还在跟 MySQL 服务较劲。

MySQL 的优势是“显得更企业级”,但它带来的成本是:你得让老师那边也能跑起来,或者至少你演示的机器上服务别崩。很多翻车现场不是因为 SQL 写错,而是 MySQL 连不上。SQLite 没有这个问题,Python 标准库自带 sqlite3,不需要装任何第三方包就能开搞。

如果你的课程硬性要求 MySQL,代码切换点其实很小。连接部分从sqlite3.connect("film.db")换成pymysql.connect(host=..., user=..., password=...),占位符从?换成%s,自增字段从AUTOINCREMENT换成AUTO_INCREMENT。其余的表结构、查询逻辑、数据分析代码全部通用。我会在下面的建表语句里标注这些差异点,方便你两套都试。

2.2 建库建表:先定主表再补关联表,一次写出六张表

我一般把电影数据库拆成六张表,这个规模刚好能展示“关系型数据库”的设计思路,又不至于为了复杂而复杂:movies存电影本体,genres和movie_genres处理电影与类型的多对多,directors和movie_directors处理导演的多对多,ratings单独存打分记录,把“用户评分”和“电影信息”彻底分开。

-- 创建六张核心表(SQLite 方言) PRAGMA foreign_keys = ON; -- SQLite 默认不启用外键约束,必须手动开 CREATE TABLE movies ( movie_id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, year INTEGER NOT NULL, country TEXT, language TEXT, duration_min INTEGER, budget REAL, box_office REAL ); CREATE TABLE genres ( genre_id INTEGER PRIMARY KEY AUTOINCREMENT, genre_name TEXT UNIQUE NOT NULL ); CREATE TABLE movie_genres ( movie_id INTEGER NOT NULL, genre_id INTEGER NOT NULL, PRIMARY KEY (movie_id, genre_id), FOREIGN KEY (movie_id) REFERENCES movies(movie_id), FOREIGN KEY (genre_id) REFERENCES genres(genre_id) ); CREATE TABLE directors ( director_id INTEGER PRIMARY KEY AUTOINCREMENT, director_name TEXT NOT NULL, birth_year INTEGER ); CREATE TABLE movie_directors ( movie_id INTEGER NOT NULL, director_id INTEGER NOT NULL, PRIMARY KEY (movie_id, director_id), FOREIGN KEY (movie_id) REFERENCES movies(movie_id), FOREIGN KEY (director_id) REFERENCES directors(director_id) ); CREATE TABLE ratings ( rating_id INTEGER PRIMARY KEY AUTOINCREMENT, movie_id INTEGER NOT NULL, rating_score REAL NOT NULL CHECK (rating_score BETWEEN 1 AND 10), rating_time TEXT, user_id INTEGER, FOREIGN KEY (movie_id) REFERENCES movies(movie_id) );

这段 SQL 里有几个设计是刻意的。movie_genres和movie_directors都是典型的“桥表”,主键由两个外键联合组成,这就在物理层面保证了“同一部电影不会重复关联同一个类型或同一个导演”,不用在业务代码里做去重判断。ratings表里的CHECK约束把评分钉死在 1 到 10 之间,比你在 Python 里写if判断更可靠,因为数据库层拦截了所有非法写入。

budget和box_office我用REAL而不是INTEGER,因为金额单位可能是“万美元”也可能是“元”,用浮点能兼容小数。如果后续统计要用“票房减预算”算盈利,浮点字段直接加减即可,不用先做类型转换。这一整套表结构不依赖于某个特定数据库,MySQL 下只需把AUTOINCREMENT换成AUTO_INCREMENT,其余原样可用。

2.3 索引与约束:让统计查询不慢、数据不乱

写完表结构,紧接着要干的一件事是建索引。大作业的数据量通常是几百到几千条,这个量级任何查询都快得感觉不到索引存在,但答辩时老师经常会问“你的系统数据量大了怎么办”,索引就是标准答案。在ratings.movie_id上建索引,能让“按电影聚合评分”这种高频查询走索引扫描;在movies.year上建索引,能让所有按年份分组统计的查询稳定提速。

CREATE INDEX idx_ratings_movie ON ratings(movie_id); CREATE INDEX idx_movies_year ON movies(year);

建索引的同时,别忘了给关键的字段加唯一约束。genres.genre_name已经加了UNIQUE,这保证了“剧情”这个类型不会因为一次导入失误出现两行。同理,在清洗数据时每部电影对应一个固定的movie_id,关联表里的数据只增不删,这样后面分析阶段做那些 JOIN 查询时,才不会出现“类型占比超过 100%”这种让人摸不着头脑的结果。

3. 用 Python 灌数据:数据集构造与清洗的两个阶段

3.1 没有现成数据时:用脚本造一批贴近结构的仿真数据

很多人在数据集这步就卡住了——网上找不到“同时带导演、类型、评分、票房、时长”的干净 CSV,有版权的榜单数据又不敢直接往报告里贴。我的做法是自己造仿真数据:数据格式完全按目标表结构来,字段类型、取值范围、多对多关系全部模拟真实场景,数量可调。把这套生成脚本放进交付物里,老师反而会认为你理解了数据从哪里来。

# generate_data.py —— 生成 300 部虚构电影信息并写入 CSV import csv import random random.seed(42) title_prefix = ["暗夜", "星际", "追光", "无声", "逆风", "零度", "迷城", "远山"] title_suffix = ["行者", "边境", "回响", "航线", "黎明", "代码", "孤岛", "旅人"] genres_pool = ["剧情", "动作", "科幻", "喜剧", "悬疑", "爱情", "动画", "纪录"] director_pool = [ ("陈远航", 1960), ("林默", 1975), ("赵一舟", 1982), ("苏晚晴", 1970), ("高野", 1968), ("周明川", 1988) ] countries = ["中国", "美国", "日本", "法国", "英国"] def make_title(): return random.choice(title_prefix) + random.choice(title_suffix) def make_movie_row(movie_id): return { "movie_id": movie_id, "title": make_title(), "year": random.randint(1980, 2024), "country": random.choice(countries), "language": "未知", "duration_min": random.randint(90, 150), "budget": round(random.uniform(500, 8000), 2), "box_office": round(random.uniform(800, 30000), 2), } def main(): movies = [] for mid in range(1, 301): row = make_movie_row(mid) row["language"] = {"中国": "汉语", "美国": "英语", "日本": "日语", "法国": "法语", "英国": "英语"}[row["country"]] movies.append(row) with open("movies.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=list(movies[0].keys())) writer.writeheader() writer.writerows(movies) if __name__ == "__main__": main()

这里有两个参数值得说明。random.seed(42)让每次运行生成的数据完全一致,这一点对答辩非常关键——昨天分析出的图表,今天重新跑还能复现,不会出现“老师你看我这个结果”的时候数字对不上。写 CSV 用了encoding="utf-8-sig",这个不是玄学,是因为 Excel 直接打开 UTF-8 文件会乱码,utf-8-sig带 BOM 头,是让 Excel 正确识别中文的最低成本手段。

语言字段我刻意没有直接随机赋值,而是通过国家映射出来,这模拟了真实数据里“字段间有依赖关系”的情况。年份范围控制在 1980 到 2024,时长控制在 90 到 150 分钟,预算和票房保持正相关性,这些边界设定让你的数据看起来像真的,而不是一眼假的随机数。如果你想调整数据量,把这个脚本里的range(1, 301)改成别的数字就行,关联表的数据量请继续看 3.2 节怎么跟着扩。

3.2 有公开数据集时:三遍清洗再入库

如果你确实找到了合适的公开数据,别直接往数据库里灌,先做三遍清洗。第一遍处理编码和空值,第二遍处理类型和去重,第三遍检查关联完整性。我见过太多人把数据导进去之后,一跑统计发现 1950 年出现了一部时长 0 分钟的电影,就是因为跳过了第一遍。

# clean_data.py —— 把原始 CSV 清洗成入库标准格式 import pandas as pd df = pd.read_csv("raw_movies.csv", encoding="utf-8-sig") # 第一遍:空值与编码 df = df.dropna(subset=["title", "year"]) df = df[df["title"].str.strip() != ""] # 第二遍:类型强转 + 异常值过滤 df["year"] = pd.to_numeric(df["year"], errors="coerce").astype("Int64") df["duration_min"] = pd.to_numeric(df["duration_min"], errors="coerce") df = df[(df["year"] >= 1888) & (df["year"] <= 2024)] df = df[(df["duration_min"].isna()) | (df["duration_min"].between(30, 300))] # 第三遍:去重 df = df.drop_duplicates(subset=["title", "year"], keep="first") df.to_csv("clean_movies.csv", index=False, encoding="utf-8-sig")

这段代码的每一遍都对应一个真实场景里的高频问题。pd.to_numeric加errors="coerce"会把“1990年”这种混入了汉字的脏数据转成缺失值,而不是让程序直接报错;astype("Int64")是 pandas 里能保留 NaN 的整数类型,直接用int会在空值时崩溃。年份过滤的下限 1888 是电影史公认的第一部电影诞生年份,低于这个数字的数据一定有问题。

去重逻辑用的是title + year组合,而不是只看标题。因为不同国家完全可能存在同名电影,比如经典的“翻拍同名”,只看标题会误删。keep="first"保证重名数据留下第一行,丢掉后续所有重复行。这一步做完,数据才具备入库的基本资格。

3.3 把数据组织成文件:数据集交付物的标准姿势

很多人在交数据集的环节犯懒,直接把 CSV 往压缩包里一扔。我一般会组织成data/raw/、data/clean/、data/processed/三层结构:原始数据放 raw,清洗脚本输出放 clean,最终导入数据库的关联文件放 processed。每一层配一个README.md,写明字段含义、编码格式、每张表的行数。这份说明本身在答辩时就是加分项,它证明你不是只会跑通代码,而是对数据的生命周期有意识。

processed 目录里需要额外放两个关联文件:电影-类型关联表、电影-导演关联表。这两个文件的数据量不是电影数的 1 倍,而是取决于每部电影挂几个类型、几个导演,模拟真实世界时每部电影挂 1~4 个类型、1~2 个导演比较合理。生成方式就是在 3.1 的脚本里再加一层随机分配,控制好movie_id的范围不要超出主表即可。

4. 数据分析不是炫技:四段代码做出答辩能讲的结论

4.1 连接数据库的公共方法:一个 db.py 打通所有查询

数据分析的第一步是统一的数据库访问入口。把连接和查询封装成一个公共模块,后面所有分析脚本都用它,既避免了每写一段分析就复制一遍连接代码,也让整个项目看起来有一个“系统”的架构。这个模块非常简单,但很多人会忽略参数化查询,直接用 f-string 拼 SQL,这是比较危险的习惯。

# db.py —— 公共数据库访问模块 import sqlite3 DB_PATH = "film.db" def get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row # 让查询结果支持按列名访问 return conn def query_all(sql, params=()): conn = get_conn() rows = conn.execute(sql, params).fetchall() conn.close() return rows

conn.row_factory = sqlite3.Row这行的价值在于,返回的每一行可以像字典一样用row["title"]取字段,而不是记住第几列是标题。query_all接收sql和params两个参数,SQL 里的?占位符由 sqlite3 负责转义,数据里有单引号、百分号都不会破坏语句结构。这一点在后面所有分析脚本里都会反复使用。

4.2 评分分布与年份趋势:让 matplotlib 输出能放进报告的图

接下来是整份大作业里最出效果的部分:评分分布直方图和年份产量趋势图。这两张图几乎能应对所有“你的数据分析做了什么”的提问,因为评分分布讲质量、年份趋势讲数量,两张图结合起来就是数据全景。

# analysis.py —— 评分分布与年份趋势 import matplotlib.pyplot as plt import pandas as pd from db import query_all # 评分分布 score_rows = query_all("SELECT rating_score FROM ratings") df_score = pd.DataFrame(score_rows, columns=["rating_score"]) df_score["rating_score"] = df_score["rating_score"].astype(float) plt.rcParams["font.sans-serif"] = ["SimHei", "Arial Unicode MS", "sans-serif"] plt.rcParams["axes.unicode_minus"] = False plt.figure(figsize=(8, 5)) plt.hist(df_score["rating_score"], bins=9, range=(1, 10), edgecolor="white") plt.xlabel("评分") plt.ylabel("评分数") plt.title("用户评分分布") plt.tight_layout() plt.savefig("report/score_distribution.png", dpi=150)

评分分布这一段的调参重点是bins=9和range=(1, 10)。评分范围 1 到 10,分 9 个箱子就把 1~2、2~3 这样的区间连续切出来,不会出现边界的评分掉到图外。dpi=150保证图片放进 Word 或 PDF 后依然清晰,不是那种放大就糊的截图。

年份趋势图需要先把movies.year按区间分组,统计各年代上映数量。我的习惯是用 SQL 完成聚合,Python 只负责画图,因为 SQL 里的GROUP BY比 pandas 的groupby在答辩时更好解释——老师一问“数据在哪分组的”,你可以直接指数据库而不是指代码。

4.3 类型占比与导演产出:两组能体现 JOIN 的高级查询

类型占比是必须用 JOIN 才能算出来的统计,直接查movies表拿不到任何类型信息。这里的核心点是:统计单位是“电影-类型”记录数,而不是“电影”数。

# 类型 TOP10 占比 sql = """ SELECT g.genre_name, COUNT(*) AS cnt FROM movie_genres mg JOIN genres g ON mg.genre_id = g.genre_id GROUP BY g.genre_name ORDER BY cnt DESC LIMIT 10; """ type_rows = query_all(sql) df_type = pd.DataFrame(type_rows, columns=["genre_name", "cnt"]) df_type["cnt"] = df_type["cnt"].astype(int) plt.figure(figsize=(8, 5)) plt.bar(df_type["genre_name"], df_type["cnt"]) plt.xticks(rotation=45) plt.tight_layout() plt.savefig("report/genre_top10.png", dpi=150)

这里 COUNT 统计的是关联记录数。一部电影同时挂“剧情”和“爱情”两个类型,它会在“剧情”和“爱情”各被计数一次。这符合业务逻辑——类型占比反映的是类型标签的覆盖广度,而不是电影数量的分配比例。如果你要算“每部电影的平均类型数”,那就用COUNT(*) / COUNT(DISTINCT mg.movie_id),这两个口径的差别是答辩时的高频问题。

导演产出分析看的是头部效应:哪几位导演作品多、平均评分高。这个查询需要三表 JOIN,同时用AVG(rating_score)做聚合,正好把整份作业里最难的部分集中展示出来。记得在最终报告里截取查询结果的表格,光说“我查了”没有说服力,把前五行贴出来。

5. 大作业避坑实录:五个让数据系统翻车的经典问题

5.1 中文乱码:Excel 打开 CSV 全是乱码,程序里读出来也是乱码

现象:生成的movies.csv用 Excel 打开后中文全是问号或乱码,pandas 读回来再写入数据库,查出来还是乱码。

原因:CSV 文件用的是 UTF-8 编码,而老版本 Excel 默认按 GBK/ANSI 打开;反过来,如果数据源是 GBK 编码,Python 默认读取方式又解不对。

解决:写入 CSV 时统一用encoding="utf-8-sig",读取时不要省略编码参数。数据库入库前,先在 Python 里把全部字符串字段做一次.strip()和编码归一,我习惯写成str(text).encode("utf-8").decode("utf-8"),能提前炸掉大部分隐藏的非法字符。

5.2 外键约束不生效:删了电影,评分记录却还删不掉

现象:在 SQLite 里执行DELETE FROM movies WHERE movie_id = 10,死活报错“外键约束失败”,可你在建表时明明写了FOREIGN KEY。

原因:SQLite 有个反直觉的默认行为——外键约束默认是关闭的,必须在每次连接后执行PRAGMA foreign_keys = ON。没开这个开关,你建表时的REFERENCES只是摆设,数据随便乱插,等到想删数据时才突然被拦住。

解决:get_conn() 里连接后立刻执行conn.execute("PRAGMA foreign_keys = ON"),并且建表 SQL 的第一行也写上。两条都加,确保不管走哪条路径连接数据库,外键都是开着的。这个问题极其隐蔽,排查半天往往就栽在这句 PRAGMA 上。

5.3 JOIN 结果翻倍:类型占比加起来超过 100%

现象:在“类型占比”分析里,把各类型百分比一加,发现是 142%,明显不对。

原因:一部电影挂了三个类型,统计类型时它被计了三次。条形图里每个柱子的“记录数”本身没错,但如果你把它当成“电影数”去解释,总和必然超过 100%。

解决:分清统计口径。展示类型标签覆盖度就用COUNT(*),并明确标注单位是“记录数”;展示电影数量就用COUNT(DISTINCT movie_id)。我在 4.3 节写的 SQL 用的是前者,但代码注释里一定要说明白,否则答辩时这个问题容易变成唯一被追问的点。

5.4 数据写进去了,查询查不到:提交时机不对

现象:脚本执行完没有报错,检查数据库文件也确认连接的是同一个路径,但另一个脚本查不到刚才插入的记录。

原因:sqlite3 默认开启了事务,执行INSERT或UPDATE后如果没有conn.commit(),数据只停留在当前连接的内存视图里,其他连接看不见。

解决:所有写操作最后都要conn.commit(),或者用上下文管理器。我建议把写操作封装成一个函数,函数末尾统一提交、统一关连接。只要保证“提交和关闭”永远成对出现,这个坑就不会反复踩。

5.5 换台电脑就跑不了:数据库文件路径写死

现象:代码在自己电脑上一切正常,拷到答辩机器上直接报错“no such table”,因为程序找不到film.db文件了。

原因:代码里写的是相对路径sqlite3.connect("film.db"),这个路径取决于命令行的工作目录,而不是代码文件所在目录。换一台电脑,工作目录一变,连接就指向了一个不存在的空数据库文件。

解决:基于代码文件位置定位数据库路径,用BASE_DIR = os.path.dirname(os.path.abspath(__file__)),再拼上DB_PATH = os.path.join(BASE_DIR, "data", "film.db")。这样不管从哪个目录启动脚本,数据库路径都稳定指向项目内部,不会出现“代码对,路径错”的尴尬。

6. 文档与操作报告怎么收尾:结构、ER 图和测试用例一个不少

最后一段实操是文档说明与操作报告的整理。操作报告和论文不一样,不需要大量理论铺垫,但每一个小标题都要能回答“做了什么、怎么做的、结果如何”。我习惯用这个结构:需求分析与功能设计、数据库设计(附 ER 图)、系统实现说明、测试记录、使用说明、心得体会。其中 ER 图是老师必看的内容,用绘图工具画出六张表的关系即可,核心是标清楚movie_genres和movie_directors两张桥表的多对多连接。

测试记录不要写“测试全部通过”这种空话,用一张表格列 4~5 个具体用例:插入合法电影、插入重复电影、查询某年评分最高的电影、删除被评分引用的电影。每条记录对应的 SQL 执行结果和预期是否一致。这张表能让你的报告立刻和其他人拉开差距,因为这已经不是“我做完了”,而是“我验证过了”。

实体关系示意(文本版): movies 1 — n movie_genres n — 1 genres movies 1 — n movie_directors n — 1 directors movies 1 — n ratings

使用说明部分请写清楚启动顺序:先运行数据生成脚本,再运行建表脚本,然后运行导入脚本,最后运行分析脚本生成图表。这一步看起来简单,但很多人的文档省略了“先运行数据生成脚本”,导致别人拿到代码后直接报错,误以为程序有 bug。我现在的习惯是先按顺序跑一遍交付物里的全部脚本,确认能复现结果,再去整理文档。我在这里吃过大亏——曾经以为自己的代码没问题,结果换台新电脑按 README 跑,第一步就挂了。

这套流程走完,你手里的交付物就是完整的:代码、数据库文件、数据集、图表、测试记录、操作报告。别把时间花在把 SQL 写得更花哨上,数据干净、结构完整、文档对齐,这三点才是拿高分最稳的路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询