以前我接过一个练手级项目,要做的是一个基于 Django 的 Python 在线学习网站。当时对方的需求很朴素:能注册登录、能看课程视频、能记录学到哪了,最好再带点练习题和讨论区。听起来不算复杂,但真正把数据模型铺开、把权限理顺、把播放进度和缓存策略串起来之后,才发现一个看似"标准"的学习平台,藏着不少值得抠细节的地方。
这篇文章我就把这个项目的设计与实现过程完整拆开,从需求分析、技术选型、数据库模型,到核心模块编码、性能优化和部署踩坑,按我实际动手的顺序来讲。想做课程类网站、或者准备把 Django 从入门往进阶推的朋友,可以直接照着这条路走一遍。
1. 需求盘点:一个学习网站到底要处理哪些"看不见"的逻辑
拿到这类项目,第一件事千万别急着startapp,先把需求拆清楚。前端展示、课程列表、用户注册这些已经是标配了,真正埋坑的是"学习进度"和"权限边界"这两个逻辑。
1.1 功能边界划分
我习惯把所有功能先分成三类:游客能看的、登录用户能用的、只有管理员能动的。
- 游客:课程列表页、课程详情页的简介部分、讲师介绍。
- 普通用户:注册登录、加入课程、记录学习进度、收藏课程、提交练习题、在讨论区发帖回帖。
- 管理员(或讲师角色):课程上传、章节管理、视频资源维护、练习题录入、查看全站学习数据。
这样划分之后,后面的路由设计、视图函数权限装饰器、甚至菜单栏的渲染条件都能直接对号入座。很多半途烂尾的项目,问题都出在"任何角色都能打开任何页面",到后期改权限改到崩溃。
1.2 学习进度不只是"看完没看完"
如果只是给课程加一个"已完成"字段,那这个网站基本没法用于真实教学场景。我当时的做法是把进度拆成两层:课程层级和章节层级。
用户点击某一个章节的视频,系统就记录一次行为,包括章节 ID、用户 ID、最后观看时间、已观看时长、视频总时长。只有某个章节的已观看时长占比较合理(比如超过 80%),才把该章节标记为"已完成";当一门课的所有章节都完成,课程才算结业。
这样设计的好处是,后续可以很自然地扩展出"继续学习"按钮:用户下次登录,直接定位到上次没看完的那个章节,而不是重新从第一节课开始。这个交互虽然只是一个小查询,但对学习体验的提升非常明显。
1.3 用户角色的扩展性
实际开发时,我并没有把用户角色做死在某一个字段里。Django 自带的auth_user表有一个is_staff字段,但这个字段偏向"能否登录管理后台"。
做在线学习网站,更合理的做法是单独加一个 Profile(用户档案)表,存角色类型,比如student、teacher、admin。为什么不用 Django 自带的三元权限硬套?因为讲师需要上传课程但不需要管理全站,而运营人员可能需要查看用户数据但不能改课程内容。自定义角色字段,配合 Django 的 Group 和 Permission,才能做到既灵活又不失控。
2. 技术选型:Django 在这个场景下的取舍逻辑
选择 Django,不是因为它"重"或者"全家桶",而是这个项目场景需要的东西,Django 默认就给得差不多。但我仍然做了几个关键补充,避免"全家桶能跑但难以扩展"的问题。
2.1 核心框架组合
我当时定的主力技术栈是这样的:
| 组件 | 选型 | 理由 |
|---|---|---|
| 后端框架 | Django 4.x | 自带 Admin、ORM、Auth,开发效率高 |
| 数据库 | PostgreSQL | 支持 JSONField 和全文检索,扩展空间大 |
| 前端 | Bootstrap + 原生 JS(少量 Vue 组件) | 开发期快,部署时不依赖 Node 链路 |
| 缓存 | Redis | 用于课程列表缓存、验证码存储、进度写入削峰 |
| 文件存储 | 本地 Media + 后续可切对象存储 | 课程视频用服务器本地目录,图片走 Media |
| 任务队列 | Celery + Redis | 处理视频转码、邮件通知这类慢任务 |
一个容易被忽视的点是:开发期用 Python 自带的sqlite很省事,但如果课程评论、进度记录这类表在后期快速膨胀,sqlite的并发写锁会变成很头疼的问题。我项目从第一天就用 PostgreSQL,省掉了后期迁移的麻烦。
2.2 为什么不用 Django REST Framework
有很多教程会默认搭配 DRF 做前后端分离,但我的结论是这个项目没必要。学习类网站的核心页面都是服务端渲染出来的,视频播放页需要的是首屏快、SEO 能抓到课程标题,纯前端渲染在爬虫面前是灾难。
所以我选择了传统的 Django Template + 少量 AJAX 接口的组合。真正需要异步交互的就三块:学习进度心跳上报、收藏按钮切换、讨论区回复。这三个场景并不需要一套完整的 API 体系,用JsonResponse写轻量视图足够。
2.3 认证和权限的落地细节
登录方式我做了"用户名 + 邮箱 + 手机号三种登录方式统一到一个入口",很多人第一版只做了用户名登录,后面加手机号登录时要改很多地方。我的做法是:在登录视图里先识别输入内容,如果包含@就算邮箱,如果纯数字且长度 11 位就算手机号,否则按用户名处理。
Django 自带的authenticate()默认只认用户名,所以要写一个小扩展:
from django.contrib.auth import authenticate def login_view(request): account = request.POST.get("account", "").strip() password = request.POST.get("password", "") user = authenticate(request, username=account, password=password) if user is None and "@" in account: user = authenticate(request, email=account, password=password) if user is None and account.isdigit(): user = authenticate(request, phone=account, password=password) ...这里有个细节:authenticate()能否用邮箱或手机号查用户,取决于ModelBackend是否被扩展。我写了一个自定义 Backend,在authenticate里先按邮箱找用户,再调用父类方法校验密码。
3. 数据模型设计:把学习过程抽象成可落地的表结构
数据模型是这类网站的地基。表结构如果设计得不好,后面每加一个功能都可能要动表结构。我花了整整一天梳理模型关系,最后的效果是:课程、章节、视频资源、学习记录、收藏、练习、评论等十几张表互相支撑,没有出现冗余混乱。
3.1 核心模型关系梳理
课程和章节是典型的一对多关系。我设计时没有让"视频"单独成为一张大表,而是让章节直接挂视频文件字段。
from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField(max_length=50, unique=True, verbose_name="分类名") created_at = models.DateTimeField(auto_now_add=True) class Course(models.Model): title = models.CharField(max_length=200, verbose_name="课程标题") subtitle = models.CharField(max_length=300, blank=True) category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="courses") teacher = models.ForeignKey(User, on_delete=models.CASCADE, related_name="created_courses") cover = models.ImageField(upload_to="course_covers/", blank=True) intro = models.TextField(blank=True, verbose_name="课程简介") is_published = models.BooleanField(default=False) price = models.DecimalField(max_digits=8, decimal_places=2, default=0) created_at = models.DateTimeField(auto_now_add=True) class Chapter(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="chapters") title = models.CharField(max_length=200) video_file = models.FileField(upload_to="course_videos/", blank=True) video_duration = models.IntegerField(default=0, help_text="单位秒") order = models.PositiveIntegerField(default=1) is_free = models.BooleanField(default=False, help_text="试看章节")关于on_delete策略,我用PROTECT保护分类,不希望一个分类被删后课程变孤儿;课程删除则级联删除章节,因为章节没有独立价值。
3.2 学习进度表的精妙之处
很多初学设计者会把进度字段直接放到"用户-课程"关系表里,这确实简单,却丢失了"看到第几章第几秒"的细节。我的进度表长这样:
class StudyProgress(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="study_progress") chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="progress_records") watched_seconds = models.IntegerField(default=0) is_completed = models.BooleanField(default=False) last_watched_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ("user", "chapter") verbose_name = "学习进度" class CourseEnrollment(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="enrollments") course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="enrolled_users") enrolled_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("user", "course")unique_together这行尤其重要,它保证同一个人同一章节只能有一条进度记录,避免重复写入。前端每次上报心跳,后端执行的是"存在则更新、不存在则创建"的update_or_create操作。
3.3 关联查询的性能预埋
模型设计阶段就要考虑查询性能。课程列表页如果每次都要查每个课程有多少章节、有多少人学过,N 次查询会拖垮响应。我直接在模型里加冗余计数字段:
class Course(models.Model): ... chapter_count = models.IntegerField(default=0) student_count = models.IntegerField(default=0)这些字段不在前端写入时手动维护,而是通过post_save信号或者 Celery 异步任务统一累加。虽然牺牲了一点一致性,但换来的是列表页秒开。对于学习类网站,学生人数显示略微延迟完全可接受。
4. 核心功能模块的实现细节
模型设计完,接下来是具体功能模块的编码。这个阶段最容易出现"思路都懂,写起来混乱"的情况,所以我把模块做成独立的 app:accounts、courses、progress、quiz、discussion。
4.1 注册登录增强
注册时我用authenticate加login的组合是常规操作,但有两个细节很多人没做:注册后自动登录、邮箱去重。
邮箱去重是关键。Django 默认允许不同用户使用同一个邮箱,但对学习网站来说,找回密码和登录都依赖邮箱唯一性。我在User创建之前手动查一遍User.objects.filter(email__iexact=email).exists(),存在则直接返回"该邮箱已注册"。
密码校验我坚持用 Django 自带validate_password,不要自己造密码规则轮子。内置校验器能保证密码哈希算法和后续check_password完全兼容。
4.2 视频播放页与学习进度心跳上报
视频播放页是学习网站最核心的页面。我的模板里内嵌一个<video>标签,前端每隔 15 秒向后端发一次心跳请求,带上当前播放位置:
// 伪代码示意,实际项目中我在此基础上增加了节流 let lastReported = 0; video.addEventListener("timeupdate", () => { const now = video.currentTime; if (now - lastReported >= 15) { navigator.sendBeacon("/api/progress/report/", new URLSearchParams({ chapter_id: chapterId, watched_seconds: Math.floor(now) })); lastReported = now; } });为什么用sendBeacon而不是普通fetch?因为用户可能直接关掉浏览器页面,普通 AJAX 请求在页面卸载时经常被终止。sendBeacon专门用于这种"页面快没了也要把数据发出去"的场景,学习进度丢失率大幅降低。
后端接收逻辑用update_or_create:
def report_progress(request): user = request.user chapter_id = request.POST.get("chapter_id") watched_seconds = int(request.POST.get("watched_seconds", 0)) chapter = Chapter.objects.get(id=chapter_id) total = chapter.video_duration progress, _ = StudyProgress.objects.update_or_create( user=user, chapter=chapter, defaults={ "watched_seconds": watched_seconds, "is_completed": watched_seconds >= total * 0.8, } ) return JsonResponse({"status": "ok"})这里"观看 80% 就算完成"是一个产品上的折中:很多用户最后几十秒不看,但学习内容已经掌握。你完全可以根据自己的场景调这个阈值。
4.3 权限控制:哪些课程需要购买/加入才能看
课程访问权限我分了三档:免费课程直接可看、需要加入才可看、需要购买/管理员指定才可看。实现方式是在视图里加一个统一的前置检查函数:
def can_access_course(user, course): if not course.is_published: return False if course.price == 0: return True if CourseEnrollment.objects.filter(user=user, course=course).exists(): return True return False然后在课程详情视图中提前判断,如果无权访问就跳转到课程介绍页并提示"加入课程后才能观看完整视频"。注意别把这个判断写死在每个章节视图里,而应该提取成公共函数,避免后续加权限逻辑时到处修改。
4.4 评论区与讨论区的防重复设计
讨论区我复用了一套 Django 的 Generic Relation 机制,让课程和章节都可以挂评论。这里最值得注意的不是怎么建表,而是如何防止重复提交。
实战里用户在网络差时点两次"发表"按钮,会产生两条一模一样的内容。我的处理是在前端提交后立即禁用按钮,同时后端在 3 秒内检测到同一用户对同一课程/章节的重复评论就拦截。简单有效,不需要引入复杂的幂等框架。
5. 检索与性能优化:当课程数量上规模之后
课程只有几十个的时候,性能问题完全看不出来。但当课程涨到几百、用户涨到几千、学习记录上万条后,有几个瓶颈一定会出现。
5.1 课程列表页的缓存策略
课程列表页是全站访问量最大的页面。每次打开都查一遍几十门课程,再跨表统计章节数和学生数,数据库压力上得很快。我的方案是 Redis 缓存整页 HTML,缓存时间设为 10 分钟。
具体做法不复杂:视图函数里先拼一个 cache key,比如course_list_page_{page_no}_{category_id},如果 Redis 里有对应值就直接返回,否则正常渲染后写入缓存。课程发布、分类调整时主动删除相关缓存,保证列表不会长期陈旧。
这里有个容易掉进的坑:只缓存数据库查询结果,不缓存模板渲染。HTML 渲染本身也耗时,尤其是包含权限判断和用户信息的组件。我建议直接缓存渲染后的片段,效果更明显。
5.2 搜索功能的轻量级实现
在项目初期,我不建议直接上 Elasticsearch。课程数量没到几万级别,用 PostgreSQL 自带的全文检索完全够用。
from django.contrib.postgres.search import SearchVector courses = Course.objects.annotate( search=SearchVector("title", "subtitle", "intro") ).filter(search=request.GET.get("q"))这个写法比icontains快一个量级,而且对中文分词的支持在 PG 12 之后可用zhparser插件扩展。如果不想折腾数据库插件,至少也要用title__icontains OR intro__icontains组合,别只搜标题。
5.3 列表页的分页与懒加载
分页我在项目里选择了自定义的paginate,而不是 Django 默认的Paginator直接渲染全部页码。当页数超过 10 页时只显示相邻页码,首页尾页保留,中间用省略号。这个体验细节比较影响观感。
课程封面图片全部走懒加载:
<img>@app.task def transcode_video(chapter_id): chapter = Chapter.objects.get(id=chapter_id) ...7. 安全加固与数据运维建议
网站上线后,安全是必须补上的课,特别是用户生成内容较多的平台。
7.1 用户上传内容的后缀名校验
Django 的FileField只校验扩展名,不校验真实文件内容。一个常见攻击是传一个带.py后缀的恶意脚本伪装成图片。我的做法是同时校验content_type和文件头:
from PIL import Image def validate_image(image): try: img = Image.open(image) img.verify() except Exception: raise ValidationError("无效图片文件")同时,上传目录放在media/下,并且 Nginx 配置里对 media 目录关闭 PHP/Python 执行权限,这是服务器安全的底线。
7.2 学习数据的定期备份
课程内容可以重新上传,但学习进度和用户数据一旦丢失,几乎无法恢复。我用pg_dump做每日全量备份,保留最近 14 份,同时用rsync把 media 目录增量同步到异地存储。
这里很多人会踩的坑是:只备份数据库不备份 media。数据库恢复后课程封面、视频文件全没了,等于白恢复。所以备份命令必须是数据库和媒体目录一起跑。
7.3 日志与监控
上线第一天我就加了简单的日志策略。Gunicorn 的访问日志记录到独立文件,Django 的LOGGING配置里把ERROR及以上级别单独输出到error.log。这样即使没有专业监控系统,tail -f error.log也能一眼看出线上异常。
有条件的话可以接入类似 Sentry 的异常收集服务,Django 的集成非常简单,只需要在配置里加一个 DSN。它能自动捕获未处理异常,并附上完整的请求参数和堆栈,比起自己翻日志高效太多了。
8. 从"能用"到"好用":几个值得做的进阶功能
到这里,这个基于 Django 的 Python 在线学习网站已经能稳定跑了。但如果想让项目更有竞争力,下面几个功能我建议按优先级逐步补上。
学习路径推荐。根据用户已完成的课程,结合分类标签计算下一步推荐内容。这个不用上机器学习,简单的"该分类未学课程按热度排序"就能产生不错的效果。
练习题自动判分。每个章节后挂几道单选题,用 Django 的ModelForm提交答案,视图里直接比对正确选项并累计得分。这块代码量不大,但对学习闭环的完成度提升显著。
下载课程证书。当用户完成全部章节后,允许其生成一份包含姓名和课程名的结业证明,用weasyprint渲染 PDF。这个功能特别适合 Python 培训类网站,产生的分享传播效应胜过一个普通好评。
WebSocket 实时讨论。Django Channels 可以实现在线课堂模式,用户在观看视频时发送实时弹幕和提问。不过这个功能对服务器资源消耗不小,建议确认有真实需求后再动工。
我在实际迭代中发现,每次加功能之前,都应该先回看一遍数据模型是否还能撑住。比如加上练习题后,用户每道题的作答记录要不要留存?如果要统计正确率,就得多准备一张答题明细表。这些都属于"一开始没想到,但迟早要补"的设计。
最后聊几句
做这个 Django 在线学习网站,我最深的一个体会是:框架功能再强大,也替代不了对业务逻辑的拆解。学习进度怎么定义才算合理、权限边界划到哪个粒度、视频心跳上报的频率设多少秒,这些细节才是项目质量的分水岭。
另一个体会是,课程类网站的数据一致性要求没那么苛刻,合理使用缓存和异步任务可以大幅度提升体验。不要把高并发那套"强一致"思路硬套到学习场景,否则会把自己累死,用户也感知不到价值。
如果你正准备动手做一个类似的在线学习平台,先花两天把课程体系、学习行为和角色权限这三个模型理清,后面代码写起来会顺畅很多。Django 只是实现工具,真正撑起平台的还是你能不能用表结构把"教与学"这件事描述清楚。