简介:一套面向高校计算机专业毕业设计与课程设计的视频点播网站系统完整方案,基于Python与Django框架构建,覆盖后端架构、前端页面与视频内容管理核心模块,可帮助学生深入理解Web应用开发全流程。资源包共一百五十六个文件,包含四十八个HTML前端页面、四十个Python后端逻辑文件、二十个JavaScript交互脚本及五个CSS样式表,另附数据库备份文件与若干图片素材,整体压缩包大小不足四兆,结构清晰且轻量。系统设计注重模块化与规范性,下载后能够直接部署运行,无需额外环境配置;配套操作指南可引导用户依次掌握环境搭建、数据初始化、功能测试与基础定制等关键步骤。已有八十二人浏览学习,适合需要快速构建可演示项目的学生参考,有助于理论知识与工程实践相结合。
1. Django 视频点播网站:毕设选题里的“常青树”,到底值不值得做?
每年毕业设计选题季,用 Python 和 Django 做视频点播网站都是 Web 方向的高频题目。原因很直白:它既有模型设计、用户系统、后台管理这些绕不开的“必答项”,又有在线播放、搜索展示这些看得见摸得着的前台效果,一套源码讲下来,答辩时老师爱问的几类问题基本都能覆盖到。我拆过不少 Django 资源,这套基于 Python 与 Django 的视频点播网站系统,胜在链路完整:从前台首页到后台发布,从本地播放到收藏记录,正好卡在课设和毕设之间“够用但不过度”的难度档上。如果你是找毕设源码的新手,或者想拿一个完整项目练手,这套附带数据集和开发教程的资源,会是条能照着复现的捷径。
2. 从零搭出 Django 视频点播骨架:模型、路由与一个能播的首页
2.1 先建项目再建 app:Django 项目实战新手容易省略的一步
Django 的惯例是一个项目(project)里按业务拆多个 app。视频点播站规模不大,但我也建议单独建一个 video 应用,而不是把所有逻辑塞在系统自建的目录里,后面加收藏、加历史记录时不用重构。
pip install django django-admin startproject vod_site cd vod_site python manage.py startapp video第一行装 Django,装完先跑python -c "import django; print(django.get_version())"确认版本,这一步能避免后面因为版本差异踩坑。第三行生成的 vod_site 是项目配置目录,第四行生成的 video 是业务应用目录。如果你下载的资源包里有现成的依赖清单 requirements.txt,直接用pip install -r requirements.txt一次性装齐,效果相同。
接下来要把 video 注册进项目配置。打开 vod_site/settings.py,在 INSTALLED_APPS 里补上这一项:
INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.messages", "django.contrib.staticfiles", "video", # 新增业务应用 ]这段配置告诉 Django,video 这个应用里的模型、后台、模板都能被统一管理。新手常犯的错是建了 app 忘了注册,结果 makemigrations 时系统提示“No installed app with label”,其实不是代码写错,是应用列表里缺了这一行。
2.2 视频、分类、用户三张核心表:字段怎么定才不被答辩老师追问
视频点播站的模型设计,核心就三张表:分类 Category、视频 Video,还有 Django 自带 auth 模块里的 User。分类和视频是典型的一对多,一个分类下面挂多个视频,视频表里用外键指向分类。
from django.db import models class Category(models.Model): name = models.CharField(max_length=50, verbose_name="分类名") slug = models.SlugField(unique=True, verbose_name="URL别名") class Meta: verbose_name = "视频分类" verbose_name_plural = verbose_name def __str__(self): return self.name class Video(models.Model): title = models.CharField(max_length=200, verbose_name="标题") category = models.ForeignKey(Category, on_delete=models.PROTECT, verbose_name="分类") cover = models.ImageField(upload_to="covers/", verbose_name="封面") video_file = models.FileField(upload_to="videos/", verbose_name="视频文件") intro = models.TextField(blank=True, verbose_name="简介") view_count = models.PositiveIntegerField(default=0, verbose_name="播放次数") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") class Meta: ordering = ["-created_at"] verbose_name = "视频" verbose_name_plural = verbose_name def __str__(self): return self.title几个关键参数值得展开说。on_delete=models.PROTECT的意思是,只要这个分类下还有视频,就不允许删除分类,数据库层面会抛出 ProtectedError。用 PROTECT 而不是 CASCADE,是因为毕设演示时如果误删分类,CASCADE 会连视频一起删掉,数据“后悔药”都没有;PROTECT 至少逼你先处理关联视频。
upload_to="covers/"是相对路径,最终文件会存到 MEDIA_ROOT 下的 covers/ 目录,文件名由 Django 自动加随机前缀,防止重名覆盖。这里先不展开 MEDIA 配置,第 4 章避坑会专门讲。ordering = ["-created_at"]是全局默认排序,列表页不写 order_by 时也能按发布时间倒序展示。
数据集的使用方式我也提一句:这套资源里通常包含一份 videos.csv 或 JSON 格式的数据,字段和我上面的模型是对应的。批量导入的方法我放在第 6 章,那个自定义命令能让你一分钟把演示数据灌进库。
2.3 Django Admin:不写一行前端代码就能管理视频数据
视频数据怎么进数据库?最简单的方式是用 Django Admin。注册代码:
from django.contrib import admin from .models import Category, Video @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): prepopulated_fields = {"slug": ("name",)} @admin.register(Video) class VideoAdmin(admin.ModelAdmin): list_display = ("title", "category", "view_count", "created_at") list_filter = ("category",) search_fields = ("title", "intro") list_editable = ("view_count",)prepopulated_fields会根据分类名自动生成 URL 别名,比如“动漫”变成“dongman”,不用手动敲英文。list_display决定后台列表页展示哪些列,答辩演示时,你打开后台就能直接看到每个视频的标题、分类、播放量和发布时间,比进一个个详情页点效率高得多。search_fields给后台加搜索框,list_editable允许在列表页直接双击修改播放量——这个字段演示时特别好用,现场把数字改成 9999 很直观。
注册完跑python manage.py makemigrations video和python manage.py migrate,再做python manage.py createsuperuser创建管理员账号,然后python manage.py runserver,浏览器打开http://127.0.0.1:8000/admin/就能看到后台。
2.4 首页视图:把视频列表交给模板前的最后一步
后台有数据后,写首页。视图代码:
from django.shortcuts import render from .models import Video, Category def index(request): categories = Category.objects.all() hot_videos = Video.objects.select_related("category").order_by("-view_count")[:8] latest_videos = Video.objects.select_related("category").order_by("-created_at")[:12] return render(request, "video/index.html", { "categories": categories, "hot_videos": hot_videos, "latest_videos": latest_videos, })select_related("category")是提前把每一条视频的外键关联对象一次性查出来,后面模板里用到video.category.name时不会每行再发一条 SQL。:8和:12是 Python 的切片操作,直接在数据库层面用 LIMIT 限制返回条数,而不是把全表数据拉回内存再截取。
模板片段:
<div class="video-grid"> {% for video in latest_videos %} <a href="{% url 'video:detail' video.id %}" class="card"> <img src="{{ video.cover.url }}" alt="{{ video.title }}"> <h3>{{ video.title }}</h3> <span>{{ video.view_count }} 次播放</span> </a> {% endfor %} </div>模板里video.cover.url能直接用,前提是 settings.py 里配好了 MEDIA_URL 和 MEDIA_ROOT,并且主路由挂载了静态媒体路由。这两块配置缺一个,图片就是裂图,具体原因见第 4 章第 2 个坑。{% url 'video:detail' video.id %}是在模板里反查 URL,名字来自路由配置,下面一节写路由。
路由配置,video/urls.py:
from django.urls import path from . import views app_name = "video" urlpatterns = [ path("", views.index, name="index"), path("video/<int:video_id>/", views.detail, name="detail"), path("search/", views.search, name="search"), ]<int:video_id>是 URL 转换器,只匹配整数,不是数字的地址直接 404,省得在视图里自己判断。app_name = "video"给应用命名空间,模板里url 'video:detail'前半段就是它。最后在项目主路由 vod_site/urls.py 里用path("", include("video.urls"))挂载,首页就能访问了。
到这里,视频点播站的最小骨架已经转起来了。下一步要把“列表页点进去能播”这条链路补完,涉及播放器、视频文件地址和播放量统计。
3. 播放页与数据联动:原生 video 标签、JSON 接口与播放量原子更新
3.1 播放器选型:为什么毕设首选原生 video 标签
在写播放页之前,先解决一个选型问题:用什么播放器?很多教程一上来就引 video.js 或 Dplayer,但对于毕设场景,我一般建议先用原生<video>,理由见这个对比:
| 方案 | 部署成本 | 对视频格式要求 | 适合场景 |
|---|---|---|---|
| 原生 video 标签 | 零依赖,浏览器自带 | MP4/H.264 基本够用 | 课设、毕设演示、内部工具 |
| video.js | 需引 JS/CSS,可换皮肤 | 同样依赖浏览器解码 | 想快速有“专业播放器”外观 |
| HLS/切片转码 | 需要 ffmpeg 转码 + 播放器 | HLS 流 | 长视频、多码率、正式生产系统 |
三个方案里,原生标签的优势是“零部署、零接口”,浏览器直接解 MP4。代价是外观朴素——只有控制条、播放按钮、进度条。但这恰恰符合毕设评价标准:功能完整比皮肤漂亮重要。如果你想要更现代的外观,在原生 video 外层套一层 CSS 就够。如果你确实坚持用 Dplayer 之类的播放器,也不是不行,但注意播放器本身只是前端组件,不是 Django 的得分点,别让答辩重心跑偏到“我引了个播放器库”这种一句话就能带过的事情上。
唯一要注意的是编码。视频文件最好统一用 H.264 编码的 MP4,因为部分浏览器不支持 HEVC/H.265 播放,这在资源数据集里一般已经转好,但你自己补充测试视频时容易踩中。检查方式是用 ffprobe 看编码:
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of csv=p=0 test.mp4输出 h264 就放心用,输出 hevc 就先用ffmpeg -i test.mp4 -c:v libx264 -c:a aac out.mp4转一遍。这个习惯我保留到现在,能省掉很多“视频黑屏只有声音”的排查时间。
3.2 详情视图与播放页模板
观看页是视频点播站的核心页。视图逻辑:
from django.shortcuts import render, get_object_or_404 from .models import Video def detail(request, video_id): video = get_object_or_404(Video, pk=video_id) related = Video.objects.filter(category=video.category)\ .exclude(pk=video.id)\ .order_by("-view_count")[:4] return render(request, "video/detail.html", { "video": video, "related": related, })get_object_or_404在对象不存在时抛出 Http404,用户访问不存在的视频 id 时看到的是“页面不存在”而不是一片空白。filter(category=video.category).exclude(pk=video.id)是找同分类下不包括当前视频的其他视频,再按播放量取前 4 个做“相关推荐”。
播放页模板的播放器部分:
<video controls preload="metadata" poster="{{ video.cover.url }}" style="width:100%"> <source src="{{ video.video_file.url }}" type="video/mp4"> 您的浏览器不支持 video 标签 </video>preload="metadata"很关键,它告诉浏览器只预加载视频的元数据(时长、宽高),而不是一进页面就把整个视频缓冲下来。毕设演示时如果视频文件动辄几百 MB,不写这个属性,页面加载会明显变慢。poster在视频没有播放前显示封面图,给页面一个视觉落点。width:100% 保证播放器宽度自适应,这个样式可以根据你的前端模板风格调整。
同分类推荐的模板可以放在播放器下方:
<div class="related-list"> {% for item in related %} <a href="{% url 'video:detail' item.id %}"> <img src="{{ item.cover.url }}" alt="{{ item.title }}"> <span>{{ item.title }}</span> </a> {% endfor %} </div>相关推荐这块逻辑不用做太复杂,同分类按播放量取前 4 条就够。答辩老师如果追问“推荐算法怎么设计的”,你可以顺势说明这是基于内容的热度推荐,再往深了做可以加权用户行为,但毕设演示到这里已经能自圆其说。
3.3 一个返回 JSON 的视频详情接口
前端页面写完了,再加一个 JSON 接口。这个接口在毕设中的价值有两块:一是答辩时老师问“如果以后要做小程序端,接口怎么复用”,你可以直接现场调用;二是方便调试,不用开页面就能核对视频数据。
from django.http import JsonResponse def video_api(request, video_id): video = get_object_or_404(Video, pk=video_id) return JsonResponse({ "id": video.id, "title": video.title, "cover": video.cover.url if video.cover else "", "video_url": video.video_file.url if video.video_file else "", "view_count": video.view_count, "category": video.category.name, })注意这里返回中文时,JsonResponse 底层使用 json.dumps,默认会把中文转成 \uXXXX 形式;如果希望接口里直接显示中文,把参数改成:
return JsonResponse(data, json_dumps_params={"ensure_ascii": False})我一般用第二种写法,因为调试时肉眼可读。video.cover.url在文件字段为空时会报错,所以加了if video.cover else ""的兜底;实际数据集里这些字段一般都有值,但接口最好保持健壮。
路由挂载也就一行:
path("api/video/<int:video_id>/", views.video_api, name="video_api"),调试接口用 curl 更直观,终端里执行curl http://127.0.0.1:8000/api/video/1/能看到 JSON 返回,再配合 jq 工具格式化,比浏览器里看原始响应舒服得多。
3.4 播放量统计:F 表达式解决并发覆盖
播放量是视频点播站的“面子指标”,每一集详情页打开都应该加 1。最直观的写法是:
video.view_count += 1 video.save()这个写法在并发下会出问题。两个请求同时读到 view_count=100,各自加 1 后分别写回 101,实际上播放了两次,数字却只加了 1。用 F 表达式改成数据库层面的原子操作:
from django.db.models import F def detail(request, video_id): video = get_object_or_404(Video, pk=video_id) Video.objects.filter(pk=video.id).update(view_count=F("view_count") + 1) video.refresh_from_db() # 后续渲染用最新的 video 对象F("view_count") + 1生成的是 SQL 里的UPDATE ... SET view_count = view_count + 1,自增操作发生在数据库内部,不经过 Python 读改写,两个并发请求也不会互相覆盖。refresh_from_db()是让当前对象重新从数据库拉一次,这样渲染模板时video.view_count就是加完以后的最新值。
如果想让播放量统计更精确,比如剔除掉同一用户反复刷新,就需要引入 session 或 cookie 标记,毕设一般做到 F 表达式这层已经够讲清楚“并发安全”这个点。下一章我会把最容易翻车的几个配置问题系统过一遍。
4. Django 视频点播避坑指南:迁移、媒体文件、N+1 与删除对象的五个坑
4.1 坑一:运行时报 no such column,问题出在迁移文件
现象:代码里明明加了view_count字段,刷新页面却报OperationalError: no such column: video_video.view_count。
原因:改完 models.py 没有生成并执行迁移,数据库表还是旧结构。
解决:
python manage.py makemigrations video python manage.py migrate第一行生成迁移文件,第二行把改动应用到数据库。如果是在原有数据上改字段,migrate 时 Django 会要求你给非空字段设默认值,此时可以在命令行里选 1,手动输入default=0,或者先给字段加default=0再迁移。血泪经验是:不要为了省事直接删数据库重新建,毕设数据少还好,数据一旦多,重建的成本远大于写一条迁移。
4.2 坑二:MEDIA_ROOT 配了,图片和视频还是不显示
现象:Django Admin 里能看到封面图和视频文件,前台列表页<img>标签却是裂图,控制台报 404。
原因:MEDIA_URL 和 MEDIA_ROOT 配置不对,或者主路由没有挂载媒体文件服务。
配置分两部分,settings.py:
MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media"主路由 vod_site/urls.py:
from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... 项目路由 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)if settings.DEBUG意思是只在开发模式下由 Django 托管媒体文件,生产环境交给 Nginx/Apache。这两个配置缺一不可:没有 MEDIA_URL,模板里.url不知道拼成什么前缀;没加 static 路由,请求 /media/xxx 走到 Django 时没有对应的 view 来处理。那一次我在一个老项目里排查了半天,最后发现是落了一行django.conf.urls.static的导入。
4.3 坑三:列表页越查越慢,25 条 SQL 的 N+1 问题
现象:首页明明只有 12 个视频卡片,用 Django Debug Toolbar 一查,执行了 25+ 条 SQL。
原因:模板里访问了video.category.name,而查询时没有 select_related,每条视频都单独查一次分类表。
解决:在视图里主动预取关联对象。
videos = Video.objects.select_related("category").order_by("-created_at")[:12]select_related 用 JOIN 把分类信息一次性查出来,SQL 数量从 25 降到 1。适用边界记住两条:select_related 只能用在 ForeignKey 和 OneToOneField 这种“一对一/多对一”的关联上;如果是 ManyToMany 多对多,要用 prefetch_related。后面做“猜你喜欢”时,如果关联表是多对多,别用错。
4.4 坑四:删除分类被 ProtectedError 拦住,到底该不该换 CASCADE
现象:Admin 后台删一个空分类没有问题,删有视频的分类时直接报ProtectedError: Cannot delete some instances of model 'Category' because they are referenced through protected foreign keys。
原因:Video.category 的 on_delete 写的是 PROTECT,数据库不允许删有关联的分类。
解决方式取决于业务:如果只是想演示“删除功能”,可以先删掉该分类下的视频再删分类;如果希望删分类时自动清空视频,就把 on_delete 改成 CASCADE 或 SET_NULL。
我的建议是保留 PROTECT。毕设里“误删全毁”比“删不掉”更难解释,前者会说清楚 PROTECT 是数据库的引用完整性保护,后者在业务上也有意义。如果你确实要改成 CASCADE,改完记得重新 makemigrations/migrate,不要只改代码不迁移。
4.5 坑五:runserver 跑大视频,拖动进度条卡成幻灯片
现象:本地python manage.py runserver打开播放页,播放器能播,但拖动进度条要缓冲好几秒,CPU 占用飙高。
原因:Django 自带开发服务器是单进程单线程模型,不适合处理大文件并发请求;视频文件动辄几百 MB,开发服务器的静态文件处理逻辑也不是为这种场景设计的。
解决分两层:
本地演示层,把视频文件压缩到合理码率,或者先用小分辨率的测试视频;部署答辩层,用 Nginx + Gunicorn/uWSGI 的方案,媒体文件交给 Nginx 直接返回。
location /media/ { alias /path/to/your/project/media/; }这段配置的含义是:所有 /media/ 开头的请求都不进 Django,直接由 Nginx 读取磁盘上的 media 目录返回。alias 后面的路径末尾斜杠要和 URL 的斜杠对应,写错会得到 404。另外,视频文件不要放在项目 Git 仓库里,毕设源码包一般也不含原片,数据集的视频路径应该可以从 CSV 里对应到 media 下。
5. 搜索、分页与用户收藏:把毕设从“能跑”做到“能讲”
5.1 关键词搜索:Q 对象组合标题、简介与分类名
视频点播站必须有搜索,不然视频多了用户只能靠翻页。搜索视图:
from django.db.models import Q def search(request): keyword = request.GET.get("q", "").strip() videos = Video.objects.select_related("category") if keyword: videos = videos.filter( Q(title__icontains=keyword) | Q(intro__icontains=keyword) ) else: videos = videos.none() return render(request, "video/search.html", { "videos": videos, "keyword": keyword, })Q 对象是为了拼 OR 条件。title__icontains=keyword对应 SQL 的LIKE '%keyword%',不区分大小写;|是 SQL 里的 OR。两个条件分别匹配标题和简介,用户搜一个词,两个字段命中都会进入结果集。videos.none()是空查询集,关键词为空时避免返回全站视频,这是搜索页和有列表页的区别。
模板里的搜索框,不要用 GET 以外的方式:
<form action="{% url 'video:search' %}" method="get"> <input type="text" name="q" value="{{ keyword }}" placeholder="搜索视频标题或简介"> <button type="submit">搜索</button> </form>用 GET 而不是 POST,是为了让搜索结果可以收藏、可以复制链接,也符合搜索引擎的惯例。value="{{ keyword }}"让搜索框保留上一次输入,这个小细节经常被忽略,但答辩演示时很加印象分。
搜不到结果时给一个友好提示:
{% if videos %} <!-- 遍历结果 --> {% else %} <p>没有找到与 “{{ keyword }}” 相关的视频</p> {% endif %}这个分支很容易漏。真实演示中,随便输一个不存在的词,页面却显示空白,会被误认为功能报错。
5.2 分页:Paginator 的三种接法,我只推荐第三种
分页实现常见有三种写法,前两种坑多,只讲结论和最终代码。第一种是先Video.objects.all()全取出来,模板里用 slice 切片展示,数据量大时性能差;第二种是视图里手动paginator.page(page_number)然后自己捕获 EmptyPage 异常,代码啰嗦;第三种是用Paginator.get_page(),它内部自动处理非法页码、越界页码。
from django.core.paginator import Paginator def video_list(request, category_slug=None): videos = Video.objects.select_related("category").all() if category_slug: videos = videos.filter(category__slug=category_slug) paginator = Paginator(videos, 12) page_number = request.GET.get("page") page_obj = paginator.get_page(page_number) return render(request, "video/list.html", {"page_obj": page_obj})Paginator(videos, 12)第二参数是每页数量,毕设里 12 比较合适,列表页不至于太短,翻页效果也能展示出来。get_page(page_number)的容错行为很省心:page 参数不是整数时返回第一页,page 超出范围时返回最后一页,不会抛 500。
模板分页条:
<div class="pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">上一页</a> {% endif %} <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span> {% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}">下一页</a> {% endif %} </div>page_obj.has_previous和page_obj.has_next是分页器提供的布尔属性,第一页不显示“上一页”,最后一页不显示“下一页”,避免出现指向空页的无效链接。模板里遍历的是page_obj本身,写法同普通列表。如果你想突出显示当前页码,page_obj.number直接用。
分页还有一个容易翻车的细节:如果你在分类列表页同时带?category=xxx和?page=2,翻页链接必须把两个参数都带上,否则点击第二页会丢掉分类筛选条件,回到全站列表。
<a href="?category={{ category_slug }}&page={{ page_obj.next_page_number }}">下一页</a>这种参数拼接虽然简单,但评审老师很容易注意到“翻页后筛选状态丢失”这种细节问题。
5.3 用户收藏:get_or_create 配合唯一约束,双重防重复
收藏功能是“用户系统闭环”的起点,比单纯做登录注册更有讲头。模型:
class Favorite(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="favorites") video = models.ForeignKey(Video, on_delete=models.CASCADE, related_name="favorited_by") created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("user", "video")unique_together是数据库层的唯一约束,同一个用户对同一个视频只能有一条收藏记录。related_name 用来反查:user.favorites.all()拿到该用户所有收藏,video.favorited_by.all()拿到该视频被哪些用户收藏。
写收藏视图:
from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, get_object_or_404 @login_required def add_favorite(request, video_id): video = get_object_or_404(Video, pk=video_id) Favorite.objects.get_or_create(user=request.user, video=video) return redirect("video:detail", video_id=video.id)@login_required保证未登录用户访问会跳转到登录页,不用自己在视图里判断 request.user.is_authenticated。get_or_create先查后建,查不到才插入,配合唯一约束,重复点击收藏按钮不会产生重复数据。返回的是元组,第一项是对象,第二项是 created 布尔值,这里不需要区分,所以没有接。
收藏列表页:
@login_required def favorite_list(request): favorites = Favorite.objects\ .select_related("video__category")\ .filter(user=request.user)\ .order_by("-created_at") return render(request, "video/favorite_list.html", {"favorites": favorites})注意这里用了select_related("video__category"),双下划线跨关联,一次 JOIN 把收藏的视频和视频的分类都查回来,再次避免 N+1。面试或答辩提到这个写法,比背概念更有说服力。
5.4 历史记录:用 Session 还是建表?毕设我建议建表
历史记录有两个实现方向:存在 session 里,省表但只在当前浏览器有效;建 WatchHistory 模型,用户可以跨设备看到。毕设我建议建表,因为答辩时老师大概率会问“刷新页面、换浏览器历史记录还在吗”,建表是针对这个问题的完整回答。
class WatchHistory(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="watch_history") video = models.ForeignKey(Video, on_delete=models.CASCADE, related_name="watch_history") watched_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-watched_at"] unique_together = ("user", "video")记录逻辑放在详情视图里,每次播放都更新该用户这条记录的时间。auto_now=True在每次保存时自动刷新为当前时间,天然符合“最近观看”语义。查询历史时直接request.user.watch_history.all()[:10]就能拿到最近看的 10 条,因为 Meta 里已经按 watched_at 倒序。
演示时的操作顺序也很重要:先登录,再播放两个视频,最后打开历史记录页,数据正好按最近观看排列。未登录状态下播放视频,历史记录页里看不到内容,这不是 Bug,是设计预期,你可以主动向老师解释清楚。
到这里,搜索、分页、收藏、历史这几块已经构成一个完整的用户行为闭环。最后一章说一个能让你在答辩现场加分的收尾技巧。
6. 答辩前的五分钟检查:媒体路径、迁移记录与一个能加分的导入命令
6.1 自定义管理命令:把数据集批量导入变成一句话
毕设资源里一般会带数据集(CSV/JSON),手动一条条在后台录入很浪费时间,而且答辩时如果数据库被清了,现场重建很狼狈。我习惯在项目里写一个自定义 Django 管理命令,把导入变成一个命令行操作。
python manage.py import_videos data/videos.csv这个命令不是 Django 自带的,需要在 video/management/commands/ 目录下建文件夹和文件。目录结构:
video/ management/ __init__.py commands/ __init__.py import_videos.py命令脚本:
from django.core.management.base import BaseCommand from video.models import Video, Category import csv class Command(BaseCommand): help = "从 CSV 导入视频数据" def add_arguments(self, parser): parser.add_argument("csv_path", type=str) def handle(self, *args, **options): with open(options["csv_path"], encoding="utf-8") as f: for row in csv.DictReader(f): category, _ = Category.objects.get_or_create(name=row["分类"]) Video.objects.update_or_create( title=row["标题"], defaults={ "category": category, "intro": row.get("简介", ""), "view_count": int(row.get("播放量", 0)), }, ) self.stdout.write(self.style.SUCCESS("视频数据导入完成"))add_arguments定义了命令行参数 csv_path;update_or_create按 title 作为唯一键,存在则更新,不存在则插入,重复执行命令不会产生重复数据。导入时如果 CSV 里还有封面和视频文件的相对路径,可以继续在 defaults 里赋值cover=row.get("封面")、video_file=row.get("视频文件"),字段名和模型对应即可。
这个命令答辩现场演示效果极好:删掉全部数据,执行一行命令,数据重新回来,整个过程不到一秒。
6.2 五分钟验证清单
常规验证顺序如下,我每次交项目前都会强制走一遍:
python manage.py makemigrations --check,确认没有未生成的迁移文件python manage.py check,系统配置自检- 依次打开首页、分类页、详情页、搜索页、收藏页,确认无 404
- 后台登录、上传一个测试视频、生成封面,确认媒体文件正常写入
- 用
runserver --noreload跑一个完整流程,排除热重载掩盖的导入错误
最后再加一个我自己的习惯:查看 Django 的 SQL 日志。临时在 settings.py 加一行 LOGGING 配置,或者直接安装 Django Debug Toolbar,看列表页的 SQL 数量是否正常。如果一个页面超过 20 条 SQL,基本可以断定有关联查询没加 select_related。有一次我帮同学排查毕设,首页 12 个视频加载出 30 条 SQL,他自己完全没察觉,直到答辩预演时页面明显卡顿才意识到。从那以后,我每次建 Django 项目都会先把数据库查询计数工具装好,再开始写业务。希望帮到你。
本文还有配套的精品资源,点击获取