简介:基于Python和Django的在线课程教育平台设计源码,面向需要完成课程设计、毕业设计或希望快速搭建在线学习网站的开发者。系统基于Python 3.5与Django 1.10框架构建,集成xadmin后台管理,覆盖课程搜索、浏览、学习等核心流程,适合作为Django Web开发的完整范例。压缩包共776个文件,大小9.71MB,其中188个PNG图片提供界面素材,161个JS脚本负责前端交互,125个Python文件承载业务逻辑与视图,另有111个HTML模板、53个CSS样式表,以及SQL数据库脚本、Dockerfile、Nginx/MySQL配置等部署相关内容,结构清晰便于按模块查阅和二次开发。已有364人学习/下载,代码分层明确,从页面展示到后台管理均有覆盖,对Django初学者理解项目组织、路由配置与ORM使用尤其有帮助,也可直接复用课程模块和后台管理逻辑。
1. 用 Python 和 Django 搭在线课程平台,先把业务状态理清楚
在线课程平台这个品类,看多了会发现一个现象:最容易出问题的往往不是视频播放器,而是“谁可以看哪门课、看到什么程度”这一堆业务状态。基于 Python 和 Django 的在线课程教育平台设计源码,核心不是写出一堆能跑的 CRUD,而是把用户角色、课程层级、订单状态、学习进度这四条线串成一套不打架的模型。这篇文章按数据建模、接口权限、支付回调、后台管理、部署优化的顺序,把从零手写一套课程平台的关键决策讲清楚。适合做毕业设计、课程设计的同学,也适合想用 Python 搭一个独立课程站的开发者——照着这个思路做,改结构比改业务容易得多。
2. 课程平台的数据模型设计:用户、课程、章节与学习进度的建表思路
Django 项目里建模,最忌讳一上来就把所有字段塞进一张表。课程平台涉及四类实体:账号体系、课程内容体系、交易体系、学习行为体系。账号和内容是根基,交易和学习进度都依赖它们,所以顺序应该是先建用户扩展,再建课程结构,最后建订单和进度。
2.1 用户模型:用 OneToOne 扩展 Django 内置 User,而不是改 auth 表
Django 自带auth.User已经覆盖用户名、密码、邮箱、登录状态这些通用能力,直接改表风险很大,迁移和第三方库兼容都会出问题。常见做法是建一个Profile表,用OneToOneField和 User 绑定,把角色、头像、简介这类业务字段放进去。
# accounts/models.py from django.contrib.auth.models import User from django.db import models class Profile(models.Model): USER_ROLE = ( ("student", "学生"), ("teacher", "讲师"), ("admin", "管理员"), ) user = models.OneToOneField( User, on_delete=models.CASCADE, related_name="profile", verbose_name="关联账号", ) role = models.CharField("角色", max_length=10, choices=USER_ROLE, default="student") avatar = models.ImageField("头像", upload_to="avatar/", blank=True) bio = models.TextField("个人介绍", blank=True, help_text="讲师展示用") def is_teacher(self): return self.role == "teacher" def __str__(self): return f"{self.user.username} ({self.get_role_display()})"on_delete=models.CASCADE表示删除账号时级联删除 Profile,回填related_name="profile"后可以直接用user.profile.role读取角色,不用手动查关联表。choices配合get_role_display()方法,在模板和 admin 里都能直接显示中文角色名,这是 Django 建模里性价比最高的字段用法。
2.2 课程内容的三级结构:Course / Chapter / Video 与排序字段
课程内容一般分三层:课程、章节、视频。为什么不是课程直接挂视频?因为一门课通常有多个单元,每个单元下有多节课,如果不分层,前端展示目录和对单节课做权限控制都会很吃力。层级建模时有两个细节:章节用order字段控制排序而不是依赖id;视频表里单独放free_preview布尔字段,用来做试看。
# course/models.py class Course(models.Model): STATUS = ( ("draft", "草稿"), ("published", "已上架"), ("offline", "已下架"), ) title = models.CharField("课程名", max_length=128) subtitle = models.CharField("副标题", max_length=255, blank=True) cover = models.ImageField("封面图", upload_to="course/cover/") price = models.DecimalField("价格", max_digits=8, decimal_places=2, default=0) instructor = models.ForeignKey( User, on_delete=models.SET_NULL, null=True, related_name="teach_courses" ) status = models.CharField( "状态", max_length=10, choices=STATUS, default="draft", db_index=True ) created_at = models.DateTimeField("创建时间", auto_now_add=True) class Meta: ordering = ["-created_at"] class Chapter(models.Model): course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="chapters") title = models.CharField("章节名", max_length=128) order = models.PositiveIntegerField("排序", default=0) class Meta: ordering = ["order"] class Video(models.Model): chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="videos") title = models.CharField("视频标题", max_length=128) video_url = models.URLField("视频地址") duration = models.IntegerField("时长秒数", default=0) free_preview = models.BooleanField("是否试看", default=False) order = models.PositiveIntegerField("排序", default=0) class Meta: ordering = ["order"]注意instructor的on_delete=models.SET_NULL,讲师账号被删时课程还能保留,只是变成无主状态,后续可以在 admin 里重新指派。status字段加了db_index=True,因为课程列表页最常见的过滤条件就是status="published",索引能直接落在这条查询上。Meta.ordering统一用order字段,前端目录渲染时直接遍历关联对象,不需要再排序。
2.3 报名与学习进度:用联合唯一约束保证“一门课只报一次”
用户报名课程、记录学习进度,这是平台的行为数据核心。报名表用UniqueConstraint做联合唯一约束,防止重复报名;进度表记录用户对每个视频的学习状态,供“继续学习”功能使用。
class Enrollment(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="enrollments") course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name="enrollments") enrolled_at = models.DateTimeField("报名时间", auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint( fields=["user", "course"], name="unique_user_course" ) ] class CourseProgress(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) video = models.ForeignKey(Video, on_delete=models.CASCADE) watched_seconds = models.IntegerField("已看秒数", default=0) completed = models.BooleanField("是否看完", default=False) updated_at = models.DateTimeField("更新时间", auto_now=True) class Meta: unique_together = ("user", "video")unique_together和UniqueConstraint的区别在新代码里越来越小,但前者写起来更短。进度表只存“用户 + 视频 + 秒数”,不冗余课程 ID——因为视频通过章节关联到课程,查询时用video__chapter__course三层关联就能带出,避免字段重复导致数据不一致。
3. 用 Django REST Framework 写课程接口,把讲师和学生的权限分清楚
课程平台的接口设计,核心矛盾是同一份数据对不同身份的人要返回不同内容。学生只能看到已上架课程,讲师能管理自己的课程但不能动别人的,课程详情页还要区分试看视频和付费视频。这些逻辑用 DRF 的ViewSet加自定义get_queryset就能收拢。
3.1 创建 app 与路由命名:django 项目里先跑通 startapp 和 reverse resolve
拿到项目后先确认应用划分。常见划分是accounts、course、trade、operation四个 app,分别放账号、课程、订单支付、学习行为。创建命令很简单:
python manage.py startapp course python manage.py startapp trade每个 app 建好后记得到settings.py的INSTALLED_APPS里注册,否则makemigrations根本扫不到新模型。路由层面,项目根路由用include挂载各 app 的路由,并给每个 app 设置命名空间,这样reverse("course:course-list")才能稳定解析。这里要用到reverse和resolve做 URL 反向解析——前端跳转、支付回调、邮件通知里都靠它生成链接,而不是手拼 URL 字符串。
# config/urls.py from django.urls import include, path urlpatterns = [ path("api/v1/course/", include("course.urls", namespace="course")), path("api/v1/trade/", include("trade.urls", namespace="trade")), ]命名空间一旦定下,代码里就不要改。resolve(request.path)可以反查当前请求命中的视图名,在日志和权限判断里都很有用。
3.2 课程列表与详情:get_queryset 按身份过滤数据
课程列表接口要满足两种场景:首页展示已上架课程,讲师后台展示自己创建的所有课程。同一个 ViewSet 里通过get_queryset按身份分流,是最简洁的写法。
# course/views.py from rest_framework import viewsets from rest_framework.permissions import IsAuthenticatedOrReadOnly class CourseViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = CourseSerializer permission_classes = [IsAuthenticatedOrReadOnly] def get_queryset(self): user = self.request.user if user.is_authenticated and user.profile.role == "teacher": return Course.objects.filter(instructor=user) return Course.objects.filter(status="published")IsAuthenticatedOrReadOnly保证未登录用户只能读不能写。讲师身份走filter(instructor=user),自然把他限制在自己的课程范围里。注意这里判断角色用的是user.profile.role——如果 Profile 还没创建会抛异常,所以注册逻辑里要用get_or_create兜底。
3.3 学习进度上报与“继续学习”接口
进度上报是典型的高频写接口,学生每看几十秒就要上报一次。接口设计要轻:只接收video_id和已看秒数,服务端负责更新或创建进度记录。
# operation/views.py from rest_framework.decorators import action from rest_framework.response import Response class ProgressViewSet(viewsets.ViewSet): @action(detail=False, methods=["post"]) def report(self, request): video_id = request.data.get("video_id") seconds = request.data.get("seconds", 0) progress, _ = CourseProgress.objects.update_or_create( user=request.user, video_id=video_id, defaults={"watched_seconds": seconds}, ) return Response({"code": 0, "message": "ok"})update_or_create把“查 + 改 + 建”合成一步,天然适合进度上报。这里只更新秒数,completed字段可以交给前端在视频播到结尾时上报一个标记接口,或者后端定时任务扫描秒数大于等于视频时长的记录。不要在前端直接传completed=true,抓包就能伪造。
3.4 登录后的回跳:redirect 中携带 next 参数传递目标地址
学生访问课程详情页时未登录,系统应先跳登录页,登录成功后再带回原页面。Django 的LoginView原生支持next参数,但前后端分离项目里更常见的是前端把redirect_url存在 localStorage,登录接口返回 token 后再跳转。服务端渲染场景下这么处理:
from django.shortcuts import redirect from django.urls import reverse def course_detail(request, course_id): if not request.user.is_authenticated: login_url = f"{reverse('accounts:login')}?next={request.path}" return redirect(login_url)next参数在后端接收时要校验:只允许站内路径,防止开放重定向。用urlparse检查 next 是否以/开头且不含http前缀,或者直接用 Django 的url_has_allowed_host_and_scheme函数做安全校验。
4. 订单、支付回调与内容访问权限:状态机是交易系统的地基
交易模块最容易踩的坑,是把支付状态只做成前端的一个布尔值。真实场景里支付会超时、会回调重复、会对账失败,这些都要靠订单状态机来兜底。在线课程是虚拟商品,下单后不需要物流流转,状态机相对简单,但边界条件一个都不能少。
4.1 订单模型与支付状态机(待支付 / 已支付 / 已关闭)
订单状态设计成四个即可:待支付、已支付、已关闭、已退款。待支付订单超过 30 分钟未完成支付,定时任务把它置为已关闭;关闭后用户重新点击购买,要新创建订单而不是复用旧单。
# trade/models.py class Order(models.Model): STATUS = ( ("pending", "待支付"), ("paid", "已支付"), ("closed", "已关闭"), ("refunded", "已退款"), ) order_no = models.CharField("订单号", max_length=64, unique=True) user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="orders") course = models.ForeignKey(Course, on_delete=models.CASCADE) amount = models.DecimalField("支付金额", max_digits=8, decimal_places=2) status = models.CharField( "状态", max_length=10, choices=STATUS, default="pending", db_index=True ) created_at = models.DateTimeField("创建时间", auto_now_add=True) paid_at = models.DateTimeField("支付时间", null=True, blank=True)状态流转规则用一张表说清楚:
| 当前状态 | 触发动作 | 目标状态 | 说明 |
|---|---|---|---|
| pending | 用户发起支付 | pending | 生成支付链接,不改变状态 |
| pending | 支付回调成功 | paid | 校验金额后落库 |
| pending | 超时未支付 | closed | 定时任务批量关闭 |
| paid | 用户申请退款 | refunded | 人工审核后操作 |
| paid | 重复回调 | paid | 幂等处理,直接返回成功 |
order_no是业务主键,生成规则不要用自增 ID,避免暴露订单量。常见做法是日期前缀 + 用户 ID + 随机串,例如20250101 + 10086 + 6位随机码。
4.2 支付回调验签:为什么不能信任前端传回的支付结果
前端传“支付成功”没有任何可信度,正确做法是服务端接收支付平台的异步回调,验签后更新订单状态。
# trade/views.py import hashlib import hmac def pay_callback(request): data = request.POST.dict() sign = data.pop("sign", "") # 按支付平台规则把参数按字典序拼接,用商户密钥算签名 raw = "&".join(f"{k}={v}" for k, v in sorted(data.items())) expect = hmac.new(PAY_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(sign, expect): return Response({"code": "fail"}) order = Order.objects.filter(order_no=data["order_no"]).first() if order and order.status == "pending" and Decimal(data["amount"]) == order.amount: order.status = "paid" order.paid_at = timezone.now() order.save(update_fields=["status", "paid_at"]) Enrollment.objects.get_or_create(user=order.user, course=order.course) return Response({"code": "success"})代码逻辑拆成三段:compare_digest做签名比对,避免直接==比较字符串带来的时序侧信道问题;金额必须强校验,回调里的金额要和订单金额完全一致,单位也要对齐;get_or_create报名关系放在支付成功之后,这一步是“付款即开课”的落点。整个回调必须是幂等的——支付平台可能重发回调,重复收到时订单已经是 paid,直接返回成功即可。
提示:本地联调支付回调时,用内网穿透或者直接在测试环境里手动构造回调数据。不要改线上支付平台的回调地址,验签失败就返回失败,让平台重试。
4.3 视频地址的下发策略:对象存储签名 URL 与 Nginx 防盗链
视频文件不建议存 Django 的 media 目录,Django 开发服务器处理大文件流式传输性能很差。常见做法是把视频放在对象存储或独立文件服务器上,数据库里存真实地址,接口下发时再决定返回什么。
# course/serializers.py class VideoSerializer(serializers.ModelSerializer): play_url = serializers.SerializerMethodField() class Meta: model = Video fields = ["id", "title", "duration", "free_preview", "play_url"] def get_play_url(self, obj): # 试看视频直接返回,付费视频需校验报名关系 if obj.free_preview: return obj.video_url request = self.context["request"] user = request.user enrolled = Enrollment.objects.filter( user=user, course=obj.chapter.course ).exists() if user.is_authenticated else False return obj.video_url if enrolled else ""付费视频的判定逻辑集中在序列化器里,前端拿到play_url为空字符串就知道要弹出购买引导。视频文件的真实 URL 不要直接暴露给前端,走签名 URL 或者 Nginx 内部跳转都有有效期控制,能防止链接被转发给未付费用户。
5. Django admin 后台改造与前后端分离项目的认证适配
管理后台是课程平台运营的日常入口,讲师审核、课程上下架、订单查询都在这里完成。Django 自带的 admin 功能够用但不够好看,调整的重点放在信息密度和操作效率上。
5.1 后台列表页优化:list_display / search_fields / list_filter 组合
默认的 admin 列表页只展示__str__返回值,课程量大了以后根本没法用。三个属性组合起来,就能把列表页变成可筛选的内容管理台。
# course/admin.py from django.contrib import admin @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ("title", "instructor", "price", "status", "created_at") list_filter = ("status", "instructor") search_fields = ("title", "subtitle", "instructor__username") list_editable = ("status",) readonly_fields = ("created_at",)search_fields里写instructor__username,就能支持按讲师账号搜索关联字段。list_editable让“上架/下架”直接在列表页下拉切换,不用每次点进详情页,运营效率明显提升。价格字段默认显示Decimal对象,看起来不够直观,可以在 admin 里加一个short_description。
5.2 admin 里处理排序与只读字段,把审核流程放回后台
5.3 前后端分离下的登录态:从 Session 切换到 JWT
如果课程平台是 Vue 或 React 前端 + Django API 的架构,登录态就不能依赖 Session。常见做法是接入djangorestframework-simplejwt,登录接口返回access和refresh两个 token,前端把 access token 放到请求头里访问受保护接口。
# settings.py REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), }切换 JWT 后要注意 token 过期时间:access token 设置 30 分钟,refresh token 设置 7 天。用户被禁言或者退款后,已经发出的 token 在有效期内依然能访问,这是 JWT 的天然短板。对在线课程平台来说影响不大,但涉及退款场景,最好在关键接口里再查一次订单状态做二次校验。
5.4 用 SimpleUI 调整 admin 界面,避免每次演示都打开默认样式
Django 原生 admin 的样式十几年没大变过,给客户或导师演示时观感一般。django-simpleui是目前最常见的免费美化方案,安装后在INSTALLED_APPS里放在django.contrib.admin之前即可覆盖默认模板。它自带菜单折叠、图标、首页看板,能直接把课程、订单、用户这几个常用表放到侧边栏一级入口。
6. 部署上线与课程列表查询优化:先看 SQL 再上缓存
6.1 环境准备:Python、Django、MySQL 驱动的版本组合
部署环境先确认版本组合,常见搭配是 Python 3.10 + Django 4.2 LTS + MySQL 8.0。MySQL 驱动在 Linux 上需要系统依赖,先装再配:
sudo apt install python3-dev default-libmysqlclient-dev build-essential pip install mysqlclient6.2 Nginx + uWSGI 跑 Django 的最小配置
课程列表页和视频元数据由 Django 处理,静态文件交给 Nginx 直出。动态请求转发给 uWSGI:
location /static/ { alias /var/www/course/static/; } location / { include uwsgi_params; uwsgi_pass 127.0.0.1:8001; }静态请求全部走 Nginx 文件系统,Django 进程只处理 API,压力小得多。
6.3 select_related / prefetch_related 消除 N+1
课程列表接口每次都查讲师姓名,如果用Course.objects.filter(status="published")再循环访问course.instructor.username,会产生 N+1 次查询。一句话就能解决:
Course.objects.filter(status="published").select_related("instructor")进度查询要用反向关联拿章节和课程,属于跨表反向集,用prefetch_related:
CourseProgress.objects.filter(user=request.user).select_related("video__chapter__course")6.4 用 djang-debug-toolbar 验证优化结果
优化完别靠感觉验收,先安装django-debug-toolbar,本地打开课程列表页,看 SQL 面板中的查询次数。未优化时是几十条,加上select_related之后理想情况是一条。确认瓶颈在 SQL 之后,再考虑给热门课程加 Redis 缓存,缓存 key 设计成course:list:published,课程上下架时主动删除缓存。
本文还有配套的精品资源,点击获取