很多人觉得做一个python网络课程在线学习平台,最费劲的是写课程内容、录视频、找老师。我最初也这么想,可真把一个平台从零搭起来之后才发现,真正的工程量藏在那些用户根本看不见的地方:课程怎么分类、视频怎么防爬、播放进度怎么记录、后台怎么让运营的人愿意用、部署之后怎么扛住流量。这篇文章就是我整个项目的复盘,从需求拆解、技术选型、数据模型设计,到视频防盗链、后台管理、性能优化,再到最后几个让我熬夜排查的坑,全都在里面了。
无论你是打算自己做个网课平台练手,还是团队里要上一个在线教育类项目,这篇文章都能给你一条可以直接参考的路线。我会把每个关键决策背后的理由讲清楚,也会把那些文档里不会写的经验教训拿出来说。有些内容可能有点长,但每一条都值得你看完。
1. 这个平台的真实起点:不是录课,而是解决"学不下去"
先说我为什么要做这个项目。当时我身边的几个朋友在搞技术培训,课程内容都是现成的,但分发方式还停留在"拉个群、发网盘链接"的阶段。问题非常明显:视频没有目录结构,学员不知道从哪开始学;看没看完完全靠自觉,没有进度记录;课程更新了也没法通知到人。更麻烦的是,网盘链接很容易被转发扩散,辛苦录的课白白流失。所以他们就想要一个像样的、带账号体系的学习平台,至少学员能登录、能看到自己学到哪了、课程能有序排列、付费后能稳定观看。
这个需求听起来很简单,但真要落地,牵扯的东西比想象中多得多。我给这个项目定了三个核心目标:
第一个目标是"学得下去"。用户打开平台,能清楚看到课程目录、章节顺序、每个视频的时长,看到自己学到第几节、还剩多少节。第二个目标是"管得起来"。后台必须能方便地上架、下架、修改课程,能看到每个用户的观看记录和学习进度,甚至能看出哪些课最受欢迎。第三个目标是"防得住盗录"。视频链接不能被简单复制走、不能被网盘二次转存、不能被人拿到直链后绕过平台直接播放。
带着这三个目标,我开始画功能清单。第一版砍掉了论坛、问答、直播、考试这些看起来很有吸引力但周期过长的东西,只保留核心链路:用户注册登录 -> 浏览课程列表 -> 查看课程详情和章节 -> 下单支付 -> 在线播放并记录进度 -> 后台管理课程和用户。这套链路跑通了,再考虑别的。说句实在话,很多网课项目死就死在功能规划太贪心,第一版就想什么都做,结果核心体验反而没打磨好。
2. 技术选型的现实答案:框架对比与我最终的选择
2.1 为什么不是FastAPI,也不是Flask
这个项目虽然不是高并发场景,但选型也不能拍脑袋。我在后端框架上认真对比过几个主流选项,Django、Flask、FastAPI都考虑过,差别其实集中在几个点上。
Flask的优势是轻量自由,但自由也意味着所有东西都要自己拼:ORM得自己选、Admin后台得自己搭、迁移工具得自己配、登录认证得自己写。对于一个需要快速出活的网课平台来说,光是这些基础设施就能耗掉大量时间。
FastAPI性能好、写起来爽,异步支持也强,但它的生态相对年轻,第三方库在版本兼容上偶尔会踩到坑。而且FastAPI默认不带Admin和ORM的完整方案,做内容管理类的系统,效率反而不如成熟框架。
Django在这类场景下几乎是作弊器。它自带Admin后台、自带ORM、自带迁移机制、自带用户认证体系,还有一整套成熟的中间件生态。网课平台本质上是内容管理系统加订单系统,Django的model定义和管理后台能让我们把大量精力从"造轮子"里解放出来,集中去处理课程播放、支付、防爬这些真正的痛点。我最后选的是Django 3.2 LTS版本,稳定支持周期长,第三方库兼容性好,网上能查到的资料也多。
前端我没有上前后端分离架构。理由很务实:这个项目大部分页面是课程展示、播放、后台管理,交互复杂度没有高到需要Vue或React单独搭一套工程的程度。用Django模板加Bootstrap 5,配合少量原生JavaScript,完成度足够,开发和维护成本却低得多。如果是团队里有专门的前端开发、或者后续要做移动端App接口,那再升级成前后端分离也不迟。项目不同阶段选型思路完全不同,千万不要为了"技术时髦"而背上不必要的复杂度。
2.2 存储方案:数据库、缓存、对象存储各管一摊
数据存储这块,我用了MySQL、Redis和对象存储三件套。MySQL存业务数据,Redis做缓存和热点数据加速,对象存储放视频文件和封面图片。
为什么视频文件不放服务器本地?很简单:视频文件体积动辄几百MB甚至上GB,放本地会占满磁盘,而且当用户量大起来之后,带宽压力全打在应用服务器上,非常容易被拖垮。对象存储的优势是自带CDN加速,按量付费,还能配置防盗链,比自建媒体服务器省心得多。数据库我用的是MySQL 8.0,Redis用6.x版本,这两个组合在稳定性上经过了大量生产环境验证,基本不会出幺蛾子。
2.3 数据模型:把"课程、章节、视频、订单、进度"串起来
数据模型是整个项目的骨架,骨架没设计好,后面写代码会越写越痛苦。我第一批设计了六张核心表,关系是这样的:
| 数据表 | 核心字段 | 作用 |
|---|---|---|
| 用户表 | 昵称、手机号、密码哈希、头像、注册时间、状态 | 承接Django自带的User模型并扩展业务字段 |
| 课程分类表 | 分类名、排序权重、父级分类 | 支持多级分类,方便做课程导航 |
| 课程表 | 标题、封面图、简介、价格、讲师、上架状态、分类外键 | 展示课程基础信息,控制上下架 |
| 章节表 | 所属课程、章节标题、排序、视频地址、视频时长、是否免费试看 | 定义课程的学习目录结构 |
| 订单表 | 用户外键、课程外键、支付金额、支付状态、交易流水号、创建时间 | 记录每一笔购买行为 |
| 学习进度表 | 用户外键、章节外键、最后观看位置、完成状态、更新时间 | 支撑"继续学习"功能 |
课程的上下架状态我用了独立的字段而不是直接删数据,这个设计非常重要。运营人员经常需要临时下架某节课进行内容修正,如果直接删数据,用户的购买记录和历史进度就全没了,所以上下架只改状态,不碰数据本身。
models.py里的核心代码大致是这样的:
from django.db import models from django.contrib.auth.models import User class Category(models.Model): name = models.CharField('分类名称', max_length=50) parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE) sort_order = models.IntegerField('排序', default=0) class Course(models.Model): STATUS_CHOICES = ((0, '下架'), (1, '上架')) title = models.CharField('课程标题', max_length=200) cover = models.ImageField('封面图', upload_to='covers/') description = models.TextField('课程简介') price = models.DecimalField('价格', max_digits=8, decimal_places=2) category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True) status = models.IntegerField('状态', choices=STATUS_CHOICES, default=0) lecturer = models.CharField('讲师', max_length=50) created_at = models.DateTimeField(auto_now_add=True) class Chapter(models.Model): course = models.ForeignKey(Course, related_name='chapters', on_delete=models.CASCADE) title = models.CharField('章节标题', max_length=200) video_url = models.CharField('视频地址', max_length=500) video_duration = models.IntegerField('视频时长(秒)', default=0) sort_order = models.IntegerField('排序', default=0) is_trial = models.BooleanField('是否免费试看', default=False) class Order(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) course = models.ForeignKey(Course, on_delete=models.CASCADE) amount = models.DecimalField('实付金额', max_digits=8, decimal_places=2) status = models.IntegerField('状态', default=0) # 0待支付 1已支付 2已取消 trade_no = models.CharField('交易流水号', max_length=128, unique=True) class StudyProgress(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE) last_position = models.IntegerField('最后观看位置(秒)', default=0) is_finished = models.BooleanField('是否看完', default=False) updated_at = models.DateTimeField(auto_now=True)一个细节是,视频时长这个字段虽然看起来不起眼,但它直接决定了课程列表页能不能展示"总时长"、学员能不能预估学习时间、以及后台能不能统计课程学习完成率。我是在视频上传后通过服务端解析自动填充的,这样运营不需要手动录入。
3. 视频防爬与播放链路:整个项目最硬的骨头
3.1 三种常见防爬方案的真实表现
视频防盗链是整个平台里我最重视的部分,因为一旦视频链接被人拿走,课程内容就相当于白送了。网上常见的防爬方案有好几种,我挨个分析过它们的真实表现。
第一种是Referer防盗链,在Nginx里检查请求来源,只有来自本站的请求才允许访问视频文件。这个方案看起来简单,但实际上非常容易绕过,因为HTTP的Referer头是客户端可以随意伪造的。用脚本模拟请求时随便带一个合法来源就能穿过这道防线,所以只能防君子,防不了小人。
第二种是IP黑白名单,限制只有平台服务器IP才能访问视频文件。但如果视频是直接在浏览器里播放的,用户的浏览器IP是不断变化的,根本没法用静态白名单。除非你给每个用户都做代理转发,那样延迟高、成本大,操作上不现实。
第三种是Token签名防盗链。对象存储和Nginx都支持这种机制:让用户携带一个带签名和有效期的URL去访问视频。签名通常由服务器用密钥对文件名加过期时间进行哈希计算得出,过期或签名不匹配就直接拒绝访问。这种方案是三种里面最靠谱的,也是我采用的主方案。
3.2 我用签名+过期时间实现的防盗链逻辑
我的视频播放流程是这样的:用户登录平台后,前端播放器请求一个播放地址接口,后端拿到请求后先检查用户有没有购买该课程(或者该章节是不是免费试看章节),确认有权限之后,再生成一个带签名的临时播放URL返回给前端。前端拿到这个URL后交给播放器加载,播放器再去对象存储拉取视频流。
签名URL的生成逻辑是拼接文件名、过期时间戳和密钥做哈希。代码大致长这样:
import hashlib, time, hmac def generate_video_url(file_key, duration=3600): """ 生成带签名的视频访问地址 duration: 有效期,单位秒 """ expire_time = int(time.time()) + duration # 这里的密钥需要与对象存储或Nginx配置中保持一致 raw_string = f"{file_key}-{expire_time}" signature = hashlib.md5(f"{raw_string}-{SECRET_KEY}".encode()).hexdigest() base_url = "https://your-cdn-domain.example.com" return f"{base_url}/{file_key}?expire={expire_time}&sign={signature}"这个方案的核心在于:URL会过期,就算有人把链接转发出去,最多也只能看到一段时间,过期之后就失效了。而且不同用户的播放请求是独立的,有效期可以设得比较短,比如15分钟到1小时,不影响正常观看,同时让扩散风险降到最低。
如果用的是Nginx层来做,还可以配合secure_link模块,在Nginx侧校验时间和签名,这样失效逻辑更前置,代码层也省心一些。我实际项目里用的是对象存储自带的签名能力,原理一样,只是密钥和规则由云侧管理。
还有一个容易被忽略的问题是,播放器拿到签名的m3u8文件后,里面的分片地址如果也是明文,整个防盗链就会变成一张废纸。所以我在上传视频时就直接处理成分片格式,每个分片的请求也走同一套签名校验,绝不让任何一层裸奔。
3.3 续播、记录、进度管理:用户看课的体验细节
账号体系建好之后,"下次继续看"是网课平台最基础也最影响口碑的功能。用户看了五分钟关掉页面,第二天再打开还从第一秒开始看,这种体验是会让人直接弃用的。
实现续播功能的核心是播放器的timeupdate事件。每过几秒,前端就把当前播放位置上报到后端接口。上报频率不能太高,不然请求量会很吓人;也不能太低,否则用户关闭页面的瞬间会丢失最后一段进度。我实际用的是每5秒上报一次,同时在页面卸载时再做一次保底上报。
后端接口要做的是"更新而不重复"。同一个用户在同一个章节上的进度记录只有一条,每次上报都是更新现有的last_position字段,同时更新updated_at。只有当观看位置超过视频总时长的90%以上时,才把is_finished标记为完成。这里有个细节值得注意:不能简单用"播放到最后一秒"判断完成,因为很多用户会跳着拖进度条,播放器报上来的位置不一定代表真的看过。严格一点的处理方式,是统计用户实际播放的时间占比,但这需要前端配合上报更多数据,第一版里我用的是90%阈值法,简单够用。
前端保存进度的核心代码大概是:
player.on('timeupdate', () => { const cur = Math.floor(player.currentTime()); // 每5秒上报一次进度 if (cur - lastReported >= 5) { fetch('/api/progress/', { method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrftoken}, body: JSON.stringify({ chapter_id: currentChapterId, position: cur }) }); lastReported = cur; } }); window.addEventListener('beforeunload', () => { // 页面关闭前做最后一次保底上报 navigator.sendBeacon('/api/progress/', JSON.stringify({ chapter_id: currentChapterId, position: Math.floor(player.currentTime()) })); });购买权限这块,Django的User模型不能直接加字段,所以我用了一对一扩展。用户下单支付成功之后,创建一条购买记录,课程详情接口查询时先检查用户是否已购买该课程。播放接口同样做权限判断:免费章节登录即可看,付费章节必须已购买,否则直接返回403。
4. 课程管理后台:内容维护者要用的顺手,才有人愿意更新
4.1 分类与上下架:比想象中复杂的"状态机"
Django自带的Admin后台拿来即用,但真正负责运营的人上手之后,反馈最多的不是"功能不够多",而是"找不到要改的东西在哪"。
课程分类这一块,我设计了一级和二级分类。一级分类比如"Python入门""Web开发""数据分析",二级分类比如"Python入门"下面再分"基础语法""爬虫实战"。分类多了以后,后台列表里如果直接显示所有分类会非常混乱,所以我给分类表加了sort_order字段,并在Admin里用树形结构展示,操作起来很清楚。
课程的上下架我特意做成一个按钮而不是直接在列表里删数据,原因前面已经说过:删除操作不可逆,一旦把课程删了,所有用户购买记录、播放进度、订单数据全都失去关联,那是事故级别的问题。所以任何课程在后台都只有"上架"和"下架"两个状态,下架只是不可见,不影响历史数据。
4.2 富文本与Markdown:编辑体验的取舍
课程详情页需要展示图文介绍,这个内容的编辑体验直接影响运营的效率。我测试过两种方案:Django Admin自带的富文本,和集成Markdown编辑器。
富文本的好处是所见即所得,非技术背景的运营人员上手零成本,缺点是粘贴过来的内容经常会带上一堆乱七八糟的样式标签,页面渲染出来的时候会有莫名的间距或字体问题。Markdown的好处是格式干净、渲染稳定,缺点是运营人员需要学习Markdown语法,并不是所有人都愿意学。
我在这个项目里做了个折中:后台用富文本编辑,存储的时候清洗掉不安全的HTML标签和样式,只在白名单标签中保留结构。渲染到前端前,再做一次消毒处理。这样运营人员写起来不费劲,前端展示也不会被乱七八糟的样式污染。清洗用到了Django自带的bleach逻辑,但实际我是在存储前用正则和标签过滤做了第一层处理,渲染时再做第二层。双保险,别嫌麻烦,因为用户发来的恶意内容可能藏在你根本想不到的地方。
4.3 视频上传的隐藏成本:格式、体积、封面
视频上传是运营最频繁的操作,也是最容易出问题的地方。上传一个几GB的原始视频文件,如果直接在后台用普通表单提交,肯定会超时或失败。我在对象存储上用了分片上传,把大文件切成小块并行上传,这种方案对大文件的友好程度高得多。同时后端在视频上传完成后,还会做几件必要的事:生成视频封面图、解析视频时长、确认转码格式。
视频格式上,Web播放兼容性最好的是MP4(H.264编码),所以如果源文件是其他格式,最好在后台触发一次转码。转码是对计算资源需求较高的操作,我采用的方案是对象存储那边配置好转码任务,文件一上传就自动触发转码,转码完成后回调平台接口,更新视频的播放地址。这样平台服务器本身不承担转码压力,运营也不用等太久。
封面图为什么重要?课程列表页如果每个课程封面上显示的都是灰扑扑的默认图片,整个平台的档次瞬间就掉下去了。我要求运营在创建课程时上传一张16:9的封面,同时在前端做了尺寸适配,大图和缩略图都有各自优化的呈现方式。
5. 部署、缓存与数据库调优:从能跑到扛得住
5.1 Gunicorn、Nginx与对象存储的配合
开发环境跑起来只是第一步,真正上线部署的时候,细节多到让人头大。我的部署架构是标准的三层:Nginx在最前面做反向代理和静态资源服务,Gunicorn跑Django应用,MySQL和Redis跑在独立的数据存储环境里。
Nginx这边,我做了两件事:一是把Django的静态文件(CSS、JS、图片)直接交给Nginx托管,不经过Python进程,这样能省掉大量无关请求对后端资源的消耗;二是配了对视频资源CDN域名的反代规则,但实际视频请求都直接走对象存储的CDN地址,不需要经过应用服务器。
Gunicorn的worker数量不是随便填的,我的建议是2 * CPU核心数 + 1个worker,然后配合同步worker模式。业务里如果有一些耗时操作(比如生成签名、查询进度),worker处理不过来时会产生排队。我后面加了一层Redis缓存,把热点接口的响应时间降了下来,这才彻底缓解了worker占用问题。
Nginx核心配置大概是:
server { listen 80; server_name your-domain.example.com; client_max_body_size 20M; location /static/ { alias /var/www/project/static/; expires 7d; } location /media/ { alias /var/www/project/media/; expires 7d; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }5.2 列表页接口从220ms到40ms的优化过程
课程列表页是整个平台访问量最大的页面,用户一进来就看它。第一版上线之后我发现,接口平均响应时间要220ms,高峰期甚至还会更高,这个延迟对用户体验的伤害很大。
排查后发现瓶颈出在两个地方:一是课程列表查询连接了分类表,每行课程都要做一次子查询,因为还没有做索引,数据库被迫做全表扫描;二是模板渲染时又对每个课程做了一次封面图片的解析,导致N+1查询问题。
第一个优化手段是加索引。给Course表的status和category_id加上了联合索引,这样上架状态的课程筛选就变成了索引查找,而不是全表扫描。第二个优化是解决N+1查询,用select_related和prefetch_related把子查询合并。第三个优化是上Redis缓存,把课程列表的JSON数据直接缓存起来,缓存失效时间设置为10分钟,同时在上架下架操作时主动清除对应缓存。
优化跑完之后,我用压测工具实测了一下,同样的请求稳定在40ms上下,响应速度比之前快了一个数量级。这个过程的经验是:先看日志和慢查询,再看代码逻辑,最后用缓存兜底,不要一上来就无脑加Redis。
5.3 定时备份与业务连续性
课程数据对平台来说是核心资产,备份这件事绝对不能省。我写了一个每天凌晨自动执行的脚本,用mysqldump对业务库做全量备份,备份文件压缩后传一份到对象存储,同时保留最近7天的备份在本地。Redis因为是缓存数据,丢了可以从数据库重建,所以没有做持久化备份,但关键的订单和进度数据全部在MySQL里,每天必须稳稳当当备份好。
万一服务挂了,恢复流程也要提前演练。我的恢复方案是:新环境启动Django、恢复MySQL备份、清空Redis缓存,整个过程大概10分钟左右。这类业务连续性的事情平时看着多余,真出了问题就是救命稻草。
6. 踩坑记录:这几件事比写功能本身更费时间
6.1 Referer防盗链形同虚设
最开始我用的是Referer防盗链,当时想得挺美:只有请求来源是本站域名才放行视频,其他全部拒绝。上线后我自己测试了一下,发现用脚本模拟带一个Referer头就能直接绕过。后来我干脆做了个小实验,把视频直链发到一个群里,结果有经验的人几分钟内就拿到了播放地址,这段视频当天就被转出去了。这次教训让我彻底明白了:Referer只是表面防护,绝对不能作为唯一的防爬手段。
后来换成签名防盗链之后,又遇到一个新的细节问题。签名URL是带查询参数的,但有些对象存储服务计算签名时不包含查询参数,导致同一个文件不同参数的URL都能访问。我排查后才确认,必须在签名算法里把完整的文件路径和参数一起参与计算,才能真正做到一个文件只认一个合法签名。
6.2 并发刷课时的数据一致性
学习进度记录看起来只是个简单更新操作,但在并发场景下也有坑。试想一下:用户同时开着两个浏览器标签看同一节课,两个页面几乎同时上报进度,后请求的那个反而覆盖掉了前一个更新的进度,导致进度乱跳。我遇到过用户反馈"我明明看到30分钟,刷新后变成20分钟",排查半天才定位到这就是并发写覆盖的问题。
修复方案很简单:更新进度时不是直接更新字段,而是加一个条件判断,只有当新上报的位置比当前记录位置更新时,才执行写入。SQL层面就是UPDATE ... WHERE last_position < new_position,这样两条并发请求不会互相覆盖成旧值。同时前端也做了防抖,同一节课在两个标签页同时播放时,只保留其中一个上报通道。
6.3 支付回调的重复通知陷阱
接支付渠道的时候,我以为调用支付接口、等用户付完款、回跳页面就完事了。结果上线后测试时发现,支付渠道的回调通知经常会发多次,而且不会按顺序保证。第一版我用"收到回调就更新订单状态"的逻辑,结果同一个订单被回调两次后,状态被覆盖,出现了订单已经支付成功但页面还显示待支付的假象。
标准做法是:订单表里维护一个支付状态字段,状态流转只能从"待支付"到"已支付",并且给订单号加唯一约束。每次收到回调先查询订单状态,如果已经是已支付就直接返回成功,不再重复处理。就算渠道方回调80次,业务逻辑也只执行一次,这就是幂等设计。这个坑几乎是每个接入支付的项目都会遇到的,越早想清楚越省心。
6.4 Debug=True开着的"隐形炸弹"
开发模式下大家都会把DEBUG=True打开,方便调试。但如果这套配置被原封不动带到生产环境,后果非常严重。有一次我在测试首页时发现页面报了个500错误,控制台里直接把整个项目的配置、数据库连接、目录结构全部打印出来了,简直像把家底摊开给人看。这不是危言耸听,一旦DEBUG=True下出现未捕获异常,Django会输出包含敏感信息的调试页,攻击者可以直接顺着这个信息往下摸。
所以生产环境部署前一定要把DEBUG设为False,并且用环境变量来管理配置,绝不能写在代码里。另外关掉调试模式后,静态资源的加载方式也会变化,开发模式里Django帮你托管静态文件,生产模式必须交给Nginx,我当时就是没意识到这点,上线后页面样式全丢了,排查了半天才发现是静态路由没配对。
这几个坑写出来,并不是为了证明我多厉害,而是想告诉你:很多问题看起来是"运气不好碰上了",其实背后都有固定套路。先把这些常见的套路摸清楚,项目的坑就能少踩一半。
做完这个平台之后,我最大的感受是:一个在线学习系统,表面上看起来是视频网站加电商,但真正决定它能不能长期跑下去的,是数据模型是否经得起演进、防爬方案是否真正落地、后台是否让运营愿意维护、以及遇到并发和边界情况时系统是否还能保持正确。如果你也要做类似的项目,我建议别急着写代码,先用几天时间把数据关系和状态流转画清楚。项目上线后再改表结构,代价真的很大。